« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.gitmodules3
-rw-r--r--downloaders.js106
-rw-r--r--general-util.js48
-rw-r--r--index.js47
-rw-r--r--package-lock.json107
-rw-r--r--package.json19
-rw-r--r--players.js249
m---------tui-lib0
9 files changed, 580 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..1f779e6
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "tui-lib"]
+	path = tui-lib
+	url = https://github.com/towerofnix/tui-lib
diff --git a/downloaders.js b/downloaders.js
new file mode 100644
index 0000000..0853046
--- /dev/null
+++ b/downloaders.js
@@ -0,0 +1,106 @@
+const { promisifyProcess } = require('./general-util')
+const { spawn } = require('child_process')
+const { promisify } = require('util')
+const fs = require('fs')
+const fse = require('fs-extra')
+const fetch = require('node-fetch')
+const tempy = require('tempy')
+const path = require('path')
+const sanitize = require('sanitize-filename')
+
+const writeFile = promisify(fs.writeFile)
+const copyFile = fse.copy
+
+// Pseudo-tempy!!
+/*
+const tempy = {
+  directory: () => './tempy-fake'
+}
+*/
+
+class Downloader {
+  download(arg) {}
+}
+
+// oh who cares about classes or functions or kool things
+
+const downloaders = {
+  extension: 'mp3', // Generally target file extension
+
+  http: arg => {
+    const out = (
+      tempy.directory() + '/' +
+      sanitize(decodeURIComponent(path.basename(arg))))
+
+    return fetch(arg)
+      .then(response => response.buffer())
+      .then(buffer => writeFile(out, buffer))
+      .then(() => out)
+  },
+
+  youtubedl: arg => {
+    const out = (
+      tempy.directory() + '/' + sanitize(arg) +
+      '.' + downloaders.extname)
+
+    const opts = [
+      '--quiet',
+      '--extract-audio',
+      '--audio-format', downloaders.extension,
+      '--output', out,
+      arg
+    ]
+
+    return promisifyProcess(spawn('youtube-dl', opts))
+      .then(() => out)
+      .catch(err => false)
+  },
+
+  local: arg => {
+    // Usually we'd just return the given argument in a local
+    // downloader, which is efficient, since there's no need to
+    // copy a file from one place on the hard drive to another.
+    // But reading from a separate drive (e.g. a USB stick or a
+    // CD) can take a lot longer than reading directly from the
+    // computer's own drive, so this downloader copies the file
+    // to a temporary file on the computer's drive.
+    // Ideally, we'd be able to check whether a file is on the
+    // computer's main drive mount or not before going through
+    // the steps to copy, but I'm not sure if there's a way to
+    // do that (and it's even less likely there'd be a cross-
+    // platform way).
+
+    // It's possible the downloader argument starts with the "file://"
+    // protocol string; in that case we'll want to snip it off and URL-
+    // decode the string.
+    const fileProto = 'file://'
+    if (arg.startsWith(fileProto)) {
+      arg = decodeURIComponent(arg.slice(fileProto.length))
+    }
+
+    // TODO: Is it necessary to sanitize here?
+    // Haha, the answer to "should I sanitize" is probably always YES..
+    const base = path.basename(arg, path.extname(arg))
+    const out = (
+      tempy.directory() + '/' + sanitize(base) + path.extname(arg))
+
+    return copyFile(arg, out)
+      .then(() => out)
+  },
+
+  echo: arg => arg,
+
+  getDownloaderFor: arg => {
+    if (arg.startsWith('http://') || arg.startsWith('https://')) {
+      if (arg.includes('youtube.com')) {
+        return downloaders.youtubedl
+      } else {
+        return downloaders.http
+      }
+    } else {
+      return downloaders.local
+    }
+  }
+}
+
+module.exports = downloaders
diff --git a/general-util.js b/general-util.js
new file mode 100644
index 0000000..35e1103
--- /dev/null
+++ b/general-util.js
@@ -0,0 +1,48 @@
+const { spawn } = require('child_process')
+const npmCommandExists = require('command-exists')
+
+module.exports.promisifyProcess = function(proc, showLogging = true) {
+  // Takes a process (from the child_process module) and returns a promise
+  // that resolves when the process exits (or rejects, if the exit code is
+  // non-zero).
+
+  return new Promise((resolve, reject) => {
+    if (showLogging) {
+      proc.stdout.pipe(process.stdout)
+      proc.stderr.pipe(process.stderr)
+    }
+
+    proc.on('exit', code => {
+      if (code === 0) {
+        resolve()
+      } else {
+        reject(code)
+      }
+    })
+  })
+}
+
+module.exports.commandExists = async function(command) {
+  // When the command-exists module sees that a given command doesn't exist, it
+  // throws an error instead of returning false, which is not what we want.
+
+  try {
+    return await npmCommandExists(command)
+  } catch(err) {
+    return false
+  }
+}
+
+module.exports.killProcess = async function(proc) {
+  // Windows is stupid and doesn't like it when we try to kill processes.
+  // So instead we use taskkill! https://stackoverflow.com/a/28163919/4633828
+
+  if (await module.exports.commandExists('taskkill')) {
+    await module.exports.promisifyProcess(
+      spawn('taskkill', ['/pid', proc.pid, '/f', '/t']),
+      false
+    )
+  } else {
+    proc.kill()
+  }
+}
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..6edbd01
--- /dev/null
+++ b/index.js
@@ -0,0 +1,47 @@
+// omg I am tired of code
+
+const { getPlayer } = require('./players')
+const { getDownloaderFor } = require('./downloaders')
+const EventEmitter = require('events')
+
+class InternalApp extends EventEmitter {
+  constructor() {
+    super()
+
+    // downloadCache [downloaderFunction] [downloaderArg]
+    this.downloadCache = new Map()
+  }
+
+  async download(arg) {
+    const downloader = getDownloaderFor(arg)
+    if (this.downloadCache.has(downloader)) {
+      const category = this.downloadCache.get(downloader)
+      if (category.hasOwnProperty(arg)) {
+        return category[arg]
+      }
+    }
+
+    const ret = await this.downloadIgnoringCache(arg)
+
+    if (!this.downloadCache.has(downloader)) {
+      this.downloadCache.set(downloader, {})
+    }
+
+    this.downloadCache.get(downloader)[arg] = ret
+
+    return ret
+  }
+
+  downloadIgnoringCache(arg) {
+    const downloader = getDownloaderFor(arg)
+    return downloader(arg)
+  }
+}
+
+async function main() {
+  const internalApp = new InternalApp()
+  const player = await getPlayer()
+  player.playFile(await internalApp.download('http://billwurtz.com/cable-television.mp3'))
+}
+
+main().catch(err => console.error(err))
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8d4a68f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,107 @@
+{
+  "name": "music-ui",
+  "version": "1.0.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "command-exists": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.6.tgz",
+      "integrity": "sha512-Qst/zUUNmS/z3WziPxyqjrcz09pm+2Knbs5mAZL4VAE0sSrNY1/w8+/YxeHcoBTsO6iojA6BW7eFf27Eg2MRuw=="
+    },
+    "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="
+    },
+    "es6-error": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-3.2.0.tgz",
+      "integrity": "sha1-5WfP3LMk1OeuWSKjcAraXeh5oMo="
+    },
+    "fifo-js": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fifo-js/-/fifo-js-2.1.0.tgz",
+      "integrity": "sha1-iEBfId6gZzYlWBieegdlXcD+FL4=",
+      "requires": {
+        "es6-error": "^3.0.1"
+      }
+    },
+    "fs-extra": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz",
+      "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==",
+      "requires": {
+        "graceful-fs": "^4.1.2",
+        "jsonfile": "^4.0.0",
+        "universalify": "^0.1.0"
+      }
+    },
+    "graceful-fs": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+      "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
+    },
+    "jsonfile": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+      "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
+      "requires": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node-fetch": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
+      "integrity": "sha1-q4hOjn5X44qUR1POxwb3iNF2i7U="
+    },
+    "sanitize-filename": {
+      "version": "1.6.1",
+      "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.1.tgz",
+      "integrity": "sha1-YS2hyWRz+gLczaktzVtKsWSmdyo=",
+      "requires": {
+        "truncate-utf8-bytes": "^1.0.0"
+      }
+    },
+    "temp-dir": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
+      "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0="
+    },
+    "tempy": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz",
+      "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==",
+      "requires": {
+        "temp-dir": "^1.0.0",
+        "unique-string": "^1.0.0"
+      }
+    },
+    "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=",
+      "requires": {
+        "utf8-byte-length": "^1.0.1"
+      }
+    },
+    "unique-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
+      "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=",
+      "requires": {
+        "crypto-random-string": "^1.0.0"
+      }
+    },
+    "universalify": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz",
+      "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc="
+    },
+    "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="
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2dcdcd8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+  "name": "music-ui",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "author": "",
+  "license": "GPL-3.0",
+  "dependencies": {
+    "command-exists": "^1.2.6",
+    "fifo-js": "^2.1.0",
+    "fs-extra": "^6.0.1",
+    "node-fetch": "^2.1.2",
+    "sanitize-filename": "^1.6.1",
+    "tempy": "^0.2.1"
+  }
+}
diff --git a/players.js b/players.js
new file mode 100644
index 0000000..d1d0186
--- /dev/null
+++ b/players.js
@@ -0,0 +1,249 @@
+// stolen from http-music
+
+const { spawn } = require('child_process')
+const FIFO = require('fifo-js')
+const EventEmitter = require('events')
+const { commandExists, killProcess } = require('./general-util')
+
+function getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}) {
+  // Multiplication casts to numbers; addition prioritizes strings.
+  // Thanks, JavaScript!
+  const curSecTotal = (3600 * curHour) + (60 * curMin) + (1 * curSec)
+  const lenSecTotal = (3600 * lenHour) + (60 * lenMin) + (1 * lenSec)
+  const percentVal = (100 / lenSecTotal) * curSecTotal
+  const percentDone = (
+    (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%'
+  )
+
+  const leftSecTotal = lenSecTotal - curSecTotal
+  let leftHour = Math.floor(leftSecTotal / 3600)
+  let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60)
+  let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60)
+
+  const pad = val => val.toString().padStart(2, '0')
+  curMin = pad(curMin)
+  curSec = pad(curSec)
+  lenMin = pad(lenMin)
+  lenSec = pad(lenSec)
+  leftMin = pad(leftMin)
+  leftSec = pad(leftSec)
+
+  // We don't want to display hour counters if the total length is less
+  // than an hour.
+  let timeDone, timeLeft, duration
+  if (parseInt(lenHour) > 0) {
+    timeDone = `${curHour}:${curMin}:${curSec}`
+    timeLeft = `${leftHour}:${leftMin}:${leftSec}`
+    duration = `${lenHour}:${lenMin}:${lenSec}`
+  } else {
+    timeDone = `${curMin}:${curSec}`
+    timeLeft = `${leftMin}:${leftSec}`
+    duration = `${lenMin}:${lenSec}`
+  }
+
+  return {percentDone, timeDone, timeLeft, duration}
+}
+
+class Player extends EventEmitter {
+  constructor() {
+    super()
+
+    this.disablePlaybackStatus = false
+  }
+
+  set process(newProcess) {
+    this._process = newProcess
+    this._process.on('exit', code => {
+      if (code !== 0 && !this._killed) {
+        this.emit('crashed', code)
+      }
+
+      this._killed = false
+    })
+  }
+
+  get process() {
+    return this._process
+  }
+
+  playFile(file) {}
+  seekAhead(secs) {}
+  seekBack(secs) {}
+  volUp(amount) {}
+  volDown(amount) {}
+  togglePause() {}
+
+  async kill() {
+    if (this.process) {
+      this._killed = true
+      await killProcess(this.process)
+    }
+  }
+
+  printStatusLine(data) {
+    // Quick sanity check - we don't want to print the status line if it's
+    // disabled! Hopefully printStatusLine won't be called in that case, but
+    // if it is, we should be careful.
+    if (!this.disablePlaybackStatus) {
+      this.emit('printStatusLine', data)
+    }
+  }
+}
+
+module.exports.MPVPlayer = class extends Player {
+  getMPVOptions(file) {
+    return ['--no-audio-display', file]
+  }
+
+  playFile(file) {
+    // The more powerful MPV player. MPV is virtually impossible for a human
+    // being to install; if you're having trouble with it, try the SoX player.
+
+    this.process = spawn('mpv', this.getMPVOptions(file))
+
+    this.process.stderr.on('data', data => {
+      if (this.disablePlaybackStatus) {
+        return
+      }
+
+      const match = data.toString().match(
+        /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/
+      )
+
+      if (match) {
+        const [
+          curHour, curMin, curSec, // ##:##:##
+          lenHour, lenMin, lenSec, // ##:##:##
+          percent // ###%
+        ] = match.slice(1)
+
+        this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}))
+      }
+    })
+
+    return new Promise(resolve => {
+      this.process.once('close', resolve)
+    })
+  }
+}
+
+module.exports.ControllableMPVPlayer = class extends module.exports.MPVPlayer {
+  getMPVOptions(file) {
+    return ['--input-file=' + this.fifo.path, ...super.getMPVOptions(file)]
+  }
+
+  playFile(file) {
+    this.fifo = new FIFO()
+
+    return super.playFile(file)
+  }
+
+  sendCommand(command) {
+    if (this.fifo) {
+      this.fifo.write(command)
+    }
+  }
+
+  seekAhead(secs) {
+    this.sendCommand(`seek +${parseFloat(secs)}`)
+  }
+
+  seekBack(secs) {
+    this.sendCommand(`seek -${parseFloat(secs)}`)
+  }
+
+  volUp(amount) {
+    this.sendCommand(`add volume +${parseFloat(amount)}`)
+  }
+
+  volDown(amount) {
+    this.sendCommand(`add volume -${parseFloat(amount)}`)
+  }
+
+  togglePause() {
+    this.sendCommand('cycle pause')
+  }
+
+  kill() {
+    if (this.fifo) {
+      this.fifo.close()
+      delete this.fifo
+    }
+
+    return super.kill()
+  }
+}
+
+module.exports.SoXPlayer = class extends Player {
+  playFile(file) {
+    // SoX's play command is useful for systems that don't have MPV. SoX is
+    // much easier to install (and probably more commonly installed, as well).
+    // You don't get keyboard controls such as seeking or volume adjusting
+    // with SoX, though.
+
+    this.process = spawn('play', [file])
+
+    this.process.stdout.on('data', data => {
+      process.stdout.write(data.toString())
+    })
+
+    // Most output from SoX is given to stderr, for some reason!
+    this.process.stderr.on('data', data => {
+      // The status line starts with "In:".
+      if (data.toString().trim().startsWith('In:')) {
+        if (this.disablePlaybackStatus) {
+          return
+        }
+
+        const timeRegex = '([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)'
+        const match = data.toString().trim().match(new RegExp(
+          `^In:([0-9.]+%)\\s*${timeRegex}\\s*\\[${timeRegex}\\]`
+        ))
+
+        if (match) {
+          const percentStr = match[1]
+
+          // SoX takes a loooooot of math in order to actually figure out the
+          // duration, since it outputs the current time and the remaining time
+          // (but not the duration).
+
+          const [
+            curHour, curMin, curSec, curSecFrac, // ##:##:##.##
+            remHour, remMin, remSec, remSecFrac // ##:##:##.##
+          ] = match.slice(2).map(n => parseInt(n))
+
+          const duration = Math.round(
+            (curHour + remHour) * 3600 +
+            (curMin + remMin) * 60 +
+            (curSec + remSec) * 1 +
+            (curSecFrac + remSecFrac) / 100
+          )
+
+          const lenHour = Math.floor(duration / 3600)
+          const lenMin = Math.floor((duration - lenHour * 3600) / 60)
+          const lenSec = Math.floor(duration - lenHour * 3600 - lenMin * 60)
+
+          this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}))
+        }
+      }
+    })
+
+    return new Promise(resolve => {
+      this.process.on('close', () => resolve())
+    })
+  }
+}
+
+module.exports.getPlayer = async function() {
+  if (await commandExists('mpv')) {
+    if (await commandExists('mkfifo')) {
+      return new module.exports.ControllableMPVPlayer()
+    } else {
+      return new module.exports.MPVPlayer()
+    }
+  } else if (await commandExists('play')) {
+    return new module.exports.SoXPlayer()
+  } else {
+    return null
+  }
+}
diff --git a/tui-lib b/tui-lib
new file mode 160000
+Subproject 6ee1936266dda3bd22e4412a7b51cdc6e3c396d