123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- '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)
- })
|