diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/loop-play.js | 69 | ||||
-rw-r--r-- | src/pickers.js | 33 | ||||
-rw-r--r-- | src/play.js | 159 | ||||
-rw-r--r-- | src/playlist-utils.js | 109 | ||||
-rw-r--r-- | src/process-argv.js | 30 | ||||
-rw-r--r-- | src/promisify-process.js | 19 |
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) + } + }) + }) +} |