« get me outta code hell

http-music - Command-line music player + utils (not a server!)
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--man/http-music-play.15
-rwxr-xr-xsrc/command-exists.js12
-rw-r--r--src/downloaders.js23
-rwxr-xr-xsrc/kill-process.js19
-rw-r--r--src/loop-play.js362
-rwxr-xr-xsrc/play.js56
-rw-r--r--todo.txt7
7 files changed, 297 insertions, 187 deletions
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
@@ -114,11 +114,6 @@ Most playback controls only work with the "mpv" player, but the "sox"/"play" pla
 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 <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 <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.)