From c047b8c57d4e5012578c072420d2b73dd5b59c4c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 14 Aug 2021 00:11:39 -0300 Subject: 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) --- combine-album.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 combine-album.js (limited to 'combine-album.js') 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) + }) -- cgit 1.3.0-6-gf8a5