« 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/loop-play.js69
-rw-r--r--src/pickers.js33
-rw-r--r--src/play.js159
-rw-r--r--src/playlist-utils.js109
-rw-r--r--src/process-argv.js30
-rw-r--r--src/promisify-process.js19
6 files changed, 419 insertions, 0 deletions
diff --git a/src/loop-play.js b/src/loop-play.js
new file mode 100644
index 0000000..e59fbc2
--- /dev/null
+++ b/src/loop-play.js
@@ -0,0 +1,69 @@
+'use strict'
+
+const fs = require('fs')
+
+const { spawn } = require('child_process')
+const { promisify } = require('util')
+const fetch = require('node-fetch')
+const sanitize = require('sanitize-filename')
+const promisifyProcess = require('./promisify-process')
+
+const writeFile = promisify(fs.writeFile)
+const unlink = promisify(fs.unlink)
+
+module.exports = async function loopPlay(fn) {
+  // Looping play function. Takes one argument, the "pick" function,
+  // which returns a track to play. Preemptively downloads the next
+  // track while the current one is playing for seamless continuation
+  // from one song to the next. Stops when the result of the pick
+  // function is null (or similar).
+
+  async function downloadNext() {
+    const picked = fn()
+
+    if (picked == null) {
+      return false
+    }
+
+    const [ title, href ] = picked
+    console.log(`Downloading ${title}..\n${href}`)
+
+    const wavFile = `.${sanitize(title)}.wav`
+
+    const res = await fetch(href)
+    const buffer = await res.buffer()
+    await writeFile('./.temp-track', buffer)
+
+    try {
+      await convert('./.temp-track', wavFile)
+    } catch(err) {
+      console.warn('Failed to convert ' + title)
+      console.warn('Selecting a new track\n')
+
+      return await downloadNext()
+    }
+
+    await unlink('./.temp-track')
+
+    return wavFile
+  }
+
+  let wavFile = await downloadNext()
+
+  while (wavFile) {
+    const nextPromise = downloadNext()
+    await playFile(wavFile)
+    await unlink(wavFile)
+    wavFile = await nextPromise
+  }
+}
+
+function convert(fromFile, toFile) {
+  const avconv = spawn('avconv', ['-y', '-i', fromFile, toFile])
+  return promisifyProcess(avconv, false)
+}
+
+function playFile(file) {
+  const play = spawn('play', [file])
+  return promisifyProcess(play)
+}
diff --git a/src/pickers.js b/src/pickers.js
new file mode 100644
index 0000000..236f9ea
--- /dev/null
+++ b/src/pickers.js
@@ -0,0 +1,33 @@
+'use strict'
+
+const { flattenPlaylist } = require('./playlist-utils')
+
+function makeOrderedPlaylistPicker(playlist) {
+  const allSongs = flattenPlaylist(playlist)
+  let index = 0
+
+  return function() {
+    if (index < allSongs.length) {
+      const picked = allSongs[index]
+      index++
+      return picked
+    } else {
+      return null
+    }
+  }
+}
+
+function makeShufflePlaylistPicker(playlist) {
+  const allSongs = flattenPlaylist(playlist)
+
+  return function() {
+    const index = Math.floor(Math.random() * allSongs.length)
+    const picked = allSongs[index]
+    return picked
+  }
+}
+
+module.exports = {
+  makeOrderedPlaylistPicker,
+  makeShufflePlaylistPicker
+}
diff --git a/src/play.js b/src/play.js
new file mode 100644
index 0000000..b0014f5
--- /dev/null
+++ b/src/play.js
@@ -0,0 +1,159 @@
+'use strict'
+
+const fs = require('fs')
+
+const { promisify } = require('util')
+const loopPlay = require('./loop-play')
+const processArgv = require('./process-argv')
+const pickers = require('./pickers')
+
+const readFile = promisify(fs.readFile)
+
+readFile('./playlist.json', 'utf-8')
+  .then(plText => JSON.parse(plText))
+  .then(async playlist => {
+    let sourcePlaylist = playlist
+    let curPlaylist = playlist
+
+    let pickerType = 'shuffle'
+
+    // WILL play says whether the user has forced playback via an argument.
+    // SHOULD play says whether the program has automatically decided to play
+    // or not, if the user hasn't set WILL play.
+    let shouldPlay = true
+    let willPlay = null
+
+    await processArgv(process.argv, {
+      '-open': async function(util) {
+        // --open <file>  (alias: -o)
+        // Opens a separate playlist file.
+        // This sets the source playlist.
+
+        const openedPlaylist = JSON.parse(await readFile(util.nextArg(), 'utf-8'))
+        sourcePlaylist = openedPlaylist
+        curPlaylist = openedPlaylist
+      },
+
+      'o': util => util.alias('-open'),
+
+      '-clear': function(util) {
+        // --clear  (alias: -c)
+        // Clears the active playlist. This does not affect the source
+        // playlist.
+
+        curPlaylist = []
+      },
+
+      'c': util => util.alias('-clear'),
+
+      '-keep': function(util) {
+        // --keep <groupPath>  (alias: -k)
+        // Keeps a group by loading it from the source playlist into the
+        // active playlist. This is usually useful after clearing the
+        // active playlist; it can also be used to keep a subgroup when
+        // you've ignored an entire parent group, e.g. `-i foo -k foo/baz`.
+
+        const pathString = util.nextArg()
+        const group = filterPlaylistByPathString(sourcePlaylist, pathString)
+        curPlaylist.push(group)
+      },
+
+      'k': util => util.alias('-keep'),
+
+      '-ignore': function(util) {
+        // --ignore <groupPath>  (alias: -i)
+        // Filters the playlist so that the given path is removed.
+
+        const pathString = util.nextArg()
+        console.log('Ignoring path: ' + pathString)
+        ignoreGroupByPathString(curPlaylist, pathString)
+      },
+
+      'i': util => util.alias('-ignore'),
+
+      '-list-groups': function(util) {
+        // --list-groups  (alias: -l, --list)
+        // Lists all groups in the playlist.
+
+        console.log(getPlaylistTreeString(curPlaylist))
+
+        // If this is the last item in the argument list, the user probably
+        // only wants to get the list, so we'll mark the 'should run' flag
+        // as false.
+        if (util.index === util.argv.length - 1) {
+          shouldPlay = false
+        }
+      },
+
+      '-list': util => util.alias('-list-groups'),
+      'l': util => util.alias('-list-groups'),
+
+      '-list-all': function(util) {
+        // --list-all  (alias: --list-tracks, -L)
+        // Lists all groups and tracks in the playlist.
+
+        console.log(getPlaylistTreeString(curPlaylist, true))
+
+        // As with -l, if this is the last item in the argument list, we
+        // won't actually be playing the playlist.
+        if (util.index === util.argv.length - 1) {
+          shouldPlay = false
+        }
+      },
+
+      '-list-tracks': util => util.alias('-list-all'),
+      'L': util => util.alias('-list-all'),
+
+      '-play': function(util) {
+        // --play  (alias: -p)
+        // Forces the playlist to actually play.
+
+        willPlay = true
+      },
+
+      'p': util => util.alias('-play'),
+
+      '-no-play': function(util) {
+        // --no-play  (alias: -np)
+        // Forces the playlist not to play.
+
+        willPlay = false
+      },
+
+      'np': util => util.alias('-no-play'),
+
+      '-debug-list': function(util) {
+        // --debug-list
+        // Prints out the JSON representation of the active playlist.
+
+        console.log(JSON.stringify(curPlaylist, null, 2))
+      },
+
+      '-picker': function(util) {
+        // --picker <shuffle|ordered>
+        // Selects the mode that the song to play is picked.
+        // This should be used after finishing modifying the active
+        // playlist.
+
+        pickerType = util.nextArg()
+      }
+    })
+
+    if (willPlay || (willPlay === null && shouldPlay)) {
+      let picker
+      if (pickerType === 'shuffle') {
+        console.log('Using shuffle picker')
+        picker = pickers.makeShufflePlaylistPicker(curPlaylist)
+      } else if (pickerType === 'ordered') {
+        console.log('Using ordered picker')
+        picker = pickers.makeOrderedPlaylistPicker(curPlaylist)
+      } else {
+        console.error('Invalid picker type: ' + pickerType)
+      }
+
+      return loopPlay(picker)
+    } else {
+      return curPlaylist
+    }
+  })
+  .catch(err => console.error(err))
diff --git a/src/playlist-utils.js b/src/playlist-utils.js
new file mode 100644
index 0000000..d853456
--- /dev/null
+++ b/src/playlist-utils.js
@@ -0,0 +1,109 @@
+'use strict'
+
+function flattenPlaylist(playlist) {
+  const groups = playlist.filter(x => Array.isArray(x[1]))
+  const nonGroups = playlist.filter(x => x[1] && !(Array.isArray(x[1])))
+  return groups.map(g => flattenPlaylist(g[1]))
+    .reduce((a, b) => a.concat(b), nonGroups)
+}
+
+function filterPlaylistByPathString(playlist, pathString) {
+  return filterPlaylistByPath(playlist, parsePathString(pathString))
+}
+
+function filterPlaylistByPath(playlist, pathParts) {
+  // Note this can be used as a utility function, rather than just as
+  // a function for use by the argv-handler!
+
+  let cur = pathParts[0]
+
+  const match = playlist.find(g => g[0] === cur || g[0] === cur + '/')
+
+  if (match) {
+    const groupContents = match[1]
+    if (pathParts.length > 1) {
+      const rest = pathParts.slice(1)
+      return filterPlaylistByPath(groupContents, rest)
+    } else {
+      return match
+    }
+  } else {
+    console.warn(`Not found: "${cur}"`)
+    return playlist
+  }
+}
+
+function ignoreGroupByPathString(playlist, pathString) {
+  const pathParts = parsePathString(pathString)
+  return ignoreGroupByPath(playlist, pathParts)
+}
+
+function ignoreGroupByPath(playlist, pathParts) {
+  // TODO: Ideally this wouldn't mutate the given playlist.
+
+  const groupToRemove = filterPlaylistByPath(playlist, pathParts)
+
+  const parentPath = pathParts.slice(0, pathParts.length - 1)
+  let parent
+
+  if (parentPath.length === 0) {
+    parent = playlist
+  } else {
+    parent = filterPlaylistByPath(playlist, pathParts.slice(0, -1))
+  }
+
+  const index = parent.indexOf(groupToRemove)
+
+  if (index >= 0) {
+    parent.splice(index, 1)
+  } else {
+    console.error(
+      'Group ' + pathParts.join('/') + ' doesn\'t exist, so we can\'t ' +
+      'explicitly ignore it.'
+    )
+  }
+}
+
+function getPlaylistTreeString(playlist, showTracks = false) {
+  function recursive(group) {
+    const groups = group.filter(x => Array.isArray(x[1]))
+    const nonGroups = group.filter(x => x[1] && !(Array.isArray(x[1])))
+
+    const childrenString = groups.map(g => {
+      const groupString = recursive(g[1])
+
+      if (groupString) {
+        const indented = groupString.split('\n').map(l => '| ' + l).join('\n')
+        return '\n' + g[0] + '\n' + indented
+      } else {
+        return g[0]
+      }
+    }).join('\n')
+
+    const tracksString = (showTracks ? nonGroups.map(g => g[0]).join('\n') : '')
+
+    if (tracksString && childrenString) {
+      return tracksString + '\n' + childrenString
+    } else if (childrenString) {
+      return childrenString
+    } else if (tracksString) {
+      return tracksString
+    } else {
+      return ''
+    }
+  }
+
+  return recursive(playlist)
+}
+
+function parsePathString(pathString) {
+  const pathParts = pathString.split('/')
+  return pathParts
+}
+
+module.exports = {
+  flattenPlaylist,
+  filterPlaylistByPathString, filterPlaylistByPath,
+  ignoreGroupByPathString, ignoreGroupByPath,
+  parsePathString
+}
diff --git a/src/process-argv.js b/src/process-argv.js
new file mode 100644
index 0000000..3193d98
--- /dev/null
+++ b/src/process-argv.js
@@ -0,0 +1,30 @@
+'use strict'
+
+module.exports = async function processArgv(argv, handlers) {
+  let i = 0
+
+  async function handleOpt(opt) {
+    if (opt in handlers) {
+      await handlers[opt]({
+        argv, index: i,
+        nextArg: function() {
+          i++
+          return argv[i]
+        },
+        alias: function(optionToRun) {
+          handleOpt(optionToRun)
+        }
+      })
+    } else {
+      console.warn('Option not understood: ' + opt)
+    }
+  }
+
+  for (; i < argv.length; i++) {
+    const cur = argv[i]
+    if (cur.startsWith('-')) {
+      const opt = cur.slice(1)
+      await handleOpt(opt)
+    }
+  }
+}
diff --git a/src/promisify-process.js b/src/promisify-process.js
new file mode 100644
index 0000000..877cb8d
--- /dev/null
+++ b/src/promisify-process.js
@@ -0,0 +1,19 @@
+'use strict'
+
+module.exports = function promisifyProcess(proc, showLogging = true) {
+  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 {
+        console.error('Process failed!', proc.spawnargs)
+        reject(code)
+      }
+    })
+  })
+}