« get me outta code hell

http-music - Command-line music player + utils (not a server!)
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/downloaders.js42
-rw-r--r--src/loop-play.js100
2 files changed, 138 insertions, 4 deletions
diff --git a/src/downloaders.js b/src/downloaders.js
index 8fa830c..1de6730 100644
--- a/src/downloaders.js
+++ b/src/downloaders.js
@@ -1,6 +1,7 @@
 'use strict'
 
 const fs = require('fs')
+const fse = require('fs-extra')
 const fetch = require('node-fetch')
 const promisifyProcess = require('./promisify-process')
 const tempy = require('tempy')
@@ -11,6 +12,7 @@ const { spawn } = require('child_process')
 const { promisify } = require('util')
 
 const writeFile = promisify(fs.writeFile)
+const copyFile = fse.copy
 
 function makeHTTPDownloader() {
   return function(arg) {
@@ -42,6 +44,32 @@ function makeYouTubeDownloader() {
 }
 
 function makeLocalDownloader() {
+  // 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).
+
+  return function(arg) {
+    const dir = tempy.directory()
+    // 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 file = dir + '/' + sanitize(base) + '.mp3'
+    return copyFile(arg, file)
+      .then(() => file)
+  }
+}
+
+function makeLocalEchoDownloader() {
   return function(arg) {
     // Since we're grabbing the file from the local file system, there's no
     // need to download or copy it!
@@ -72,7 +100,7 @@ module.exports = {
   makeLocalDownloader,
   makePowerfulDownloader,
 
-  getDownloader: downloaderType => {
+  getDownloaderFor: downloaderType => {
     if (downloaderType === 'http') {
       return makeHTTPDownloader()
     } else if (downloaderType === 'youtube') {
@@ -82,5 +110,17 @@ module.exports = {
     } else {
       return null
     }
+  },
+
+  getDownloaderFor(arg) {
+    if (arg.startsWith('http://') || arg.startsWith('https://')) {
+      if (arg.includes('youtube.com')) {
+        return makeYouTubeDownloader()
+      } else {
+        return makeHTTPDownloader()
+      }
+    } else {
+      return makeLocalDownloader()
+    }
   }
 }
diff --git a/src/loop-play.js b/src/loop-play.js
index 10b1d5f..aca518a 100644
--- a/src/loop-play.js
+++ b/src/loop-play.js
@@ -3,24 +3,111 @@
 const { spawn } = require('child_process')
 const FIFO = require('fifo-js')
 const EventEmitter = require('events')
+const { getDownloaderFor } = require('./downloaders')
+
+class DownloadController extends EventEmitter {
+  waitForDownload() {
+    // Returns a promise that resolves when a download is
+    // completed.  Note that this isn't necessarily the download
+    // that was initiated immediately before a call to
+    // waitForDownload (if any), since that download may have
+    // been canceled (see cancel).  You can also listen for the
+    // 'downloaded' event instead.
+
+    return new Promise(resolve => {
+      this.once('downloaded', file => resolve(file))
+    })
+  }
+
+  async download(downloader, arg) {
+    // Downloads a file.  This doesn't return anything; use
+    // waitForDownload to get the result of this.
+    // (The reasoning is that it's possible for a download to
+    // be canceled and replaced with a new download (see cancel)
+    // which would void the result of the old download.)
+
+    let canceled = false
+    this.once('skipped', () => {
+      canceled = true
+    })
+
+    const file = await downloader(arg)
+
+    if (!canceled) {
+      this.emit('downloaded', file)
+    }
+  }
+
+  cancel() {
+    // Cancels the current download.  This doesn't cancel any
+    // waitForDownload promises, though -- you'll need to start
+    // a new download to resolve those.
+
+    this.emit('skipped')
+  }
+}
 
 class PlayController {
-  constructor(picker) {
+  constructor(picker, downloadController) {
     this.currentTrack = null
     this.playArgs = []
     this.process = null
     this.picker = picker
+    this.downloadController = downloadController
   }
 
   async loopPlay() {
+    let next
+
+    let downloadNext = () => {
+      if (this.startNextDownload() !== null) {
+        return this.downloadController.waitForDownload().then(_next => {
+          next = _next
+        })
+      } else {
+        next = null
+        return Promise.resolve()
+      }
+    }
+
+    await downloadNext()
+
+    while (next) {
+      await Promise.all([
+        this.playFile(next),
+        downloadNext()
+      ])
+    }
+  }
+
+  startNextDownload() {
+    // TODO: Is there a method for this?
+    // TODO: Handle/test null return from picker.
+    const arg = this.picker()[1]
+
+    if (arg === null) {
+      return null
+    } else {
+      const downloader = getDownloaderFor(arg)
+      this.downloadController.download(downloader, arg)
+    }
+  }
+
+  async old_loopPlay() {
     // Playing music in a loop isn't particularly complicated; essentially, we
     // just want to keep picking and playing tracks until none is picked.
 
     let nextTrack = await this.picker()
 
+    await this.downloadManager.download(getDownloaderFor(nextTrack), nextTrack)
+
+    let downloadNext
+
     while (nextTrack) {
       this.currentTrack = nextTrack
 
+      this.downloadManager.download(getDownloaderFor(nextTrack), nextTrack)
+
       await this.playFile(nextTrack[1])
 
       nextTrack = await this.picker()
@@ -35,7 +122,7 @@ class PlayController {
       file
     ])
 
-    this.process.stderr.on('data', data => {
+    const handleData = data => {
       const match = data.toString().match(
         /(..):(..):(..) \/ (..):(..):(..) \(([0-9]+)%\)/
       )
@@ -70,6 +157,12 @@ class PlayController {
           `\x1b[K~ (${percentStr}%) ${curStr} / ${lenStr}\r`
         )
       }
+    }
+
+    this.process.stderr.on('data', handleData)
+
+    this.process.once('exit', () => {
+      this.process.stderr.removeListener('data', handleData)
     })
 
     return new Promise(resolve => {
@@ -136,7 +229,8 @@ module.exports = function loopPlay(picker, playArgs = []) {
   // function is null (or similar). Optionally takes a second argument
   // used as arguments to the `play` process (before the file name).
 
-  const playController = new PlayController(picker)
+  const downloadController = new DownloadController()
+  const playController = new PlayController(picker, downloadController)
   playController.playArgs = playArgs
 
   const promise = playController.loopPlay()