« get me outta code hell

handy combine-album.js utility - 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:
author(quasar) nebula <towerofnix@gmail.com>2021-08-14 00:11:39 -0300
committer(quasar) nebula <towerofnix@gmail.com>2021-08-14 00:12:07 -0300
commitc047b8c57d4e5012578c072420d2b73dd5b59c4c (patch)
tree52270b0895d4bb61790aff43856ff4e3d2e78982 /combine-album.js
parent9c7e30f90f0e30535f87fbb28222c9a40940d9ec (diff)
handy combine-album.js utility
this isn't exposed via the mtui command so like, just run it directly
with node right now lol

(this commit also makes "." parse in timestamp positions)
Diffstat (limited to 'combine-album.js')
-rw-r--r--combine-album.js220
1 files changed, 220 insertions, 0 deletions
diff --git a/combine-album.js b/combine-album.js
new file mode 100644
index 0000000..9fd9cf0
--- /dev/null
+++ b/combine-album.js
@@ -0,0 +1,220 @@
+'use strict'
+
+// too lazy to use import syntax :)
+const { readdir, readFile, stat, writeFile } = require('fs/promises')
+const { spawn } = require('child_process')
+const { promisifyProcess, parseOptions } = require('./general-util')
+const { musicExtensions } = require('./crawlers')
+const path = require('path')
+const shellescape = require('shell-escape')
+
+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
+  }
+
+  let tsText
+  switch (opts.format) {
+    case 'json':
+      tsText = JSON.stringify(tsData) + '\n'
+      break
+    case 'txt':
+      tsText = tsData.map(t => `${t.timestamp} ${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 ${path.resolve(shellescape([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)
+  })