From 588482d3dad9a3ff023b6a152c490e375eb6746a Mon Sep 17 00:00:00 2001 From: towerofnix Date: Mon, 7 Aug 2017 21:10:00 -0300 Subject: Windows support! (Hopefully this didn't break macOS/Linux.) --- man/http-music-play.1 | 5 - src/command-exists.js | 12 ++ src/downloaders.js | 23 +++- src/kill-process.js | 19 +++ src/loop-play.js | 362 +++++++++++++++++++++++++++++++------------------- src/play.js | 56 +++----- todo.txt | 7 + 7 files changed, 297 insertions(+), 187 deletions(-) create mode 100755 src/command-exists.js create mode 100755 src/kill-process.js diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 8f69ab5..2237563 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -113,11 +113,6 @@ Valid options include "mpv" and "sox" (or "play"). Most playback controls only work with the "mpv" player, but the "sox"/"play" player is typically much more easy to (and commonly) install than "mpv". The default is \fBmpv\fR, but \fBsox\fR will be used if mpv is not installed. -.TP -.BR \-\-play\-opts -Sets command line options passed to the \fBplay\fR command. -For example, playback volume may be set to 30% by using \fB\-\-play\-opts '\-\-volume 30'\fR. - .TP .BR \-\-print\-playlist ", " \-\-log-playlist ", " \-\-json Prints the JSON representation of the active playlist to the console. diff --git a/src/command-exists.js b/src/command-exists.js new file mode 100755 index 0000000..9921364 --- /dev/null +++ b/src/command-exists.js @@ -0,0 +1,12 @@ +const npmCommandExists = require('command-exists') + +module.exports = async function commandExists(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 + } +} diff --git a/src/downloaders.js b/src/downloaders.js index 2df6655..c41efa5 100644 --- a/src/downloaders.js +++ b/src/downloaders.js @@ -3,10 +3,11 @@ const fs = require('fs') const fse = require('fs-extra') const fetch = require('node-fetch') -const promisifyProcess = require('./promisify-process') const tempy = require('tempy') const path = require('path') const sanitize = require('sanitize-filename') +const promisifyProcess = require('./promisify-process') +const commandExists = require('./command-exists') const { spawn } = require('child_process') const { promisify } = require('util') @@ -109,14 +110,24 @@ function makePowerfulDownloader(downloader, maxAttempts = 5) { } } -function makeConverterDownloader(downloader, type) { - return async function(arg) { - const inFile = await downloader(arg) +async function makeConverter(type) { + let binary + if (await commandExists('avconv')) { + binary = 'avconv' + } else if (await commandExists('ffmpeg')) { + binary = 'ffmpeg' + } else { + throw new Error('avconv or ffmpeg is required for converter downloader!') + } + + console.log(`Using ${binary} converter.`) + + return async function(inFile) { const base = path.basename(inFile, path.extname(inFile)) const tempDir = tempy.directory() const outFile = `${tempDir}/${base}.${type}` - await promisifyProcess(spawn('avconv', ['-i', inFile, outFile]), false) + await promisifyProcess(spawn(binary, ['-i', inFile, outFile]), false) return outFile } @@ -127,7 +138,7 @@ module.exports = { makeYouTubeDownloader, makeLocalDownloader, makePowerfulDownloader, - makeConverterDownloader, + makeConverter, byName: { 'http': makeHTTPDownloader, diff --git a/src/kill-process.js b/src/kill-process.js new file mode 100755 index 0000000..c6a3349 --- /dev/null +++ b/src/kill-process.js @@ -0,0 +1,19 @@ +'use strict' + +const { spawn } = require('child_process') +const commandExists = require('./command-exists') +const promisifyProcess = require('./promisify-process') + +module.exports = async function killProcess(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 commandExists('taskkill')) { + await promisifyProcess( + spawn('taskkill', ['/pid', proc.pid, '/f', '/t']), + false + ) + } else { + proc.kill() + } +} diff --git a/src/loop-play.js b/src/loop-play.js index dd477a1..1e1a75d 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -12,15 +12,161 @@ const { spawn } = require('child_process') const FIFO = require('fifo-js') const EventEmitter = require('events') const promisifyProcess = require('./promisify-process') +const killProcess = require('./kill-process') const { getItemPathString } = require('./playlist-utils') const { safeUnlink } = require('./playlist-utils') const { - getDownloaderFor, byName: downloadersByName + getDownloaderFor, byName: downloadersByName, makeConverter } = require('./downloaders') +class Player { + playFile(file) {} + seekAhead(secs) {} + seekBack(secs) {} + volUp(amount) {} + volDown(amount) {} + togglePause() {} + kill() {} +} + +class MPVPlayer 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 => { + const match = data.toString().match( + /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/ + ) + + if (match) { + const [ + curHour, curMin, curSec, // ##:##:## + lenHour, lenMin, lenSec, // ##:##:## + percent // ###% + ] = match.slice(1) + + let curStr, lenStr + + // We don't want to display hour counters if the total length is less + // than an hour. + if (parseInt(lenHour) > 0) { + curStr = `${curHour}:${curMin}:${curSec}` + lenStr = `${lenHour}:${lenMin}:${lenSec}` + } else { + curStr = `${curMin}:${curSec}` + lenStr = `${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 percentStr = (Math.trunc(percentVal * 100) / 100).toFixed(2) + + process.stdout.write( + `\x1b[K~ (${percentStr}%) ${curStr} / ${lenStr}\r` + ) + } + }) + + return new Promise(resolve => { + this.process.once('close', resolve) + }) + } + + async kill() { + if (this.process) { + await killProcess(this.process) + } + } +} + +class ControllableMPVPlayer extends 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() + } +} + +class SoXPlayer 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', [ + ...this.playOpts, + file + ]) + + return promisifyProcess(this.process) + } + + async kill() { + if (this.process) { + await killProcess(this.process) + } + } +} + class DownloadController extends EventEmitter { + constructor(playlist) { + super() + + this.playlist = playlist + } + waitForDownload() { // Returns a promise that resolves when a download is // completed. Note that this isn't necessarily the download @@ -62,10 +208,13 @@ class DownloadController extends EventEmitter { this.once('canceled', this._handleCanceled) - let file + let downloadFile + + // TODO: Be more specific; 'errored' * 2 could instead be 'downloadErrored' and + // 'convertErrored'. try { - file = await downloader(arg) + downloadFile = await downloader(arg) } catch(err) { this.emit('errored', err) return @@ -74,11 +223,33 @@ class DownloadController extends EventEmitter { // If this current download has been canceled, we should get rid of the // download file (and shouldn't emit a download success). if (canceled) { - this.emit('deleteFile', file) - } else { - this.emit('downloaded', file) - this.cleanupListeners() + await safeUnlink(downloadFile, this.playlist) + return + } + + let convertFile + + const converter = await makeConverter('wav') + + try { + convertFile = await converter(downloadFile) + } catch(err) { + this.emit('errored', err) + return + } finally { + // Whether the convertion succeeds or not (hence 'finally'), we should + // delete the temporary download file. + await safeUnlink(downloadFile, this.playlist) + } + + // Again, if canceled, we should delete temporary files and stop. + if (canceled) { + await safeUnlink(convertFile, this.playlist) + return } + + this.emit('downloaded', convertFile) + this.cleanupListeners() } cleanupListeners() { @@ -98,15 +269,18 @@ class DownloadController extends EventEmitter { } class PlayController extends EventEmitter { - constructor(picker, downloadController) { + constructor(picker, player, playlist, downloadController) { super() this.picker = picker + this.player = player + this.playlist = playlist this.downloadController = downloadController - this.playOpts = [] - this.playerCommand = null + this.currentTrack = null - this.process = null + this.nextTrack = null + this.nextFile = undefined // TODO: Why isn't this null? + this.stopped = false } async loopPlay() { @@ -118,7 +292,7 @@ class PlayController extends EventEmitter { await this.waitForDownload() - while (this.nextTrack) { + while (this.nextTrack && !this.stopped) { this.currentTrack = this.nextTrack const next = this.nextFile @@ -134,7 +308,7 @@ class PlayController extends EventEmitter { // that all temporary files are stored in the same folder, together; // indeed an unusual case, but technically possible.) if (next !== this.nextFile) { - this.emit('deleteFile', next) + await safeUnlink(next, this.playlist) } } @@ -197,139 +371,30 @@ class PlayController extends EventEmitter { } playFile(file) { - if (this.playerCommand === 'sox' || this.playerCommand === 'play') { - return this.playFileSoX(file) - } else if (this.playerCommand === 'mpv') { - return this.playFileMPV(file) - } else { - if (this.playerCommand) { - console.warn('Invalid player command given?', this.playerCommand) - } else { - console.warn('No player command given?') - } - - return Promise.resolve() - } - } - - playFileSoX(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', [ - ...this.playOpts, - file - ]) - - return promisifyProcess(this.process) + return this.player.playFile(file) } - playFileMPV(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.fifo = new FIFO() - this.process = spawn('mpv', [ - '--input-file=' + this.fifo.path, - '--no-audio-display', - file, - ...this.playOpts - ]) - - this.process.stderr.on('data', data => { - const match = data.toString().match( - /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/ - ) - - if (match) { - const [ - curHour, curMin, curSec, // ##:##:## - lenHour, lenMin, lenSec, // ##:##:## - percent // ###% - ] = match.slice(1) - - let curStr, lenStr - - // We don't want to display hour counters if the total length is less - // than an hour. - if (parseInt(lenHour) > 0) { - curStr = `${curHour}:${curMin}:${curSec}` - lenStr = `${lenHour}:${lenMin}:${lenSec}` - } else { - curStr = `${curMin}:${curSec}` - lenStr = `${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 percentStr = (Math.trunc(percentVal * 100) / 100).toFixed(2) - - process.stdout.write( - `\x1b[K~ (${percentStr}%) ${curStr} / ${lenStr}\r` - ) - } - }) - - return new Promise(resolve => { - this.process.once('close', resolve) - }) - } - - skip() { - this.kill() + async skip() { + await this.player.kill() + this.currentTrack = null } async skipUpNext() { if (this.nextFile) { - this.emit('deleteFile', this.nextFile) + await safeUnlink(this.nextFile, this.playlist) } this.downloadController.cancel() this.startNextDownload() } - 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') - } - - sendCommand(command) { - if (this.playerCommand === 'mpv' && this.fifo) { - this.fifo.write(command) - } - } - - kill() { - if (this.process) { - this.process.kill() - } - - if (this.fifo) { - this.fifo.close() - delete this.fifo - } - + async stop() { + // TODO: How to bork download-controller files?? Wait for it to emit a + // 'cleaned up' event? This whole program being split-up is a Baaaaad idea. + this.downloadController.cancel() + await this.player.kill() this.currentTrack = null + this.stopped = true } logTrackInfo() { @@ -363,12 +428,32 @@ module.exports = function loopPlay( // function is null (or similar). Optionally takes a second argument // used as arguments to the `play` process (before the file name). - const downloadController = new DownloadController() + let player + if (playerCommand === 'sox' || playerCommand === 'play') { + player = new SoXPlayer() + } else if (playerCommand === 'mpv') { + player = new ControllableMPVPlayer() + } else if ( + playerCommand === 'mpv-nocontrolls' || + playerCommand === 'mpv-windows' || + playerCommand === 'mpv-nofifo' + ) { + player = new MPVPlayer() + } else { + if (playerCommand) { + console.warn('Invalid player command given?', playerCommand) + } else { + console.warn('No player command given?') + } + + return Promise.resolve() + } - const playController = new PlayController(picker, downloadController) + const downloadController = new DownloadController(playlist) - downloadController.on('deleteFile', f => safeUnlink(f, playlist)) - playController.on('deleteFile', f => safeUnlink(f, playlist)) + const playController = new PlayController( + picker, player, playlist, downloadController + ) Object.assign(playController, {playerCommand, playOpts}) @@ -377,6 +462,7 @@ module.exports = function loopPlay( return { promise, playController, - downloadController + downloadController, + player } } diff --git a/src/play.js b/src/play.js index 43576ff..db7088c 100755 --- a/src/play.js +++ b/src/play.js @@ -6,7 +6,7 @@ const { promisify } = require('util') const clone = require('clone') const fs = require('fs') const fetch = require('node-fetch') -const npmCommandExists = require('command-exists') +const commandExists = require('./command-exists') const pickers = require('./pickers') const loopPlay = require('./loop-play') const processArgv = require('./process-argv') @@ -41,17 +41,6 @@ function clearConsoleLine() { process.stdout.write('\x1b[1K\r') } -async function commandExists(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 - } -} - async function determineDefaultPlayer() { if (await commandExists('mpv')) { return 'mpv' @@ -301,16 +290,9 @@ async function main(args) { // --player // Sets the shell command by which audio is played. // Valid options include 'sox' (or 'play') and 'mpv'. Use whichever is - // installed on your system; mpv is the default. + // installed on your system. playerCommand = util.nextArg() - }, - - '-play-opts': function(util) { - // --play-opts - // Sets command line options passed to the `play` command. - - playOpts = util.nextArg().split(' ') } } @@ -341,8 +323,9 @@ async function main(args) { const { promise: playPromise, - playController: play, - downloadController + playController, + downloadController, + player } = loopPlay(activePlaylist, picker, playerCommand, playOpts) // We're looking to gather standard input one keystroke at a time. @@ -366,31 +349,31 @@ async function main(args) { ) if (Buffer.from([0x20]).equals(data)) { - play.togglePause() + player.togglePause() } if (esc(0x43).equals(data)) { - play.seekAhead(5) + player.seekAhead(5) } if (esc(0x44).equals(data)) { - play.seekBack(5) + player.seekBack(5) } if (shiftEsc(0x43).equals(data)) { - play.seekAhead(30) + player.seekAhead(30) } if (shiftEsc(0x44).equals(data)) { - play.seekBack(30) + player.seekBack(30) } if (esc(0x41).equals(data)) { - play.volUp(10) + player.volUp(10) } if (esc(0x42).equals(data)) { - play.volDown(10) + player.volDown(10) } if (Buffer.from('s').equals(data)) { @@ -400,7 +383,7 @@ async function main(args) { "(Press I for track info!)" ) - play.skip() + playController.skip() } if (Buffer.from([0x7f]).equals(data)) { @@ -410,10 +393,7 @@ async function main(args) { "(Press I for track info!)" ) - // TODO: It would be nice to have this as a method of - // PlayController. - // Double TODO: This doesn't actually work!! - play.skipUpNext() + playController.skipUpNext() } if ( @@ -421,7 +401,7 @@ async function main(args) { Buffer.from('t').equals(data) ) { clearConsoleLine() - play.logTrackInfo() + playController.logTrackInfo() } if ( @@ -429,9 +409,9 @@ async function main(args) { Buffer.from([0x03]).equals(data) || // ^C Buffer.from([0x04]).equals(data) // ^D ) { - play.kill() - process.stdout.write('\n') - process.exit(0) + playController.stop().then(() => { + process.exit(0) + }) } }) diff --git a/todo.txt b/todo.txt index 417e506..98ef844 100644 --- a/todo.txt +++ b/todo.txt @@ -268,3 +268,10 @@ TODO: Handle avconv failing (probably handle downloader rejections from within TODO: Delete temporary files when done with them - seriously! http-music alone filled up a good 9GB of disk space, solely on temporary music files. (Done!) + +TODO: Players (MPV, SoX) should be separate (sub-)classes. + (Done!) + +TODO: FIFO doesn't work on Windows. + (Done! - Use mpv-nofifo player. Would like to automatically check for + mkfifo command; then use nofifo if that doesn't exist.) -- cgit 1.3.0-6-gf8a5