diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/cli.js | 2 | ||||
-rwxr-xr-x | src/crawl-http.js | 50 | ||||
-rwxr-xr-x | src/crawl-local.js | 3 | ||||
-rw-r--r-- | src/crawlers.js | 1 | ||||
-rwxr-xr-x | src/download-playlist.js | 9 | ||||
-rw-r--r-- | src/downloaders.js | 2 | ||||
-rw-r--r-- | src/duration-graph.js | 277 | ||||
-rw-r--r-- | src/general-util.js | 39 | ||||
-rw-r--r-- | src/loop-play.js | 105 | ||||
-rw-r--r-- | src/open-file.js | 35 | ||||
-rw-r--r-- | src/pickers.js | 39 | ||||
-rwxr-xr-x | src/play.js | 190 | ||||
-rw-r--r-- | src/playlist-utils.js | 57 | ||||
-rw-r--r-- | src/process-metadata.js | 108 | ||||
-rw-r--r-- | src/smart-playlist.js | 119 |
15 files changed, 891 insertions, 145 deletions
diff --git a/src/cli.js b/src/cli.js index 9021cc6..eeb5e99 100755 --- a/src/cli.js +++ b/src/cli.js @@ -23,7 +23,9 @@ async function main(args) { switch (args[0]) { case 'play': script = require('./play'); break case 'download-playlist': script = require('./download-playlist'); break + case 'process-metadata': script = require('./process-metadata'); break case 'smart-playlist': script = require('./smart-playlist'); break + case 'duration-graph': script = require('./duration-graph'); break case 'setup': script = require('./setup'); break default: diff --git a/src/crawl-http.js b/src/crawl-http.js index d3e1533..5a4932d 100755 --- a/src/crawl-http.js +++ b/src/crawl-http.js @@ -19,9 +19,10 @@ function crawl(absURL, opts = {}, internals = {}) { maxAttempts = 5, keepSeparateHosts = false, + stayInSameDirectory = true, keepAnyFileType = false, - fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov'], + fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov', 'mpga', 'mod'], filterRegex = null } = opts @@ -35,7 +36,7 @@ function crawl(absURL, opts = {}, internals = {}) { const verboseLog = text => { if (verbose) { - console.log(text) + console.error(text) } } @@ -43,10 +44,12 @@ function crawl(absURL, opts = {}, internals = {}) { return fetch(absURL) .then( - res => res.text().then(text => { + res => res.text().then(async text => { const links = getHTMLLinks(text) - return Promise.all(links.map(link => { + const items = [] + + for (const link of links) { let [ name, href ] = link // If the name (that's the content inside of <a>..</a>) ends with a @@ -56,27 +59,34 @@ function crawl(absURL, opts = {}, internals = {}) { name = name.slice(0, -1) } - const urlObj = new url.URL(href, absURL) + name = name.trim() + + const urlObj = new url.URL(href, absURL + '/') const linkURL = url.format(urlObj) if (internals.allURLs.includes(linkURL)) { verboseLog("[Ignored] Already done this URL: " + linkURL) - - return false + continue } internals.allURLs.push(linkURL) if (filterRegex && !(filterRegex.test(linkURL))) { verboseLog("[Ignored] Failed regex: " + linkURL) - - return false + continue } if (!keepSeparateHosts && urlObj.host !== absURLObj.host) { verboseLog("[Ignored] Inconsistent host: " + linkURL) + continue + } - return false + if (stayInSameDirectory) { + const relative = path.relative(absURLObj.pathname, urlObj.pathname) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + verboseLog("[Ignored] Outside of parent directory: " + linkURL) + continue + } } if (href.endsWith('/')) { @@ -84,8 +94,10 @@ function crawl(absURL, opts = {}, internals = {}) { verboseLog("[Dir] " + linkURL) - return crawl(linkURL, opts, Object.assign({}, internals)) - .then(({ items }) => ({name, items})) + items.push(await ( + crawl(linkURL, opts, Object.assign({}, internals)) + .then(({ items }) => ({name, items})) + )) } else { // It's a file! @@ -96,14 +108,15 @@ function crawl(absURL, opts = {}, internals = {}) { !(extensions.includes(path.extname(href))) ) { verboseLog("[Ignored] Bad extension: " + linkURL) - - return false + continue } verboseLog("[File] " + linkURL) - return Promise.resolve({name, downloaderArg: linkURL}) + items.push({name, downloaderArg: linkURL}) } - }).filter(Boolean)).then(items => ({items})) + } + + return {items} }), err => { @@ -190,7 +203,10 @@ async function main(args, shouldReturn = false) { // such. Defaults to false. verbose = true - console.log('Outputting verbosely.') + console.error( + 'Outputting verbosely. (Log output goes to STDERR - ' + + 'you can still pipe to a file to save your playlist.)' + ) }, 'v': util => util.alias('-verbose'), diff --git a/src/crawl-local.js b/src/crawl-local.js index 3134193..bd83552 100755 --- a/src/crawl-local.js +++ b/src/crawl-local.js @@ -21,7 +21,8 @@ function crawl(dirPath, extensions = [ // This list isn't very extensive, and can be customized via the // --extensions (or --exts, -e) option. 'ogg', 'oga', - 'wav', 'mp3', 'mp4', 'm4a', 'aac' + 'wav', 'mp3', 'mp4', 'm4a', 'aac', + 'mod' ]) { return readDir(dirPath).then(items => { items.sort(sortIgnoreCase(naturalSort())) diff --git a/src/crawlers.js b/src/crawlers.js index 5ad7fb4..635cc1e 100644 --- a/src/crawlers.js +++ b/src/crawlers.js @@ -7,6 +7,7 @@ module.exports = { case 'crawl-local': return require('./crawl-local') case 'crawl-itunes': return require('./crawl-itunes') case 'crawl-youtube': return require('./crawl-youtube') + case 'open-file': return require('./open-file') default: return null } } diff --git a/src/download-playlist.js b/src/download-playlist.js index b41c240..852cb64 100755 --- a/src/download-playlist.js +++ b/src/download-playlist.js @@ -12,6 +12,7 @@ const { } = require('./playlist-utils') const { getDownloaderFor, makePowerfulDownloader } = require('./downloaders') +const { showTrackProcessStatus } = require('./general-util') const { promisify } = require('util') const { spawn } = require('child_process') @@ -24,12 +25,8 @@ async function downloadCrawl(playlist, topOut = './out/') { const flat = flattenGrouplike(playlist) let doneCount = 0 - const showStatus = function() { - const total = flat.items.length - const percent = Math.trunc(doneCount / total * 10000) / 100 - console.log( - `\x1b[1mDownload crawler - ${percent}% completed ` + - `(${doneCount}/${total} tracks)\x1b[0m`) + const showStatus = () => { + showTrackProcessStatus(flat.items.length, doneCount) } // First off, we go through all tracks and see which are already downloaded. diff --git a/src/downloaders.js b/src/downloaders.js index f5efa3e..8ac3a92 100644 --- a/src/downloaders.js +++ b/src/downloaders.js @@ -79,7 +79,7 @@ function makeLocalDownloader() { // 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' + const file = dir + '/' + sanitize(base) + path.extname(arg) return copyFile(arg, file) .then(() => file) } diff --git a/src/duration-graph.js b/src/duration-graph.js new file mode 100644 index 0000000..5d0bf85 --- /dev/null +++ b/src/duration-graph.js @@ -0,0 +1,277 @@ +'use strict' + +const fs = require('fs') +const util = require('util') +const processArgv = require('./process-argv') + +const { + updatePlaylistFormat, + isGroup, isItem, + getItemPathString, + flattenGrouplike +} = require('./playlist-utils') + +const readFile = util.promisify(fs.readFile) + +const metrics = {} +metrics.duration = Symbol('Duration') +metrics.length = metrics.duration +metrics.time = metrics.duration +metrics.tracks = Symbol('# of tracks') +metrics.items = metrics.tracks + +function getUncachedDurationOfItem(item) { + if (isGroup(item)) { + return item.items.reduce((a, b) => a + getDurationOfItem(b), 0) + } else { + if (item && item.metadata && item.metadata.duration) { + return item.metadata.duration + } else { + console.warn('Item missing metadata:', getItemPathString(item)) + return 0 + } + } +} + +// This is mostly just to avoid logging out "item missing metadata" warnings +// multiple times. +function getDurationOfItem(item) { + if (metrics.duration in item === false) { + item[metrics.duration] = getUncachedDurationOfItem(item) + } + + return item[metrics.duration] +} + +function getTrackCount(item) { + if (metrics.tracks in item === false) { + if (isGroup(item)) { + item[metrics.tracks] = flattenGrouplike(item).items.length + } else { + item[metrics.tracks] = 1 + } + } + + return item[metrics.tracks] +} + +const getHours = n => Math.floor(n / 3600) +const getMinutes = n => Math.floor((n % 3600) / 60) +const getSeconds = n => n % 60 + +function wordFormatDuration(durationNumber) { + if (typeof durationNumber !== 'number') { + throw new Error('Non-number passed') + } + + // oh yeah + const hours = getHours(durationNumber), + minutes = getMinutes(durationNumber), + seconds = getSeconds(durationNumber) + + return [ + hours ? `${hours} hours` : false, + minutes ? `${minutes} minutes` : false, + seconds ? `${seconds} seconds` : false + ].filter(Boolean).join(', ') || '(No length.)' +} + +function digitalFormatDuration(durationNumber) { + if (typeof durationNumber !== 'number') { + throw new Error('Non-number passed') + } + + const hours = getHours(durationNumber), + minutes = getMinutes(durationNumber), + seconds = getSeconds(durationNumber) + + return [hours, minutes, seconds].filter(Boolean).length ? [ + hours ? `${hours}` : false, + minutes ? `${minutes}`.padStart(2, '0') : '00', + seconds ? `${seconds}`.padStart(2, '0') : '00' + ].filter(Boolean).join(':') : '(No length.)' +} + +function padStartList(strings) { + const len = strings.reduce((a, b) => Math.max(a, b.length), 0) + return strings.map(s => s.padStart(len, ' ')) +} + +function measureItem(item, metric) { + if (metric === metrics.duration) { + return getDurationOfItem(item) + } else if (metric === metrics.tracks) { + return getTrackCount(item) + } else { + throw new Error('Invalid metric: ' + metric) + } +} + +function makePlaylistGraph(playlist, { + graphWidth = 60, + onlyFirst = 20, + metric = metrics.duration +} = {}) { + const output = [] + + const wholePlaylistLength = measureItem(playlist, metric) + + const briefFormatDuration = duration => { + if (metric === metrics.duration) { + return digitalFormatDuration(duration) + } else { + return duration.toString() + } + } + + const longFormatDuration = duration => { + if (metric === metrics.duration) { + return wordFormatDuration(duration) + } else if (metric === metrics.tracks) { + return `${duration} tracks` + } else { + return duration.toString() + } + } + + let topThings = playlist.items.map((item, i) => { + const duration = measureItem(item, metric) + const briefDuration = briefFormatDuration(duration) + return {item, duration, briefDuration} + }) + + topThings.sort((a, b) => b.duration - a.duration) + + const ignoredThings = topThings.slice(onlyFirst) + + topThings = topThings.slice(0, onlyFirst) + + const displayLength = topThings.reduce((a, b) => a + b.duration, 0) + + // Left-pad the brief durations so they're all the same length. + { + const len = topThings.reduce((a, b) => Math.max(a, b.briefDuration.length), 0) + for (const obj of topThings) { + obj.padDuration = obj.briefDuration.padStart(len, ' ') + } + } + + let totalWidth = 0 + for (let i = 0; i < topThings.length; i++) { + // Add a color to each item. + const colorCode = (i % 6) + 1 + topThings[i].fgColor = `\x1b[3${colorCode}m` + topThings[i].bgColor = `\x1b[4${colorCode}m` + + topThings[i].partOfWhole = 1 / displayLength * topThings[i].duration + + let w = Math.floor(topThings[i].partOfWhole * graphWidth) + if (totalWidth < graphWidth) { + w = Math.max(1, w) + } + totalWidth += w + topThings[i].visualWidth = w + } + + output.push(' Whole length: ' + longFormatDuration(wholePlaylistLength), '') + + output.push(' ' + topThings.map(({ bgColor, fgColor, visualWidth }) => { + return bgColor + fgColor + '-'.repeat(visualWidth) + }).join('') + '\x1b[0m' + (ignoredThings.length ? ' *' : ''), '') + + output.push(' Length by item:') + + output.push(...topThings.map(({ item, padDuration, visualWidth, fgColor }) => + ` ${fgColor}${ + // Dim the row if it doesn't show up in the graph. + visualWidth === 0 ? '\x1b[2m- ' : ' ' + }${padDuration} ${item.name}\x1b[0m` + )) + + if (ignoredThings.length) { + const totalDuration = ignoredThings.reduce((a, b) => a + b.duration, 0) + const dur = longFormatDuration(totalDuration) + output.push( + ` \x1b[2m(* Plus ${ignoredThings.length} skipped items, accounting `, + ` for ${dur}.)\x1b[0m` + ) + } + + if (topThings.some(x => x.visualWidth === 0)) { + output.push('', + ' (Items that are too short to show up on the', + ' visual graph are dimmed and marked with a -.)' + ) + } + + return output +} + +async function main(args) { + if (args.length === 0) { + console.log("Usage: http-music duration-graph /path/to/processed-playlist.json") + return + } + + let graphWidth = 60 + let onlyFirst = 20 + let metric = metrics.duration + + await processArgv(args.slice(1), { + '-metric': util => { + const arg = util.nextArg() + if (Object.keys(metrics).includes(arg)) { + metric = metrics[arg] + } else { + console.warn('Didn\'t set metric because it isn\'t recognized:', arg) + } + }, + + '-measure': util => util.alias('-metric'), + 'm': util => util.alias('-metric'), + + '-graph-width': util => { + const arg = util.nextArg() + const newVal = parseInt(arg) + if (newVal > 0) { + graphWidth = newVal + } else { + console.warn('Didn\'t set graph width because it\'s not greater than 0:', arg) + } + }, + + '-width': util => util.alias('-graph-width'), + 'w': util => util.alias('-graph-width'), + + '-only-first': util => { + const arg = util.nextArg() + const newVal = parseInt(arg) + if (newVal > 0) { + onlyFirst = newVal + } else { + console.warn('You can\'t use the first *zero* tracks! -', arg) + } + }, + + '-only': util => util.alias('-only-first'), + 'o': util => util.alias('-only-first'), + '-first': util => util.alias('-only-first'), + 'f': util => util.alias('-only-first'), + + '-all': util => { + onlyFirst = Infinity + }, + + 'a': util => util.alias('-all') + }) + + const playlist = updatePlaylistFormat(JSON.parse(await readFile(args[0]))) + + for (const line of makePlaylistGraph(playlist, { + graphWidth, onlyFirst, metric + })) { + console.log(line) + } +} + +module.exports = main diff --git a/src/general-util.js b/src/general-util.js new file mode 100644 index 0000000..825dd90 --- /dev/null +++ b/src/general-util.js @@ -0,0 +1,39 @@ +const { promisify } = require('util') +const fs = require('fs') +const fetch = require('node-fetch') + +const readFile = promisify(fs.readFile) + +module.exports.showTrackProcessStatus = function( + total, doneCount, noLineBreak = false +) { + // Log a status line which tells how many tracks are processed and what + // percent is completed. (Uses non-specific language: it doesn't say + // "how many tracks downloaded" or "how many tracks processed", but + // rather, "how many tracks completed".) Pass noLineBreak = true to skip + // the \n character (you'll probably also want to log \r after). + + const percent = Math.trunc(doneCount / total * 10000) / 100 + process.stdout.write( + `\x1b[1m${percent}% completed ` + + `(${doneCount}/${total} tracks)\x1b[0m` + + (noLineBreak ? '' : '\n') + ) +} + +function downloadPlaylistFromURL(url) { + return fetch(url).then(res => res.text()) +} + +function downloadPlaylistFromLocalPath(path) { + return readFile(path).then(buf => buf.toString()) +} + +module.exports.downloadPlaylistFromOptionValue = function(arg) { + // TODO: Verify things! + if (arg.startsWith('http://') || arg.startsWith('https://')) { + return downloadPlaylistFromURL(arg) + } else { + return downloadPlaylistFromLocalPath(arg) + } +} diff --git a/src/loop-play.js b/src/loop-play.js index 34ac4d5..0fd94e4 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -11,15 +11,19 @@ const { spawn } = require('child_process') const FIFO = require('fifo-js') const EventEmitter = require('events') +const fs = require('fs') +const util = require('util') const killProcess = require('./kill-process') const { HistoryController, generalPicker } = require('./pickers') +const writeFile = util.promisify(fs.writeFile) + const { getDownloaderFor, byName: downloadersByName, makeConverter } = require('./downloaders') const { - getItemPathString, safeUnlink, parentSymbol + getItemPathString, safeUnlink, parentSymbol, sourceSymbol } = require('./playlist-utils') function createStatusLine({percentStr, curStr, lenStr}) { @@ -33,13 +37,34 @@ class Player extends EventEmitter { this.disablePlaybackStatus = false } + set process(newProcess) { + this._process = newProcess + this._process.on('exit', code => { + if (code !== 0 && !this._killed) { + this.emit('crashed', code) + } + + this._killed = false + }) + } + + get process() { + return this._process + } + playFile(file) {} seekAhead(secs) {} seekBack(secs) {} volUp(amount) {} volDown(amount) {} togglePause() {} - kill() {} + + async kill() { + if (this.process) { + this._killed = true + await killProcess(this.process) + } + } printStatusLine(str) { // Quick sanity check - we don't want to print the status line if it's @@ -107,12 +132,6 @@ class MPVPlayer extends Player { this.process.once('close', resolve) }) } - - async kill() { - if (this.process) { - await killProcess(this.process) - } - } } class ControllableMPVPlayer extends MPVPlayer { @@ -228,9 +247,6 @@ class SoXPlayer extends Player { } async kill() { - if (this.process) { - await killProcess(this.process) - } } } @@ -294,7 +310,7 @@ class DownloadController extends EventEmitter { try { downloadFile = await downloader(downloaderArg) } catch(err) { - this.emit('errored', err) + this.emit('errored', 'Download error: ' + err) return } @@ -310,7 +326,7 @@ class DownloadController extends EventEmitter { try { convertFile = await this.converter(converterOptions)(downloadFile) } catch(err) { - this.emit('errored', err) + this.emit('errored', 'Convert error: ' + err) return } finally { // Whether the convertion succeeds or not (hence 'finally'), we should @@ -345,14 +361,19 @@ class DownloadController extends EventEmitter { } class PlayController extends EventEmitter { - constructor(player, playlist, historyController, downloadController) { + constructor({ + player, playlist, historyController, downloadController, + useConverterOptions = true, + trackDisplayFile = null // File to output current track path to. + }) { super() this.player = player this.playlist = playlist this.historyController = historyController this.downloadController = downloadController - this.useConverterOptions = true + this.useConverterOptions = useConverterOptions + this.trackDisplayFile = trackDisplayFile this.currentTrack = null this.nextTrack = null @@ -360,6 +381,28 @@ class PlayController extends EventEmitter { this.stopped = false this.shouldMoveNext = true this.failedCount = 0 + this.playFailCount = 0 + + this.player.on('crashed', () => { + if (this.currentTrack) { + console.log('\x1b[31mFailed to play track \x1b[1m' + + getItemPathString(this.currentTrack) + '\x1b[0m' + ) + } else { + console.log('\x1b[31mFailed to play track.\x1b[0m') + } + this.playFailCount++ + + if (this.playFailCount >= 5) { + console.error( + '\x1b[31mFailed to play 5 tracks. Halting, to prevent damage to ' + + 'the computer.\x1b[0m' + ) + + process.exit(1) + throw new Error('Intentionally halted - failed to play tracks.') + } + }) this.player.on('printStatusLine', playerString => { let fullStatusLine = '' @@ -440,6 +483,12 @@ class PlayController extends EventEmitter { ]) if (next) { + if (this.trackDisplayFile) { + await writeFile(this.trackDisplayFile, + getItemPathString(this.currentTrack[sourceSymbol]) + ) + } + await this.playFile(next) // Now that we're done playing the file, we should delete it.. unless @@ -529,8 +578,8 @@ class PlayController extends EventEmitter { "prevent damage to the computer.\x1b[0m" ) - process.exit(0) - throw new Error('Intentionally halted.') + process.exit(1) + throw new Error('Intentionally halted - failed to download tracks.') } // A little bit blecht, but.. this works. @@ -603,7 +652,11 @@ class PlayController extends EventEmitter { this.stopped = true } - logTrackInfo() { + logTrackInfo(upNextTrackCount = 3, previousTrackCount = undefined) { + if (typeof previousTrackCount === 'undefined') { + previousTrackCount = upNextTrackCount + } + const getColorMessage = t => { if (!t) return '\x1b[2m(No track)\x1b[0m' @@ -626,13 +679,13 @@ class PlayController extends EventEmitter { const tl = hc.timeline const tlI = hc.timelineIndex - for (let i = Math.max(0, tlI - 2); i < tlI; i++) { + for (let i = Math.max(0, tlI - (previousTrackCount - 1)); i < tlI; i++) { console.log(`\x1b[2m(Prev) ${getCleanMessage(tl[i])}\x1b[0m`) } console.log(`\x1b[1m(Curr) \x1b[1m${getColorMessage(tl[tlI])}\x1b[0m`) - for (let i = tlI + 1; i < Math.min(tlI + 3, tl.length); i++) { + for (let i = tlI + 1; i < Math.min(tlI + upNextTrackCount, tl.length); i++) { console.log(`(Next) ${getCleanMessage(tl[i])}`) } } @@ -643,7 +696,8 @@ module.exports = async function startLoopPlay( pickerOptions, playerCommand, converterCommand, useConverterOptions = true, disablePlaybackStatus = false, - startTrack = null + startTrack = null, + trackDisplayFile = null } ) { // Looping play function. Takes a playlist and an object containing general @@ -686,11 +740,12 @@ module.exports = async function startLoopPlay( historyController.timeline.push(startTrack) } - const playController = new PlayController( - player, playlist, historyController, downloadController - ) + const playController = new PlayController({ + player, playlist, historyController, downloadController, + trackDisplayFile + }) - Object.assign(playController, {playerCommand, useConverterOptions}) + Object.assign(playController, {useConverterOptions}) const promise = playController.loopPlay() diff --git a/src/open-file.js b/src/open-file.js new file mode 100644 index 0000000..f8af595 --- /dev/null +++ b/src/open-file.js @@ -0,0 +1,35 @@ +// Internal "crawler" that simply opens a file and returns the playlist stored +// in that file. This can also open web URLs; it uses the same code that the +// play option --open-playlist does. + +const { + downloadPlaylistFromOptionValue +} = require('./general-util') + +async function crawl(input) { + return JSON.parse(await downloadPlaylistFromOptionValue(input)) +} + +async function main(args, shouldReturn = false) { + if (args.length !== 1) { + console.log("Usage: open-file /example/path.json") + console.log("Note that open-file is generally supposed to be used as a 'source' argument!") + console.log("So, for example, you could make a playlist that looks something like this:") + console.log('{"items": [') + console.log(' {"source": ["open-file", "jazz/playlist.json"]},') + console.log(' {"source": ["open-file", "noise/playlist.json"]}') + console.log(']}') + return + } + + const playlist = await crawl(args[0]) + + const str = JSON.stringify(playlist, null, 2) + if (shouldReturn) { + return str + } else { + console.log(str) + } +} + +module.exports = {crawl, main} diff --git a/src/pickers.js b/src/pickers.js index 41eed53..f5ba3d8 100644 --- a/src/pickers.js +++ b/src/pickers.js @@ -153,6 +153,42 @@ function sortFlattenGrouplike(grouplike, sort, getRandom) { return {items: flattenGrouplike(grouplike).items} } + if (['alphabetically', 'alphabetical', 'alphabet', 'az', 'a-z'].includes(sort)) { + return {items: flattenGrouplike(grouplike).items.sort( + function (a, b) { + let { name: aName } = a + let { name: bName } = b + + const cleanup = str => { + str = str.trim() + str = str.toLowerCase() + str = str.replace(/[^a-zA-Z0-9]/g, '') + + if (/^[0-9]+$/.test(str)) { + // Do nothing, the string is made of one group of digits and so + // would be messed up by our sort here if we got rid of those + // digits. + } else { + str = str.replace(/^[0-9]+/, '').trim() + } + + return str + } + + aName = cleanup(aName) + bName = cleanup(bName) + + if (aName < bName) { + return -1 + } else if (aName === bName) { + return 0 + } else { + return +1 + } + } + )} + } + if ( sort === 'shuffle' || sort === 'shuffled' || sort === 'shuffle-tracks' || sort === 'shuffled-tracks' @@ -177,7 +213,8 @@ function generalPicker(sourcePlaylist, lastTrack, options) { if (![ 'order', 'ordered', 'shuffle', 'shuffled', 'shuffle-tracks', - 'shuffled-tracks','shuffle-groups', 'shuffled-groups' + 'shuffled-tracks', 'shuffle-groups', 'shuffled-groups', + 'alphabetically', 'alphabetical', 'alphabet', 'a-z', 'az' ].includes(sort)) { throw new Error(`Invalid sort mode: ${sort}`) } diff --git a/src/play.js b/src/play.js index ff9e76a..30a151e 100755 --- a/src/play.js +++ b/src/play.js @@ -11,37 +11,25 @@ const commandExists = require('./command-exists') const startLoopPlay = require('./loop-play') const processArgv = require('./process-argv') const promisifyProcess = require('./promisify-process') -const processSmartPlaylist = require('./smart-playlist') +const { processSmartPlaylist } = require('./smart-playlist') const { filterPlaylistByPathString, removeGroupByPathString, getPlaylistTreeString, - updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack + updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack, + flattenGrouplike } = require('./playlist-utils') const { + downloadPlaylistFromOptionValue +} = require('./general-util') + +const { compileKeybindings, getComboForCommand, stringifyCombo } = require('./keybinder') const readFile = promisify(fs.readFile) const writeFile = promisify(fs.writeFile) -function downloadPlaylistFromURL(url) { - return fetch(url).then(res => res.text()) -} - -function downloadPlaylistFromLocalPath(path) { - return readFile(path) -} - -function downloadPlaylistFromOptionValue(arg) { - // TODO: Verify things! - if (arg.startsWith('http://') || arg.startsWith('https://')) { - return downloadPlaylistFromURL(arg) - } else { - return downloadPlaylistFromLocalPath(arg) - } -} - function clearConsoleLine() { process.stdout.write('\x1b[1K\r') } @@ -102,6 +90,19 @@ async function main(args) { // keybinding files. let mayTrustShellCommands = true + // The file to output the playlist path to the current file. + let trackDisplayFile + + // Whether or not a playlist has been opened yet. This is just used to + // decide when exactly to load the default playlist. (We don't want to load + // it as soon as the process starts, since there might be an --open-playlist + // option that specifies opening a *different* playlist! But if we encounter + // an action that requires a playlist, and no playlist has yet been opened, + // we assume that the user probably wants to do something with the default + // playlist, and that's when we open it. See requiresOpenPlaylist for the + // implementation of this.) + let hasOpenedPlaylist = false + const keybindings = [ [['space'], 'togglePause'], [['left'], 'seek', -5], @@ -110,11 +111,12 @@ async function main(args) { [['shiftRight'], 'seek', +30], [['up'], 'skipBack'], [['down'], 'skipAhead'], - [['s'], 'skipAhead'], [['delete'], 'skipUpNext'], - [['i'], 'showTrackInfo'], - [['t'], 'showTrackInfo'], - [['q'], 'quit'] + [['s'], 'skipAhead'], [['S'], 'skipAhead'], + [['i'], 'showTrackInfo'], [['I'], 'showTrackInfo'], + [['t'], 'showTrackInfo', 0, 0], [['T'], 'showTrackInfo', 0, 0], + [['%'], 'showTrackInfo', 20, 0], + [['q'], 'quit'], [['Q'], 'quit'] ] async function openPlaylist(arg, silent = false) { @@ -140,6 +142,8 @@ async function main(args) { const importedPlaylist = JSON.parse(playlistText) + hasOpenedPlaylist = true + await loadPlaylist(importedPlaylist) } @@ -154,7 +158,9 @@ async function main(args) { // ..And finally, we have to update the playlist format again, since // processSmartPlaylist might have added new (un-updated) items: - const finalPlaylist = updatePlaylistFormat(processedPlaylist) + const finalPlaylist = updatePlaylistFormat(processedPlaylist, true) + // We also pass true so that the playlist-format-updater knows that this + // is the source playlist. sourcePlaylist = finalPlaylist @@ -192,14 +198,22 @@ async function main(args) { keybindings.unshift(...openedKeybindings) } - function requiresOpenPlaylist() { + async function requiresOpenPlaylist() { if (activePlaylist === null) { - throw new Error( - "This action requires an open playlist - try --open (file)" - ) + if (hasOpenedPlaylist === false) { + await openDefaultPlaylist() + } else { + throw new Error( + "This action requires an open playlist - try --open (file)" + ) + } } } + function openDefaultPlaylist() { + return openPlaylist('./playlist.json', true) + } + const optionFunctions = { '-help': function(util) { // --help (alias: -h, -?) @@ -234,13 +248,15 @@ async function main(args) { await loadPlaylist(JSON.parse(util.nextArg())) }, - '-write-playlist': function(util) { + '-playlist-string': util => util.alias('-open-playlist-string'), + + '-write-playlist': async function(util) { // --write-playlist <file> (alias: --write, -w, --save) // Writes the active playlist to a file. This file can later be used // with --open <file>; you won't need to stick in all the filtering // options again. - requiresOpenPlaylist() + await requiresOpenPlaylist() const playlistString = JSON.stringify(activePlaylist, null, 2) const file = util.nextArg() @@ -264,11 +280,11 @@ async function main(args) { 'w': util => util.alias('-write-playlist'), '-save': util => util.alias('-write-playlist'), - '-print-playlist': function(util) { + '-print-playlist': async function(util) { // --print-playlist (alias: --log-playlist, --json) // Prints out the JSON representation of the active playlist. - requiresOpenPlaylist() + await requiresOpenPlaylist() console.log(JSON.stringify(activePlaylist, null, 2)) @@ -295,26 +311,26 @@ async function main(args) { await openKeybindings(util.nextArg(), false) }, - '-clear': function(util) { + '-clear': async function(util) { // --clear (alias: -c) // Clears the active playlist. This does not affect the source // playlist. - requiresOpenPlaylist() + await requiresOpenPlaylist() activePlaylist.items = [] }, 'c': util => util.alias('-clear'), - '-keep': function(util) { + '-keep': async 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 removed an entire parent group, e.g. `-r foo -k foo/baz`. - requiresOpenPlaylist() + await requiresOpenPlaylist() const pathString = util.nextArg() const group = filterPlaylistByPathString(sourcePlaylist, pathString) @@ -326,11 +342,11 @@ async function main(args) { 'k': util => util.alias('-keep'), - '-remove': function(util) { + '-remove': async function(util) { // --remove <groupPath> (alias: -r, -x) // Filters the playlist so that the given path is removed. - requiresOpenPlaylist() + await requiresOpenPlaylist() const pathString = util.nextArg() console.log("Ignoring path: " + pathString) @@ -340,38 +356,59 @@ async function main(args) { 'r': util => util.alias('-remove'), 'x': util => util.alias('-remove'), - '-filter': function(util) { - // --filter <property> <value> (alias: -f) - // Filters the playlist so that only tracks with the given property- - // value pair are kept. + '-filter': async function(util) { + // --filter <filterJSON> + // Filters the playlist so that only tracks that match the given filter + // are kept. FilterJSON should be a JSON object as described in the + // man page section "filters". + + const filterJSON = util.nextArg() - const property = util.nextArg() - const value = util.nextArg() + let filterObj + try { + filterObj = JSON.parse(filterJSON) + } catch (error) { + console.error('Invalid JSON for filter:', filterJSON) + return + } - const p = filterGrouplikeByProperty(activePlaylist, property, value) - activePlaylist = updatePlaylistFormat(p) + activePlaylist.filters = [filterObj] + activePlaylist = await processSmartPlaylist(activePlaylist) + activePlaylist = updatePlaylistFormat(activePlaylist) }, 'f': util => util.alias('-filter'), - '-collapse-groups': function() { + '-collapse-groups': async function() { // --collapse-groups (alias: --collapse) // Collapses groups in the active playlist so that there is only one // level of sub-groups. Handy for shuffling the order groups play in; // try `--collapse-groups --sort shuffle-groups`. - requiresOpenPlaylist() + await requiresOpenPlaylist() activePlaylist = updatePlaylistFormat(collapseGrouplike(activePlaylist)) }, '-collapse': util => util.alias('-collapse-groups'), - '-list-groups': function(util) { + '-flatten-tracks': async function() { + // --flatten-tracks (alias: --flatten) + // Flattens the entire active playlist, so that only tracks remain, + // and there are no groups. + + await requiresOpenPlaylist() + + activePlaylist = updatePlaylistFormat(flattenGrouplike(activePlaylist)) + }, + + '-flatten': util => util.alias('-flatten-tracks'), + + '-list-groups': async function(util) { // --list-groups (alias: -l, --list) // Lists all groups in the playlist. - requiresOpenPlaylist() + await requiresOpenPlaylist() console.log(getPlaylistTreeString(activePlaylist)) @@ -386,11 +423,11 @@ async function main(args) { '-list': util => util.alias('-list-groups'), 'l': util => util.alias('-list-groups'), - '-list-all': function(util) { + '-list-all': async function(util) { // --list-all (alias: --list-tracks, -L) // Lists all groups and tracks in the playlist. - requiresOpenPlaylist() + await requiresOpenPlaylist() console.log(getPlaylistTreeString(activePlaylist, true)) @@ -445,6 +482,7 @@ async function main(args) { }, '-sort': util => util.alias('-sort-mode'), + 'S': util => util.alias('-sort-mode'), '-shuffle-seed': function(util) { // --shuffle-seed <seed> (alias: --seed) @@ -568,6 +606,24 @@ async function main(args) { '-hide-playback-status': util => util.alias('-disable-playback-status'), + '-track-display-file': async function(util) { + // --track-display-file (alias: --display-track-file) + // Sets the file to output the current track's path to every time a new + // track is played. This is mostly useful for using tools like OBS to + // interface with http-music, for example so that you can display the + // name/path of the track that is currently playing in a live stream. + const file = util.nextArg() + try { + await writeFile(file, 'Not yet playing.') + } catch (error) { + console.log(`Failed to set track display file to "${file}".`) + return + } + trackDisplayFile = file + }, + + '-display-track-file': util => util.alias('-track-display-file'), + '-trust-shell-commands': function(util) { // --trust-shell-commands (alias: --trust) // Lets keybindings run shell commands. Only use this when loading @@ -593,10 +649,12 @@ async function main(args) { '-trust': util => util.alias('-trust-shell-commands') } - await openPlaylist('./playlist.json', true) - await processArgv(args, optionFunctions) + if (!hasOpenedPlaylist) { + await openDefaultPlaylist() + } + if (activePlaylist === null) { console.error( "Cannot play - no open playlist. Try --open <playlist file>?" @@ -609,6 +667,22 @@ async function main(args) { } if (willPlay || (willPlay === null && shouldPlay)) { + // Quick and simple test - if there are no items in the playlist, don't + // continue. This is mainly to catch incomplete user-entered commands + // (like `http-music play -c`). + if (flattenGrouplike(activePlaylist).items.length === 0) { + console.error( + 'Your playlist doesn\'t have any tracks in it, so it can\'t be ' + + 'played.' + ) + console.error( + '(Make sure your http-music command doesn\'t have any typos ' + + 'and isn\'t incomplete? You might have used -c or --clear but not ' + + '--keep to actually pick tracks to play!)' + ) + return false + } + console.log(`Using sort: ${pickerSortMode} and loop: ${pickerLoopMode}.`) console.log(`Using ${playerCommand} player.`) console.log(`Using ${converterCommand} converter.`) @@ -629,7 +703,8 @@ async function main(args) { willUseConverterOptions === null && shouldUseConverterOptions ), disablePlaybackStatus, - startTrack + startTrack, + trackDisplayFile }) // We're looking to gather standard input one keystroke at a time. @@ -715,10 +790,9 @@ async function main(args) { }) }, - // TODO: Number of history/up-next tracks to show. - 'showTrackInfo': function() { + 'showTrackInfo': function(previousTrackCount = 3, upNextTrackCount = undefined) { clearConsoleLine() - playController.logTrackInfo() + playController.logTrackInfo(previousTrackCount, upNextTrackCount) }, 'runShellCommand': async function(command, args) { diff --git a/src/playlist-utils.js b/src/playlist-utils.js index 3983c7a..f817037 100644 --- a/src/playlist-utils.js +++ b/src/playlist-utils.js @@ -8,8 +8,9 @@ const unlink = promisify(fs.unlink) const parentSymbol = Symbol('Parent group') const oldSymbol = Symbol('Old track or group reference') +const sourceSymbol = Symbol('Source-playlist item reference') -function updatePlaylistFormat(playlist) { +function updatePlaylistFormat(playlist, firstTime = false) { const defaultPlaylist = { options: [], items: [] @@ -39,10 +40,10 @@ function updatePlaylistFormat(playlist) { const fullPlaylistObj = Object.assign(defaultPlaylist, playlistObj) - return updateGroupFormat(fullPlaylistObj) + return updateGroupFormat(fullPlaylistObj, firstTime) } -function updateGroupFormat(group) { +function updateGroupFormat(group, firstTime = false) { const defaultGroup = { name: '', items: [], @@ -62,7 +63,7 @@ function updateGroupFormat(group) { groupObj.items = groupObj.items.map(item => { // Check if it's a group; if not, it's probably a track. if (typeof item[1] === 'array' || item.items) { - item = updateGroupFormat(item) + item = updateGroupFormat(item, firstTime) } else { item = updateTrackFormat(item) @@ -79,6 +80,10 @@ function updateGroupFormat(group) { item[parentSymbol] = groupObj + if (firstTime) { + item[sourceSymbol] = item + } + return item }) @@ -107,22 +112,37 @@ function updateTrackFormat(track) { return Object.assign(defaultTrack, trackObj) } -function mapGrouplikeItems(grouplike, handleTrack) { - if (typeof handleTrack === 'undefined') { +function filterTracks(grouplike, handleTrack) { + // Recursively filters every track in the passed grouplike. The track-handler + // function passed should either return true (to keep a track) or false (to + // remove the track). After tracks are filtered, groups which contain no + // items are removed. + + if (typeof handleTrack !== 'function') { throw new Error("Missing track handler function") } - return { - items: grouplike.items.map(item => { + return Object.assign({}, grouplike, { + items: grouplike.items.filter(item => { if (isTrack(item)) { return handleTrack(item) - } else if (isGroup(item)) { - return mapGrouplikeItems(item, handleTrack, handleGroup) } else { - throw new Error('Non-track/group item') + return true + } + }).map(item => { + if (isGroup(item)) { + return filterTracks(item, handleTrack) + } else { + return item + } + }).filter(item => { + if (isGroup(item)) { + return item.items.length > 0 + } else { + return true } }) - } + }) } function flattenGrouplike(grouplike) { @@ -261,15 +281,7 @@ function filterGrouplikeByPath(grouplike, pathParts) { let possibleMatches if (firstPart.startsWith('?')) { - // TODO: Note to self - remove isGroup here to let this match anything, not - // just groups. Definitely want to do that in the future, but there'll need - // to be some preparing first - for example, what if a group contains a - // track which is the same name as the group? Then there are two possible - // matches; how should http-music know which to pick? Probably be biased to - // pick a group before a track, but.. that doesn't seem perfect either. And - // it doesn't solve the problem where there might be two descendants of the - // same name (groups or otherwise). - possibleMatches = collectGrouplikeChildren(grouplike, isGroup) + possibleMatches = collectGrouplikeChildren(grouplike) firstPart = firstPart.slice(1) } else { possibleMatches = grouplike.items @@ -525,8 +537,9 @@ async function safeUnlink(file, playlist) { } module.exports = { - parentSymbol, oldSymbol, + parentSymbol, oldSymbol, sourceSymbol, updatePlaylistFormat, updateTrackFormat, + filterTracks, flattenGrouplike, partiallyFlattenGrouplike, collapseGrouplike, filterGrouplikeByProperty, diff --git a/src/process-metadata.js b/src/process-metadata.js new file mode 100644 index 0000000..43ffe62 --- /dev/null +++ b/src/process-metadata.js @@ -0,0 +1,108 @@ +const fs = require('fs') +const processArgv = require('./process-argv') +const promisifyProcess = require('./promisify-process') +const { spawn } = require('child_process') +const { promisify } = require('util') +const { showTrackProcessStatus } = require('./general-util') +const { updatePlaylistFormat, flattenGrouplike } = require('./playlist-utils') + +const readFile = promisify(fs.readFile) +const writeFile = promisify(fs.writeFile) + +async function probe(filePath) { + const ffprobe = spawn('ffprobe', [ + '-print_format', 'json', + '-show_entries', 'stream=codec_name:format', + '-select_streams', 'a:0', + '-v', 'quiet', + filePath + ]) + + let probeDataString = '' + + ffprobe.stdout.on('data', data => { + probeDataString += data + }) + + await promisifyProcess(ffprobe, false) + + return JSON.parse(probeDataString) +} + +async function main(args) { + if (args.length < 2) { + console.error('Usage: http-music process-metadata <in> <out> (..args..)') + console.error('See \x1b[1mman http-music-process-metadata\x1b[0m!') + return false + } + + const inFile = args[0] + const outFile = args[1] + + // Whether or not to save actual audio tag data. (This includes things like + // genre, track #, and album, as well as any non-standard data set on the + // file.) + let saveTags = false + + // Whether or not to skip tracks which have already been processed. + let skipCompleted = true + + await processArgv(args.slice(1), { + '-save-tags': function() { + saveTags = true + }, + + '-tags': util => util.alias('-save-tags'), + 't': util => util.alias('-save-tags'), + + '-skip-completed': function() { + skipCompleted = true + }, + + '-skip-done': util => util.alias('-skip-completed'), + '-faster': util => util.alias('-skip-completed'), + + '-no-skip-completed': function() { + skipCompleted = false + }, + + '-no-skip-done': util => util.alias('-no-skip-completed'), + '-slower': util => util.alias('-no-skip-completed') + }) + + let doneCount = 0 + + const playlist = updatePlaylistFormat(JSON.parse(await readFile(args[0]))) + + const flattened = flattenGrouplike(playlist) + for (const item of flattened.items) { + if (!(skipCompleted && 'metadata' in item)) { + const probeData = await probe(item.downloaderArg) + + item.metadata = Object.assign(item.metadata || {}, { + duration: parseInt(probeData.format.duration), + size: parseInt(probeData.format.size), + bitrate: parseInt(probeData.format.bit_rate) + }) + + if (saveTags) { + item.metadata.tags = probeData.tags + } + } + + doneCount++ + showTrackProcessStatus(flattened.items.length, doneCount, true) + process.stdout.write(' \r') + } + + await writeFile(outFile, JSON.stringify(playlist, null, 2)) + + console.log(`\nDone! Processed ${flattened.items.length} tracks.`) +} + +module.exports = main + +if (require.main === module) { + main(process.argv.slice(2)) + .catch(err => console.error(err)) +} diff --git a/src/smart-playlist.js b/src/smart-playlist.js index 76cd877..cbe5182 100644 --- a/src/smart-playlist.js +++ b/src/smart-playlist.js @@ -2,37 +2,126 @@ const fs = require('fs') const { getCrawlerByName } = require('./crawlers') +const { isGroup, filterTracks, sourceSymbol } = require('./playlist-utils') const { promisify } = require('util') const readFile = promisify(fs.readFile) -async function processItem(item) { +async function processSmartPlaylist(item) { // Object.assign is used so that we keep original properties, e.g. "name" // or "apply". (It's also used so we return copies of original objects.) - if ('source' in item) { + const newItem = Object.assign({}, item) + + if ('source' in newItem) { const [ name, ...args ] = item.source const crawlModule = getCrawlerByName(name) - if (crawlModule === null) { + if (crawlModule) { + const { crawl } = crawlModule + Object.assign(newItem, await crawl(...args)) + } else { console.error(`No crawler by name ${name} - skipped item:`, item) - return Object.assign({}, item, {failed: true}) + newItem.failed = true + } + + delete newItem.source + } else if ('items' in newItem) { + newItem.items = await Promise.all(item.items.map(processSmartPlaylist)) + } + + if ('filters' in newItem) filters: { + if (!isGroup(newItem)) { + console.warn('Filter on non-group (no effect):', newItem) + break filters } - const { crawl } = crawlModule + newItem.filters = newItem.filters.filter(filter => { + if ('tag' in filter === false) { + console.warn('Filter is missing "tag" property (skipping this filter):', filter) + return false + } - return Object.assign({}, item, await crawl(...args)) - } else if ('items' in item) { - return Object.assign({}, item, { - items: await Promise.all(item.items.map(processItem)) + return true }) - } else { - return Object.assign({}, item) + + Object.assign(newItem, filterTracks(newItem, track => { + for (const filter of newItem.filters) { + const { tag } = filter + + let value = track + for (const key of tag.split('.')) { + if (key in Object(value)) { + value = value[key] + } else { + console.warn(`In tag "${tag}", key "${key}" not found.`) + console.warn('...value until now:', value) + console.warn('...track:', track) + console.warn('...filter:', filter) + return false + } + } + + if ('gt' in filter && value <= filter.gt) return false + if ('lt' in filter && value >= filter.lt) return false + if ('gte' in filter && value < filter.gte) return false + if ('lte' in filter && value > filter.lte) return false + if ('least' in filter && value < filter.least) return false + if ('most' in filter && value > filter.most) return false + if ('min' in filter && value < filter.min) return false + if ('max' in filter && value > filter.max) return false + + for (const prop of ['includes', 'contains']) { + if (prop in filter) { + if (Array.isArray(value) || typeof value === 'string') { + if (!value.includes(filter.includes)) return false + } else { + console.warn( + `Value of tag "${tag}" is not an array or string, so passing ` + + `"${prop}" does not make sense.` + ) + console.warn('...value:', value) + console.warn('...track:', track) + console.warn('...filter:', filter) + return false + } + } + } + + if (filter.regex) { + if (typeof value === 'string') { + let re + try { + re = new RegExp(filter.regex) + } catch (error) { + console.warn('Invalid regular expression:', re) + console.warn('...error message:', error.message) + console.warn('...filter:', filter) + return false + } + if (!re.test(value)) return false + } else { + console.warn( + `Value of tag "${tag}" is not a string, so passing "regex" ` + + 'does not make sense.' + ) + console.warn('...value:', value) + console.warn('...track:', track) + console.warn('...filter:', filter) + return false + } + } + } + + return true + })) + + delete newItem.filters } -} -module.exports = processItem + return newItem +} async function main(opts) { // TODO: Error when no file is given @@ -41,10 +130,12 @@ async function main(opts) { console.log("Usage: smart-playlist /path/to/playlist") } else { const playlist = JSON.parse(await readFile(opts[0])) - console.log(JSON.stringify(await processItem(playlist), null, 2)) + console.log(JSON.stringify(await processSmartPlaylist(playlist), null, 2)) } } +module.exports = Object.assign(main, {processSmartPlaylist}) + if (require.main === module) { main(process.argv.slice(2)) .catch(err => console.error(err)) |