diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .gitmodules | 3 | ||||
-rw-r--r-- | downloaders.js | 106 | ||||
-rw-r--r-- | general-util.js | 48 | ||||
-rw-r--r-- | index.js | 47 | ||||
-rw-r--r-- | package-lock.json | 107 | ||||
-rw-r--r-- | package.json | 19 | ||||
-rw-r--r-- | players.js | 249 | ||||
m--------- | tui-lib | 0 |
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 |