« 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
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
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)
-rw-r--r--combine-album.js220
-rw-r--r--crawlers.js16
-rw-r--r--package-lock.json226
-rw-r--r--package.json1
-rw-r--r--ui.js2
5 files changed, 457 insertions, 8 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)
+  })
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))