« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/combine-album.js
diff options
context:
space:
mode:
Diffstat (limited to 'combine-album.js')
-rw-r--r--combine-album.js223
1 files changed, 223 insertions, 0 deletions
diff --git a/combine-album.js b/combine-album.js
new file mode 100644
index 0000000..3b57b6c
--- /dev/null
+++ b/combine-album.js
@@ -0,0 +1,223 @@
+'use strict'
+
+import {readdir, readFile, stat, writeFile} from 'node:fs/promises'
+import {spawn} from 'node:child_process'
+import path from 'node:path'
+
+import shellescape from 'shell-escape'
+
+import {musicExtensions} from './crawlers.js'
+import {getTimeStringsFromSec, parseOptions, promisifyProcess} from './general-util.js'
+
+async function timestamps(files) {
+  const tsData = []
+
+  let timestamp = 0
+  for (const file of files) {
+    const args = [
+      '-print_format', 'json',
+      '-show_entries', 'stream=codec_name:format',
+      '-select_streams', 'a:0',
+      '-v', 'quiet',
+      file
+    ]
+
+    const ffprobe = spawn('ffprobe', args)
+
+    let data = ''
+    ffprobe.stdout.on('data', chunk => {
+      data += chunk
+    })
+
+    await promisifyProcess(ffprobe, false)
+
+    let result
+    try {
+      result = JSON.parse(data)
+    } catch (error) {
+      throw new Error(`Failed to parse ffprobe output - cmd: ffprobe ${args.join(' ')}`)
+    }
+
+    const duration = parseFloat(result.format.duration)
+
+    tsData.push({
+      comment: path.basename(file, path.extname(file)),
+      timestamp,
+      timestampEnd: (timestamp += duration)
+    })
+  }
+
+  // Serialize to a nicer format.
+  for (const ts of tsData) {
+    ts.timestamp = Math.trunc(ts.timestamp * 100) / 100
+    ts.timestampEnd = Math.trunc(ts.timestampEnd * 100) / 100
+  }
+
+  return tsData
+}
+
+async function main() {
+  const validFormats = ['txt', 'json']
+
+  let files = []
+
+  const opts = await parseOptions(process.argv.slice(2), {
+    'format': {
+      type: 'value',
+      validate(value) {
+        if (validFormats.includes(value)) {
+          return true
+        } else {
+          return `a valid output format (${validFormats.join(', ')})`
+        }
+      }
+    },
+
+    'no-concat-list': {type: 'flag'},
+    'concat-list': {type: 'value'},
+
+    'out': {type: 'value'},
+    'o': {alias: 'out'},
+
+    [parseOptions.handleDashless]: opt => files.push(opt)
+  })
+
+  if (files.length === 0) {
+    console.error(`Please provide either a directory (album) or a list of tracks to generate timestamps from.`)
+    return 1
+  }
+
+  if (!opts.format) {
+    opts.format = 'txt'
+  }
+
+  let defaultOut = false
+  let outFromDirectory
+  if (!opts.out) {
+    opts.out = `timestamps.${opts.format}`
+    defaultOut = true
+  }
+
+  const stats = []
+
+  {
+    let errored = false
+    for (const file of files) {
+      try {
+        stats.push(await stat(file))
+      } catch (error) {
+        console.error(`Failed to stat ${file}`)
+        errored = true
+      }
+    }
+    if (errored) {
+      console.error(`One or more paths provided failed to stat.`)
+      console.error(`There are probably permission issues preventing access!`)
+      return 1
+    }
+  }
+
+  if (stats.some(s => !s.isFile() && !s.isDirectory())) {
+    console.error(`A path was provided which isn't a file or a directory.`);
+    console.error(`This utility doesn't know what to do with that!`);
+    return 1
+  }
+
+  if (stats.length > 1 && !stats.every(s => s.isFile())) {
+    if (stats.some(s => s.isFile())) {
+      console.error(`Please don't provide a mix of files and directories.`)
+    } else {
+      console.error(`Please don't provide more than one directory.`)
+    }
+    console.error(`This utility is only capable of generating a timestamps file from either one directory (an album) or a list of (audio) files.`)
+    return 1
+  }
+
+  if (files.length === 1 && stats[0].isDirectory()) {
+    const dir = files[0]
+    try {
+      files = await readdir(dir)
+      files = files.filter(f => musicExtensions.includes(path.extname(f).slice(1)))
+    } catch (error) {
+      console.error(`Failed to read ${dir} as directory.`)
+      console.error(error)
+      console.error(`Please provide a readable directory or multiple audio files.`)
+      return 1
+    }
+    files = files.map(file => path.join(dir, file))
+    if (defaultOut) {
+      opts.out = path.join(path.dirname(dir), path.basename(dir) + '.timestamps.' + opts.format)
+      outFromDirectory = dir.replace(new RegExp(path.sep + '$'), '')
+    }
+  } else if (process.argv.length > 3) {
+    files = process.argv.slice(2)
+  } else {
+    console.error(`Please provide an album directory or multiple audio files.`)
+    return 1
+  }
+
+  let tsData
+  try {
+    tsData = await timestamps(files)
+  } catch (error) {
+    console.error(`Ran into a code error while processing timestamps:`)
+    console.error(error)
+    return 1
+  }
+
+  const duration = tsData[tsData.length - 1].timestampEnd
+
+  let tsText
+  switch (opts.format) {
+    case 'json':
+      tsText = JSON.stringify(tsData) + '\n'
+      break
+    case 'txt':
+      tsText = tsData.map(t => `${getTimeStringsFromSec(t.timestamp, duration, true).timeDone} ${t.comment}`).join('\n') + '\n'
+      break
+  }
+
+  if (opts.out === '-') {
+    process.stdout.write(tsText)
+  } else {
+    try {
+      writeFile(opts.out, tsText)
+    } catch (error) {
+      console.error(`Failed to write to output file ${opts.out}`)
+      console.error(`Confirm path is writeable or pass "--out -" to print to stdout`)
+      return 1
+    }
+  }
+
+  console.log(`Wrote timestamps to ${opts.out}`)
+
+  if (!opts['no-concat-list']) {
+    const concatOutput = (
+      (defaultOut
+        ? (outFromDirectory || 'album')
+        : `/path/to/album`)
+      + path.extname(files[0]))
+
+    const concatListPath = opts['concat-list'] || `/tmp/combine-album-concat.txt`
+    try {
+      await writeFile(concatListPath, files.map(file => `file ${shellescape([path.resolve(file)])}`).join('\n') + '\n')
+      console.log(`Generated ffmpeg concat list at ${concatListPath}`)
+      console.log(`# To concat:`)
+      console.log(`ffmpeg -f concat -safe 0 -i ${shellescape([concatListPath])} -c copy ${shellescape([concatOutput])}`)
+    } catch (error) {
+      console.warn(`Failed to generate ffmpeg concat list`)
+      console.warn(error)
+    } finally {
+      console.log(`(Pass --no-concat-list to skip this step)`)
+    }
+  }
+
+  return 0
+}
+
+main().then(
+  code => process.exit(code),
+  err => {
+    console.error(err)
+    process.exit(1)
+  })