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 ++++++++++++++++++++++++++++++++++++++++++++++++++++ crawlers.js | 16 ++-- package-lock.json | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + ui.js | 2 +- 5 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 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) + }) diff --git a/crawlers.js b/crawlers.js index 3f6e391..6af615d 100644 --- a/crawlers.js +++ b/crawlers.js @@ -11,6 +11,15 @@ const { promisify } = require('util') const readDir = promisify(fs.readdir) const stat = promisify(fs.stat) +const musicExtensions = [ + 'ogg', 'oga', + 'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus', + 'mp4', 'mov', 'mkv', + 'mod' +] + +module.exports.musicExtensions = musicExtensions + // Each value is a function with these additional properties: // * crawlerName: The name of the crawler, such as "crawl-http". Used by // getCrawlerByName. @@ -229,12 +238,7 @@ function getHTMLLinks(text) { } */ -function crawlLocal(dirPath, extensions = [ - 'ogg', 'oga', - 'wav', 'mp3', 'm4a', 'aac', 'flac', 'opus', - 'mp4', 'mov', 'mkv', - 'mod' -], isTop = true) { +function crawlLocal(dirPath, extensions = musicExtensions, isTop = true) { // If the passed path is a file:// URL, try to decode it: try { const url = new URL(dirPath) diff --git a/package-lock.json b/package-lock.json index 592e796..f093957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,8 +1,227 @@ { "name": "mtui", "version": "0.0.1", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "version": "0.0.1", + "license": "GPL-3.0", + "dependencies": { + "command-exists": "^1.2.9", + "expand-home-dir": "0.0.3", + "mkdirp": "^0.5.5", + "natural-orderby": "^2.0.3", + "node-fetch": "^2.6.0", + "open": "^7.0.3", + "sanitize-filename": "^1.6.3", + "shell-escape": "^0.2.0", + "tempy": "^0.2.1", + "tui-lib": "^0.2.1", + "tui-text-editor": "^0.3.1", + "word-wrap": "^1.2.3" + }, + "bin": { + "mtui": "index.js" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" + }, + "node_modules/crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dependencies": { + "clone": "^1.0.2" + } + }, + "node_modules/expand-home-dir": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", + "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" + }, + "node_modules/is-docker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", + "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz", + "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==", + "engines": { + "node": ">=8" + } + }, + "node_modules/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "engines": { + "node": "*" + } + }, + "node_modules/node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/open": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", + "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" + }, + "node_modules/temp-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", + "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "engines": { + "node": ">=4" + } + }, + "node_modules/tempy": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", + "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", + "dependencies": { + "temp-dir": "^1.0.0", + "unique-string": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tui-lib": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.2.1.tgz", + "integrity": "sha512-AHyhA9neF8tM5dAJnggKIO1W0w5pSVjuuYryp/bMJee6ol2kIzd8p4mbri0Es6/BP9bvPdYFjhSddWwzAE0TpQ==", + "dependencies": { + "wcwidth": "^1.0.1", + "word-wrap": "^1.2.3" + } + }, + "node_modules/tui-text-editor": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.3.1.tgz", + "integrity": "sha512-ySLdKfUHwxt6W1hub7Qt7smtuwujRHWxMIwdnO+IOzhd2B9naIg07JDr2LISZ3X+SZg0mvBNcGGeTf+L8bcSpw==", + "dependencies": { + "tui-lib": "^0.1.1" + } + }, + "node_modules/tui-text-editor/node_modules/tui-lib": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.1.tgz", + "integrity": "sha512-QAE4axNCJ42IZSNnc2pLOkFtzHqYFgenDyw88JHHRNd8PXTVO8+JIpJArpgAguopd4MmoYaJbreze0BHoWMXfA==", + "dependencies": { + "wcwidth": "^1.0.1", + "word-wrap": "^1.2.3" + } + }, + "node_modules/unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "dependencies": { + "crypto-random-string": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", + "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "engines": { + "node": ">=0.10.0" + } + } + }, "dependencies": { "clone": { "version": "1.0.4", @@ -82,6 +301,11 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" + }, "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", diff --git a/package.json b/package.json index 421b0a4..e3ea74d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "node-fetch": "^2.6.0", "open": "^7.0.3", "sanitize-filename": "^1.6.3", + "shell-escape": "^0.2.0", "tempy": "^0.2.1", "tui-lib": "^0.2.1", "tui-text-editor": "^0.3.1", diff --git a/ui.js b/ui.js index 2599427..2da7e02 100644 --- a/ui.js +++ b/ui.js @@ -1187,7 +1187,7 @@ class AppElement extends FocusElement { const duration = (metadata ? metadata.duration : Infinity) const data = lines - .map(line => line.match(/^\s*([0-9:]+)\s*(\S.*)\s*$/)) + .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/)) .filter(match => match) .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]})) .filter(({ timestamp: sec }) => !isNaN(sec)) -- cgit 1.3.0-6-gf8a5