combine-album.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. 'use strict'
  2. import {readdir, readFile, stat, writeFile} from 'node:fs/promises'
  3. import {spawn} from 'node:child_process'
  4. import path from 'node:path'
  5. import shellescape from 'shell-escape'
  6. import {musicExtensions} from './crawlers.js'
  7. import {getTimeStringsFromSec, parseOptions, promisifyProcess} from './general-util.js'
  8. async function timestamps(files) {
  9. const tsData = []
  10. let timestamp = 0
  11. for (const file of files) {
  12. const args = [
  13. '-print_format', 'json',
  14. '-show_entries', 'stream=codec_name:format',
  15. '-select_streams', 'a:0',
  16. '-v', 'quiet',
  17. file
  18. ]
  19. const ffprobe = spawn('ffprobe', args)
  20. let data = ''
  21. ffprobe.stdout.on('data', chunk => {
  22. data += chunk
  23. })
  24. await promisifyProcess(ffprobe, false)
  25. let result
  26. try {
  27. result = JSON.parse(data)
  28. } catch (error) {
  29. throw new Error(`Failed to parse ffprobe output - cmd: ffprobe ${args.join(' ')}`)
  30. }
  31. const duration = parseFloat(result.format.duration)
  32. tsData.push({
  33. comment: path.basename(file, path.extname(file)),
  34. timestamp,
  35. timestampEnd: (timestamp += duration)
  36. })
  37. }
  38. // Serialize to a nicer format.
  39. for (const ts of tsData) {
  40. ts.timestamp = Math.trunc(ts.timestamp * 100) / 100
  41. ts.timestampEnd = Math.trunc(ts.timestampEnd * 100) / 100
  42. }
  43. return tsData
  44. }
  45. async function main() {
  46. const validFormats = ['txt', 'json']
  47. let files = []
  48. const opts = await parseOptions(process.argv.slice(2), {
  49. 'format': {
  50. type: 'value',
  51. validate(value) {
  52. if (validFormats.includes(value)) {
  53. return true
  54. } else {
  55. return `a valid output format (${validFormats.join(', ')})`
  56. }
  57. }
  58. },
  59. 'no-concat-list': {type: 'flag'},
  60. 'concat-list': {type: 'value'},
  61. 'out': {type: 'value'},
  62. 'o': {alias: 'out'},
  63. [parseOptions.handleDashless]: opt => files.push(opt)
  64. })
  65. if (files.length === 0) {
  66. console.error(`Please provide either a directory (album) or a list of tracks to generate timestamps from.`)
  67. return 1
  68. }
  69. if (!opts.format) {
  70. opts.format = 'txt'
  71. }
  72. let defaultOut = false
  73. let outFromDirectory
  74. if (!opts.out) {
  75. opts.out = `timestamps.${opts.format}`
  76. defaultOut = true
  77. }
  78. const stats = []
  79. {
  80. let errored = false
  81. for (const file of files) {
  82. try {
  83. stats.push(await stat(file))
  84. } catch (error) {
  85. console.error(`Failed to stat ${file}`)
  86. errored = true
  87. }
  88. }
  89. if (errored) {
  90. console.error(`One or more paths provided failed to stat.`)
  91. console.error(`There are probably permission issues preventing access!`)
  92. return 1
  93. }
  94. }
  95. if (stats.some(s => !s.isFile() && !s.isDirectory())) {
  96. console.error(`A path was provided which isn't a file or a directory.`);
  97. console.error(`This utility doesn't know what to do with that!`);
  98. return 1
  99. }
  100. if (stats.length > 1 && !stats.every(s => s.isFile())) {
  101. if (stats.some(s => s.isFile())) {
  102. console.error(`Please don't provide a mix of files and directories.`)
  103. } else {
  104. console.error(`Please don't provide more than one directory.`)
  105. }
  106. console.error(`This utility is only capable of generating a timestamps file from either one directory (an album) or a list of (audio) files.`)
  107. return 1
  108. }
  109. if (files.length === 1 && stats[0].isDirectory()) {
  110. const dir = files[0]
  111. try {
  112. files = await readdir(dir)
  113. files = files.filter(f => musicExtensions.includes(path.extname(f).slice(1)))
  114. } catch (error) {
  115. console.error(`Failed to read ${dir} as directory.`)
  116. console.error(error)
  117. console.error(`Please provide a readable directory or multiple audio files.`)
  118. return 1
  119. }
  120. files = files.map(file => path.join(dir, file))
  121. if (defaultOut) {
  122. opts.out = path.join(path.dirname(dir), path.basename(dir) + '.timestamps.' + opts.format)
  123. outFromDirectory = dir.replace(new RegExp(path.sep + '$'), '')
  124. }
  125. } else if (process.argv.length > 3) {
  126. files = process.argv.slice(2)
  127. } else {
  128. console.error(`Please provide an album directory or multiple audio files.`)
  129. return 1
  130. }
  131. let tsData
  132. try {
  133. tsData = await timestamps(files)
  134. } catch (error) {
  135. console.error(`Ran into a code error while processing timestamps:`)
  136. console.error(error)
  137. return 1
  138. }
  139. const duration = tsData[tsData.length - 1].timestampEnd
  140. let tsText
  141. switch (opts.format) {
  142. case 'json':
  143. tsText = JSON.stringify(tsData) + '\n'
  144. break
  145. case 'txt':
  146. tsText = tsData.map(t => `${getTimeStringsFromSec(t.timestamp, duration, true).timeDone} ${t.comment}`).join('\n') + '\n'
  147. break
  148. }
  149. if (opts.out === '-') {
  150. process.stdout.write(tsText)
  151. } else {
  152. try {
  153. writeFile(opts.out, tsText)
  154. } catch (error) {
  155. console.error(`Failed to write to output file ${opts.out}`)
  156. console.error(`Confirm path is writeable or pass "--out -" to print to stdout`)
  157. return 1
  158. }
  159. }
  160. console.log(`Wrote timestamps to ${opts.out}`)
  161. if (!opts['no-concat-list']) {
  162. const concatOutput = (
  163. (defaultOut
  164. ? (outFromDirectory || 'album')
  165. : `/path/to/album`)
  166. + path.extname(files[0]))
  167. const concatListPath = opts['concat-list'] || `/tmp/combine-album-concat.txt`
  168. try {
  169. await writeFile(concatListPath, files.map(file => `file ${shellescape([path.resolve(file)])}`).join('\n') + '\n')
  170. console.log(`Generated ffmpeg concat list at ${concatListPath}`)
  171. console.log(`# To concat:`)
  172. console.log(`ffmpeg -f concat -safe 0 -i ${shellescape([concatListPath])} -c copy ${shellescape([concatOutput])}`)
  173. } catch (error) {
  174. console.warn(`Failed to generate ffmpeg concat list`)
  175. console.warn(error)
  176. } finally {
  177. console.log(`(Pass --no-concat-list to skip this step)`)
  178. }
  179. }
  180. return 0
  181. }
  182. main().then(
  183. code => process.exit(code),
  184. err => {
  185. console.error(err)
  186. process.exit(1)
  187. })