From b11539dadd64cf283eb056414df383cfa15f7b3d Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 24 Nov 2017 23:07:11 -0400 Subject: Gracefully exit if the active playlist has no items --- src/play.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/play.js b/src/play.js index ff9e76a..c910b08 100755 --- a/src/play.js +++ b/src/play.js @@ -15,7 +15,8 @@ const processSmartPlaylist = require('./smart-playlist') const { filterPlaylistByPathString, removeGroupByPathString, getPlaylistTreeString, - updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack + updatePlaylistFormat, collapseGrouplike, filterGrouplikeByProperty, isTrack, + flattenGrouplike } = require('./playlist-utils') const { @@ -609,6 +610,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.`) -- cgit 1.3.0-6-gf8a5 From ef947f88f6fb084cbc97f89ffffcf93561ba93a5 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 24 Nov 2017 23:07:46 -0400 Subject: Mark todo.txt task as completed --- todo.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/todo.txt b/todo.txt index 7c2a765..1c9d314 100644 --- a/todo.txt +++ b/todo.txt @@ -407,3 +407,4 @@ TODO: Case-insensitive checking with command keybindings - I think this is TODO: Handle empty (active) playlists. Showing an error message and stopping is best, I think. + (Done!) -- cgit 1.3.0-6-gf8a5 From 31692325a13be15d75d739c34ab47047ac45fde2 Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 27 Nov 2017 22:00:11 -0400 Subject: Publish smart-playlist command properly --- src/play.js | 2 +- src/smart-playlist.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/play.js b/src/play.js index c910b08..5b71c94 100755 --- a/src/play.js +++ b/src/play.js @@ -11,7 +11,7 @@ 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, diff --git a/src/smart-playlist.js b/src/smart-playlist.js index 76cd877..4d20e80 100644 --- a/src/smart-playlist.js +++ b/src/smart-playlist.js @@ -6,7 +6,7 @@ const { getCrawlerByName } = require('./crawlers') 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.) @@ -25,15 +25,13 @@ async function processItem(item) { return Object.assign({}, item, await crawl(...args)) } else if ('items' in item) { return Object.assign({}, item, { - items: await Promise.all(item.items.map(processItem)) + items: await Promise.all(item.items.map(processSmartPlaylist)) }) } else { return Object.assign({}, item) } } -module.exports = processItem - async function main(opts) { // TODO: Error when no file is given @@ -41,10 +39,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)) -- cgit 1.3.0-6-gf8a5 From 6f640a0b8e8e5b26a266f4680a626a629d3c7944 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 5 Jan 2018 23:20:23 -0400 Subject: Support mpga in crawl-http --- src/crawl-http.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crawl-http.js b/src/crawl-http.js index d3e1533..9c7608e 100755 --- a/src/crawl-http.js +++ b/src/crawl-http.js @@ -21,7 +21,7 @@ function crawl(absURL, opts = {}, internals = {}) { keepSeparateHosts = false, keepAnyFileType = false, - fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov'], + fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov', 'mpga'], filterRegex = null } = opts -- cgit 1.3.0-6-gf8a5 From 18435f58f82849dcc86ab2042491828b2873b39a Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 5 Jan 2018 23:20:58 -0400 Subject: WIP(?) metadata processing tool --- src/cli.js | 1 + src/download-playlist.js | 9 ++-- src/general-util.js | 16 +++++++ src/process-metadata.js | 108 +++++++++++++++++++++++++++++++++++++++++++++++ todo.txt | 12 ++++++ 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/general-util.js create mode 100644 src/process-metadata.js diff --git a/src/cli.js b/src/cli.js index 9021cc6..095757f 100755 --- a/src/cli.js +++ b/src/cli.js @@ -23,6 +23,7 @@ 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 'setup': script = require('./setup'); break 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/general-util.js b/src/general-util.js new file mode 100644 index 0000000..67f53e3 --- /dev/null +++ b/src/general-util.js @@ -0,0 +1,16 @@ +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') + ) +} 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 (..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/todo.txt b/todo.txt index 1c9d314..d2def51 100644 --- a/todo.txt +++ b/todo.txt @@ -408,3 +408,15 @@ TODO: Case-insensitive checking with command keybindings - I think this is TODO: Handle empty (active) playlists. Showing an error message and stopping is best, I think. (Done!) + +TODO: A way to switch between what information is displayed in the status bar. + I think using ">" and "<" as default keybindings would work. + Make one set be (track # in group) / (# of tracks in group); one be + (total track #) / (total # of tracks). + +TODO: Adding onto the last one, show the total amount of time in the group/all + groups together. Requires a track metadata tool, though... + +TODO: Make process-metadata work with non-local tracks, somehow... + +TODO: Make process-metadata work nicely with smart playlists, somehow... -- cgit 1.3.0-6-gf8a5 From 5da7a8ecb5594d1dc7d4720b01d3b7c6b2f7a2d9 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 17:04:38 -0400 Subject: Todo note --- todo.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/todo.txt b/todo.txt index d2def51..a3eb19f 100644 --- a/todo.txt +++ b/todo.txt @@ -420,3 +420,6 @@ TODO: Adding onto the last one, show the total amount of time in the group/all TODO: Make process-metadata work with non-local tracks, somehow... TODO: Make process-metadata work nicely with smart playlists, somehow... + +TODO: A way (key, option) to change the "/ duration" text in the status bar to + "- remaining". -- cgit 1.3.0-6-gf8a5 From 0a514c53087e40453089dbf223267655a89b3076 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 17:07:23 -0400 Subject: MAKE DEFAULT KEYBINDINGS WORK WITH CAPS LOCK --- src/play.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/play.js b/src/play.js index 5b71c94..788f6bc 100755 --- a/src/play.js +++ b/src/play.js @@ -111,11 +111,11 @@ 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'], [['T'], 'showTrackInfo'], + [['q'], 'quit'], [['Q'], 'quit'] ] async function openPlaylist(arg, silent = false) { -- cgit 1.3.0-6-gf8a5 From 8b26455db6af055b0ed09d8494a62e5d99f04975 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 17:12:11 -0400 Subject: Mark case-insensitive keybindings as done --- todo.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/todo.txt b/todo.txt index a3eb19f..4fc7366 100644 --- a/todo.txt +++ b/todo.txt @@ -404,6 +404,7 @@ TODO: A way to search the playlist for a path. Probably best to modify the TODO: Case-insensitive checking with command keybindings - I think this is broken with the new command system. + (Done!) TODO: Handle empty (active) playlists. Showing an error message and stopping is best, I think. -- cgit 1.3.0-6-gf8a5 From 2ec0349909be5c8cca63008f18603555493b95d9 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 17:19:20 -0400 Subject: Add --playlist-string alias, update man page --- man/http-music-play.1 | 5 +++++ src/play.js | 2 ++ todo.txt | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 9c6b927..7c77c5b 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -150,6 +150,11 @@ Opens a specific file to be used as the playlist file. (This sets the source playlist.) The default playlist file used upon loading is \fBplaylist.json\fR (in the same directory as \fBhttp-music\fR is being run in). +.TP +.BR \-\-open\-playlist\-string ", " \-\-playlist\-string " \fIplaylistString\fR" +Loads a playlist directly from the passed string, which should be the JSON text of a playlist. +(This sets the source playlist.) + .TP .BR \-p ", " \-\-play Forces the playlist to actually play, regardless of options such as \fB\-\-list\fR. See also \fB\-\-no\-play\fR. diff --git a/src/play.js b/src/play.js index 788f6bc..75099f9 100755 --- a/src/play.js +++ b/src/play.js @@ -235,6 +235,8 @@ async function main(args) { await loadPlaylist(JSON.parse(util.nextArg())) }, + '-playlist-string': util => util.alias('-open-playlist-string'), + '-write-playlist': function(util) { // --write-playlist (alias: --write, -w, --save) // Writes the active playlist to a file. This file can later be used diff --git a/todo.txt b/todo.txt index 4fc7366..06f1c51 100644 --- a/todo.txt +++ b/todo.txt @@ -423,4 +423,4 @@ TODO: Make process-metadata work with non-local tracks, somehow... TODO: Make process-metadata work nicely with smart playlists, somehow... TODO: A way (key, option) to change the "/ duration" text in the status bar to - "- remaining". + "- remaining". This would work very nicely with the >/< status bar idea. -- cgit 1.3.0-6-gf8a5 From ed54217c7b1f720ce817a6df10f4f9920e815e5f Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 17:34:27 -0400 Subject: Let ?abc match tracks as well as groups If you want a *track* whose name is abc but also have a *group* that is named abc, just specify the name of the group containing abc: --keep ?theGroupName/abc instead of --keep ?abc. --- src/playlist-utils.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/playlist-utils.js b/src/playlist-utils.js index 3983c7a..5343fb4 100644 --- a/src/playlist-utils.js +++ b/src/playlist-utils.js @@ -261,15 +261,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 -- cgit 1.3.0-6-gf8a5 From a22688a0f04920c73a06a7c1be09037c4de8f0ce Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 17:44:42 -0400 Subject: Add paused indicator for mpv player --- src/loop-play.js | 15 ++++++++++++--- todo.txt | 1 + 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/loop-play.js b/src/loop-play.js index 34ac4d5..70b5e5c 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -22,8 +22,11 @@ const { getItemPathString, safeUnlink, parentSymbol } = require('./playlist-utils') -function createStatusLine({percentStr, curStr, lenStr}) { - return `(${percentStr}) ${curStr} / ${lenStr}` +function createStatusLine({percentStr, curStr, lenStr, paused = false}) { + return ( + `(${percentStr}) ${curStr} / ${lenStr}` + + (paused === true ? ' (Paused)' : '') + ) } class Player extends EventEmitter { @@ -31,6 +34,7 @@ class Player extends EventEmitter { super() this.disablePlaybackStatus = false + this.paused = false } playFile(file) {} @@ -99,7 +103,9 @@ class MPVPlayer extends Player { (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' ) - this.printStatusLine(createStatusLine({percentStr, curStr, lenStr})) + this.printStatusLine(createStatusLine({ + percentStr, curStr, lenStr, paused: this.paused + })) } }) @@ -150,6 +156,7 @@ class ControllableMPVPlayer extends MPVPlayer { togglePause() { this.sendCommand('cycle pause') + this.paused = !this.paused } kill() { @@ -217,6 +224,8 @@ class SoXPlayer extends Player { lenStr = `${lenMin}:${pad(lenSec)}` } + // No need to pass paused to createStatusLine, since the SoX player + // can never be paused! this.printStatusLine(createStatusLine({percentStr, curStr, lenStr})) } } diff --git a/todo.txt b/todo.txt index 06f1c51..a08b3d9 100644 --- a/todo.txt +++ b/todo.txt @@ -161,6 +161,7 @@ TODO: The results of pressing key commands aren't very clear currently. Useful things that come to mind would be presenting the volume when it's changed; making it clear that a song is being skipped when it is; and having "paused" be part of the status bar. + (Done!) TODO: Figure out a way to make the same mpv process be reused, so that options such as volume can be remembered. (At the moment volume is reset to the -- cgit 1.3.0-6-gf8a5 From a72500509a5a334bd8f0f7d490a4833c03201966 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 24 Jan 2018 22:53:17 -0400 Subject: Revert "Add paused indicator for mpv player" This reverts commit a22688a0f04920c73a06a7c1be09037c4de8f0ce. I still have to think through this feature a fair bit more - currently if you skip to the next track while paused, or pause before MPV actually starts playing, the paused indicator shows while MPV isn't actually paused. --- src/loop-play.js | 15 +++------------ todo.txt | 1 - 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/loop-play.js b/src/loop-play.js index 70b5e5c..34ac4d5 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -22,11 +22,8 @@ const { getItemPathString, safeUnlink, parentSymbol } = require('./playlist-utils') -function createStatusLine({percentStr, curStr, lenStr, paused = false}) { - return ( - `(${percentStr}) ${curStr} / ${lenStr}` + - (paused === true ? ' (Paused)' : '') - ) +function createStatusLine({percentStr, curStr, lenStr}) { + return `(${percentStr}) ${curStr} / ${lenStr}` } class Player extends EventEmitter { @@ -34,7 +31,6 @@ class Player extends EventEmitter { super() this.disablePlaybackStatus = false - this.paused = false } playFile(file) {} @@ -103,9 +99,7 @@ class MPVPlayer extends Player { (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' ) - this.printStatusLine(createStatusLine({ - percentStr, curStr, lenStr, paused: this.paused - })) + this.printStatusLine(createStatusLine({percentStr, curStr, lenStr})) } }) @@ -156,7 +150,6 @@ class ControllableMPVPlayer extends MPVPlayer { togglePause() { this.sendCommand('cycle pause') - this.paused = !this.paused } kill() { @@ -224,8 +217,6 @@ class SoXPlayer extends Player { lenStr = `${lenMin}:${pad(lenSec)}` } - // No need to pass paused to createStatusLine, since the SoX player - // can never be paused! this.printStatusLine(createStatusLine({percentStr, curStr, lenStr})) } } diff --git a/todo.txt b/todo.txt index a08b3d9..06f1c51 100644 --- a/todo.txt +++ b/todo.txt @@ -161,7 +161,6 @@ TODO: The results of pressing key commands aren't very clear currently. Useful things that come to mind would be presenting the volume when it's changed; making it clear that a song is being skipped when it is; and having "paused" be part of the status bar. - (Done!) TODO: Figure out a way to make the same mpv process be reused, so that options such as volume can be remembered. (At the moment volume is reset to the -- cgit 1.3.0-6-gf8a5 From 64bcc2930392d70437dc5bc8b2f078840d8998a9 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 27 Jan 2018 00:23:21 -0400 Subject: Various improvements to crawl-http Names are now trimmed. You shouldn't see " Vim!" anymore - just "Vim!". .MOD files are considered to be music. The crawler will try to avoid going out of whatever directory was passed to it. --- src/crawl-http.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/crawl-http.js b/src/crawl-http.js index 9c7608e..ae38ca4 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', 'mpga'], + fileTypes = ['wav', 'ogg', 'oga', 'mp3', 'mp4', 'm4a', 'mov', 'mpga', 'mod'], filterRegex = null } = opts @@ -56,7 +57,9 @@ 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)) { @@ -79,6 +82,14 @@ function crawl(absURL, opts = {}, internals = {}) { 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) + return false + } + } + if (href.endsWith('/')) { // It's a directory! -- cgit 1.3.0-6-gf8a5 From 60d4ac4b28eee349070ad0930330654e2d67e27d Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 27 Jan 2018 00:39:22 -0400 Subject: Make crawl-http go through one directory at a time Hopefully this makes the tool, like, less of an unintentional denial-of-service. --- src/crawl-http.js | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/crawl-http.js b/src/crawl-http.js index ae38ca4..b40ed02 100755 --- a/src/crawl-http.js +++ b/src/crawl-http.js @@ -44,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 ..) ends with a @@ -64,29 +66,26 @@ function crawl(absURL, opts = {}, internals = {}) { 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) - - return false + continue } if (stayInSameDirectory) { const relative = path.relative(absURLObj.pathname, urlObj.pathname) if (relative.startsWith('..') || path.isAbsolute(relative)) { verboseLog("[Ignored] Outside of parent directory: " + linkURL) - return false + continue } } @@ -95,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! @@ -107,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 => { -- cgit 1.3.0-6-gf8a5 From fe65f1777f130ec9d61c5ce06532a551b5dcc899 Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 12 Feb 2018 19:28:49 -0400 Subject: Make it reasonable to have crawl-http save to a file while verbosely logging --- man/http-music-crawl-http.1 | 5 +++++ src/crawl-http.js | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/man/http-music-crawl-http.1 b/man/http-music-crawl-http.1 index 1f96fc6..24b6980 100644 --- a/man/http-music-crawl-http.1 +++ b/man/http-music-crawl-http.1 @@ -73,3 +73,8 @@ As you can see, the resulting playlist file follows the same structure as the di .BR -m ", " --max-download-attempts Sets the maximum number of times any single directory will be attempted to be downloaded, when the HTTP download request fails. Defaults to 5. + +.TP +.BR -v ", " --verbose +Outputs potentially-useful debugging information regarding what files and links are (and aren't) being followed. +Note that log output goes to STDERR, so you can still pipe STDOUT to a file to save the resulting playlist. diff --git a/src/crawl-http.js b/src/crawl-http.js index b40ed02..5a4932d 100755 --- a/src/crawl-http.js +++ b/src/crawl-http.js @@ -36,7 +36,7 @@ function crawl(absURL, opts = {}, internals = {}) { const verboseLog = text => { if (verbose) { - console.log(text) + console.error(text) } } @@ -203,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'), -- cgit 1.3.0-6-gf8a5 From e249bda854212d9ba29015b0c895b72aa2ee3cad Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 12 Feb 2018 19:45:52 -0400 Subject: Add --track-display-file option for meme OBS livestreams --- man/http-music-play.1 | 5 +++++ src/loop-play.js | 29 ++++++++++++++++++++++------- src/play.js | 24 +++++++++++++++++++++++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 7c77c5b..604691d 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -192,6 +192,11 @@ Sets the track to begin playback from. Especially useful when using an ordered sort; for example, this option could be used to start a long album part way through. (See also \fB\-\-sort\fR.) +.TP +.BR \-\-track\-display\-file ", " \-\-display\-track\-file " \fIfilePath\fR" +Sets the file to output the current track's path to every time a track is played. +This is mostly useful for interfacing tools like OBS with http-music, for example so that you can display the name/path of the track that is currently playing during a live stream. + .TP .BR \-w ", " \-\-write\-playlist ", " \-\-write ", " \-\-save " \fIfilePath\fR" Writes the active playlist to a file. diff --git a/src/loop-play.js b/src/loop-play.js index 34ac4d5..e82e77e 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -11,9 +11,13 @@ 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') @@ -345,14 +349,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 @@ -440,6 +449,10 @@ class PlayController extends EventEmitter { ]) if (next) { + if (this.trackDisplayFile) { + await writeFile(this.trackDisplayFile, getItemPathString(this.currentTrack)) + } + await this.playFile(next) // Now that we're done playing the file, we should delete it.. unless @@ -643,7 +656,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 +700,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/play.js b/src/play.js index 75099f9..a3481ea 100755 --- a/src/play.js +++ b/src/play.js @@ -103,6 +103,9 @@ async function main(args) { // keybinding files. let mayTrustShellCommands = true + // The file to output the playlist path to the current file. + let trackDisplayFile + const keybindings = [ [['space'], 'togglePause'], [['left'], 'seek', -5], @@ -571,6 +574,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 @@ -648,7 +669,8 @@ async function main(args) { willUseConverterOptions === null && shouldUseConverterOptions ), disablePlaybackStatus, - startTrack + startTrack, + trackDisplayFile }) // We're looking to gather standard input one keystroke at a time. -- cgit 1.3.0-6-gf8a5 From 1b5d6f8f96baae53367a7a7d0f9485a42029eaa3 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 16 Feb 2018 11:02:51 -0400 Subject: Make --track-display-file show SOURCE path to track This also means we're keeping track of the source item of items, which is sure to be useful more later. --- src/loop-play.js | 6 ++++-- src/play.js | 4 +++- src/playlist-utils.js | 15 ++++++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/loop-play.js b/src/loop-play.js index e82e77e..83cbcfe 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -23,7 +23,7 @@ const { } = require('./downloaders') const { - getItemPathString, safeUnlink, parentSymbol + getItemPathString, safeUnlink, parentSymbol, sourceSymbol } = require('./playlist-utils') function createStatusLine({percentStr, curStr, lenStr}) { @@ -450,7 +450,9 @@ class PlayController extends EventEmitter { if (next) { if (this.trackDisplayFile) { - await writeFile(this.trackDisplayFile, getItemPathString(this.currentTrack)) + await writeFile(this.trackDisplayFile, + getItemPathString(this.currentTrack[sourceSymbol]) + ) } await this.playFile(next) diff --git a/src/play.js b/src/play.js index a3481ea..d5df591 100755 --- a/src/play.js +++ b/src/play.js @@ -158,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 diff --git a/src/playlist-utils.js b/src/playlist-utils.js index 5343fb4..dad828c 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 }) @@ -517,7 +522,7 @@ async function safeUnlink(file, playlist) { } module.exports = { - parentSymbol, oldSymbol, + parentSymbol, oldSymbol, sourceSymbol, updatePlaylistFormat, updateTrackFormat, flattenGrouplike, partiallyFlattenGrouplike, collapseGrouplike, -- cgit 1.3.0-6-gf8a5 From 820b7940db2f1d533848f024fbb1d6f4841ce598 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sun, 18 Feb 2018 19:31:30 -0400 Subject: Move downloadPlaylistFromOptionValue into general-util --- src/general-util.js | 23 +++++++++++++++++++++++ src/play.js | 21 ++++----------------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/general-util.js b/src/general-util.js index 67f53e3..63ef1b2 100644 --- a/src/general-util.js +++ b/src/general-util.js @@ -1,3 +1,9 @@ +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 ) { @@ -14,3 +20,20 @@ module.exports.showTrackProcessStatus = function( (noLineBreak ? '' : '\n') ) } + +function downloadPlaylistFromURL(url) { + return fetch(url).then(res => res.text()) +} + +function downloadPlaylistFromLocalPath(path) { + return readFile(path) +} + +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/play.js b/src/play.js index d5df591..158c887 100755 --- a/src/play.js +++ b/src/play.js @@ -19,6 +19,10 @@ const { flattenGrouplike } = require('./playlist-utils') +const { + downloadPlaylistFromOptionValue +} = require('./general-util') + const { compileKeybindings, getComboForCommand, stringifyCombo } = require('./keybinder') @@ -26,23 +30,6 @@ const { 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') } -- cgit 1.3.0-6-gf8a5 From 86e42f7a7ec5cf27e2186d111017fe7943acd079 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sun, 18 Feb 2018 19:44:29 -0400 Subject: Let 'source' property simply open a file --- src/crawlers.js | 1 + src/general-util.js | 2 +- src/open-file.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/open-file.js 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/general-util.js b/src/general-util.js index 63ef1b2..825dd90 100644 --- a/src/general-util.js +++ b/src/general-util.js @@ -26,7 +26,7 @@ function downloadPlaylistFromURL(url) { } function downloadPlaylistFromLocalPath(path) { - return readFile(path) + return readFile(path).then(buf => buf.toString()) } module.exports.downloadPlaylistFromOptionValue = function(arg) { diff --git a/src/open-file.js b/src/open-file.js new file mode 100644 index 0000000..357bdae --- /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') + +function crawl(input) { + return 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} -- cgit 1.3.0-6-gf8a5 From b2ac9246886f72bef8b96cf218ed2d803397dafa Mon Sep 17 00:00:00 2001 From: Florrie Date: Sun, 18 Feb 2018 23:44:07 -0400 Subject: Make completely new filter system See the man page for how it works now. --- man/http-music-play.1 | 67 +++++++++++++++++++++++++++--- src/open-file.js | 4 +- src/play.js | 25 ++++++++---- src/playlist-utils.js | 32 +++++++++++---- src/smart-playlist.js | 111 +++++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 206 insertions(+), 33 deletions(-) diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 604691d..978e00c 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -107,11 +107,9 @@ By default, they are enabled. See also \fB\-\-disable\-converter\-options\fR. .TP -.BR \-f ", " \-\-filter " \fIproperty\fR \fIvalue\fR" -Filters the playlist so that only tracks with the given property-value pair are kept. -If the property is an array, it checks if the given value is contained within that array. -For example, this is useful for adding "tags" to songs. -Try adding \fB"tag": ["cool"]\fR to a track in a playlist file, then use \fB\-\-filter tag cool\fR to only play that track (and other tracks whose \fB"tag"\fR property contains \fB"cool"\fR). +.BR \-f ", " \-\-filter " \fIfilterJSON\fR +Filters the playlist so that only tracks that match the given filter are kept. +\fIfilterJSON\fR should be a JSON object as described in the section \fBFILTERS\fR. .TP .BR \-h ", " \-? ", " \-\-help @@ -203,6 +201,65 @@ Writes the active playlist to a file. This file can later be used with \fB\-\-open\fR; you won't need to stick in all the filtering options again. +.SH FILTERS +Filters are simple pieces of JSON text used to indicate exactly what songs http-music should select to play from a playlist. +A basic filter might look something like \fB{"tag": "name.length", "most": 10}\fR. +Filters can be specified in two ways: +.TP +1) +By using the \fB--filter\fR (shorthand \fB-f\fR) option. +For example: \fBhttp-music play --filter '{"tag": "name.length", "most": 10}\fR. +.TP +2) +By passing the filter directly into the playlist's JSON file, under the \fB"filters"\fR field. +For example: \fB{"source": ["open-file", "playlist.json"], "filters": [{"tag": "name.length", "most": 10}]}\fR. +.PP +Either of these ways have the same effect: only tracks whose names are at most 10 characters long are played. + +.PP +Generally, filters can only access data that is available right inside the playlist file. +If you try to pass \fBmetadata.duration\fR as the tag when there is no such value in the playlist file, \fBthe filter will not work.\fR +Thus, the power of filters are unlocked primarily when using the \fBhttp-music process-playlist\fR command initially. +This utility command automatically adds specific metadata information, such as duration, to the \fBmetadata\fR property of each track. +That metadata can then be accessed using filters, for example \fB{"tag": "metadata.duration", "least": 180}\fR. + +.PP +Generally, every filter must have a \fB"tag"\fR property as well as at least one other property (and potentially more) used to check the value of that tag. +The \fB"tag"\fR property is simply a path to any property on the track; for example, \fBmetadata.bitrate\fR means the \fBbitrate\fR property found on the track's \fBmetadata\fR, so 18000 in \fB{"name": "Cool track", "metadata": {"bitrate": 18000}}\fR. +A list of every property follows: + +.TP +.BR gt " \fIamount\fR" +Checks if the tag value is greater than the given amount. +\fB{"tag": "metadata.duration", "gt": 30}\fR only keeps tracks which are more than 30 seconds long. + +.TP +.BR lt " \fIamount\fR" +Checks if the tag value is less than the given amount. +\fB{"tag": "metadata.duration", "lt": 120}\fR only keeps tracks which are less than 120 seconds long. + +.TP +.BR gte ", " least ", " min " \fIamount\fR" +Checks if the tag value is greater than or equal to the given amount. +\fB{"tag": "metadata.duration", "gte": 300}\fR only keeps tracks that are at least five minutes long. + +.TP +.BR lte ", " most ", " max " \fIamount\fR" +Checks if the tag value is less than or equal to the given amount. +\fB{"tag": "metadata.duration", "lte": 60}\fR only keeps tracks that are 60 seconds or shorter. + +.TP +.BR includes ", " contains " \fIvalue\fR" +Checks if the tag value contains the given value. +\fB{"tag": "name", "contains": "the"}\fR only keeps tracks whose names contain "the" (case-sensitive). +\fB{"tag": "genres", "contains": "jazz"}\fR only keeps tracks whose "genres" tag contains "jazz". +(There is not officially a property "genres" on http-music tracks, but this could be added to a playlist file by hand.) + +.TP +.BR regex " \fIre\fR" +Checks if the tag value matches the given regular expression. +\fB{"tag": "name", "regex": "^[Aa]"}\fR only keeps tracks whose names begin with "A" or "a". + .SH EXAMPLES Basic usage: diff --git a/src/open-file.js b/src/open-file.js index 357bdae..f8af595 100644 --- a/src/open-file.js +++ b/src/open-file.js @@ -6,8 +6,8 @@ const { downloadPlaylistFromOptionValue } = require('./general-util') -function crawl(input) { - return downloadPlaylistFromOptionValue(input) +async function crawl(input) { + return JSON.parse(await downloadPlaylistFromOptionValue(input)) } async function main(args, shouldReturn = false) { diff --git a/src/play.js b/src/play.js index 158c887..462cd88 100755 --- a/src/play.js +++ b/src/play.js @@ -335,16 +335,25 @@ async function main(args) { 'r': util => util.alias('-remove'), 'x': util => util.alias('-remove'), - '-filter': function(util) { - // --filter (alias: -f) - // Filters the playlist so that only tracks with the given property- - // value pair are kept. + '-filter': async function(util) { + // --filter + // 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 property = util.nextArg() - const value = util.nextArg() + const filterJSON = util.nextArg() - const p = filterGrouplikeByProperty(activePlaylist, property, value) - activePlaylist = updatePlaylistFormat(p) + let filterObj + try { + filterObj = JSON.parse(filterJSON) + } catch (error) { + console.error('Invalid JSON for filter:', filterJSON) + return + } + + activePlaylist.filters = [filterObj] + activePlaylist = await processSmartPlaylist(activePlaylist) + activePlaylist = updatePlaylistFormat(activePlaylist) }, 'f': util => util.alias('-filter'), diff --git a/src/playlist-utils.js b/src/playlist-utils.js index dad828c..f817037 100644 --- a/src/playlist-utils.js +++ b/src/playlist-utils.js @@ -112,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) { @@ -524,6 +539,7 @@ async function safeUnlink(file, playlist) { module.exports = { parentSymbol, oldSymbol, sourceSymbol, updatePlaylistFormat, updateTrackFormat, + filterTracks, flattenGrouplike, partiallyFlattenGrouplike, collapseGrouplike, filterGrouplikeByProperty, diff --git a/src/smart-playlist.js b/src/smart-playlist.js index 4d20e80..cbe5182 100644 --- a/src/smart-playlist.js +++ b/src/smart-playlist.js @@ -2,6 +2,7 @@ const fs = require('fs') const { getCrawlerByName } = require('./crawlers') +const { isGroup, filterTracks, sourceSymbol } = require('./playlist-utils') const { promisify } = require('util') const readFile = promisify(fs.readFile) @@ -10,26 +11,116 @@ 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 } - const { crawl } = crawlModule + 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 + } - return Object.assign({}, item, await crawl(...args)) - } else if ('items' in item) { - return Object.assign({}, item, { - items: await Promise.all(item.items.map(processSmartPlaylist)) + 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 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 } + + return newItem } async function main(opts) { -- cgit 1.3.0-6-gf8a5 From 0c3ded953b9d2a5b127d62f2fccdc3b4658f6c7b Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 22 Feb 2018 01:40:50 -0400 Subject: Only open default playlist when needed That is, only when no playlist has already been loaded, and the action requires a playilst. Also a todo.txt note. --- src/play.js | 58 ++++++++++++++++++++++++++++++++++++---------------------- todo.txt | 8 ++++++++ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/play.js b/src/play.js index 462cd88..b049a0d 100755 --- a/src/play.js +++ b/src/play.js @@ -131,6 +131,8 @@ async function main(args) { const importedPlaylist = JSON.parse(playlistText) + hasOpenedPlaylist = true + await loadPlaylist(importedPlaylist) } @@ -185,14 +187,24 @@ async function main(args) { keybindings.unshift(...openedKeybindings) } - function requiresOpenPlaylist() { + let hasOpenedPlaylist = false + + 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, -?) @@ -229,13 +241,13 @@ async function main(args) { '-playlist-string': util => util.alias('-open-playlist-string'), - '-write-playlist': function(util) { + '-write-playlist': async function(util) { // --write-playlist (alias: --write, -w, --save) // Writes the active playlist to a file. This file can later be used // with --open ; 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() @@ -259,11 +271,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)) @@ -290,26 +302,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 (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) @@ -321,11 +333,11 @@ async function main(args) { 'k': util => util.alias('-keep'), - '-remove': function(util) { + '-remove': async function(util) { // --remove (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) @@ -358,24 +370,24 @@ async function main(args) { '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) { + '-list-groups': async function(util) { // --list-groups (alias: -l, --list) // Lists all groups in the playlist. - requiresOpenPlaylist() + await requiresOpenPlaylist() console.log(getPlaylistTreeString(activePlaylist)) @@ -390,11 +402,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)) @@ -615,10 +627,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 ?" diff --git a/todo.txt b/todo.txt index 06f1c51..83ed985 100644 --- a/todo.txt +++ b/todo.txt @@ -424,3 +424,11 @@ TODO: Make process-metadata work nicely with smart playlists, somehow... TODO: A way (key, option) to change the "/ duration" text in the status bar to "- remaining". This would work very nicely with the >/< status bar idea. + +TODO: Be a bit more loose (strict?) about what means crashing... Right now if + five tracks fail to play in a row, http-music stops. This is good for + dealing with, for example, a messed up playlist file that now references + moved MP3s, since "failing" means "the download failed". But if the PLAY + command fails (i.e. mpv or sox exits with code 1), THAT should also be + counted as a failure. (An example case of the "play" command failing -- + trying to play a track when there is no audio device.) -- cgit 1.3.0-6-gf8a5 From ee1fb8297bbd71faa51985556232bb75cae11274 Mon Sep 17 00:00:00 2001 From: Florrie Date: Thu, 22 Feb 2018 01:46:05 -0400 Subject: Add comment explaining what hasOpenedPlaylist does I can totally see myself forgetting what on earth the variable exists for without this documentation. --- src/play.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/play.js b/src/play.js index b049a0d..d54004a 100755 --- a/src/play.js +++ b/src/play.js @@ -93,6 +93,16 @@ async function main(args) { // 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], @@ -187,8 +197,6 @@ async function main(args) { keybindings.unshift(...openedKeybindings) } - let hasOpenedPlaylist = false - async function requiresOpenPlaylist() { if (activePlaylist === null) { if (hasOpenedPlaylist === false) { -- cgit 1.3.0-6-gf8a5 From cd661532bcc861d177730273130768a33928ca37 Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 23 Feb 2018 09:01:02 -0400 Subject: Handle player process crashing gracefully E.g. try pulseaudio -k, then run http-music. --- src/loop-play.js | 35 +++++++++++++++++++++++++++++++++-- todo.txt | 1 + 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/loop-play.js b/src/loop-play.js index 83cbcfe..c702bed 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -37,6 +37,19 @@ class Player extends EventEmitter { this.disablePlaybackStatus = false } + set process(newProcess) { + this._process = newProcess + this._process.on('exit', code => { + if (code !== 0) { + this.emit('crashed', code) // TODO: HANDLE THIS + } + }) + } + + get process() { + return this._process + } + playFile(file) {} seekAhead(secs) {} seekBack(secs) {} @@ -369,6 +382,24 @@ class PlayController extends EventEmitter { this.stopped = false this.shouldMoveNext = true this.failedCount = 0 + this.playFailCount = 0 + + this.player.on('crashed', () => { + console.log('\x1b[31mFailed to play track \x1b[1m' + + getItemPathString(this.currentTrack) + '\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 = '' @@ -544,8 +575,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. diff --git a/todo.txt b/todo.txt index 83ed985..073a18b 100644 --- a/todo.txt +++ b/todo.txt @@ -432,3 +432,4 @@ TODO: Be a bit more loose (strict?) about what means crashing... Right now if command fails (i.e. mpv or sox exits with code 1), THAT should also be counted as a failure. (An example case of the "play" command failing -- trying to play a track when there is no audio device.) + (Done!) -- cgit 1.3.0-6-gf8a5 From 68d879fd17821bc5cd71d9aeedf861dd6c0b488a Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 23 Feb 2018 19:04:57 -0400 Subject: Don't count intentionally .kill()ing a process as it crashing --- src/loop-play.js | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/loop-play.js b/src/loop-play.js index c702bed..9c679c7 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -40,9 +40,11 @@ class Player extends EventEmitter { set process(newProcess) { this._process = newProcess this._process.on('exit', code => { - if (code !== 0) { - this.emit('crashed', code) // TODO: HANDLE THIS + if (code !== 0 && !this._killed) { + this.emit('crashed', code) } + + this._killed = false }) } @@ -56,7 +58,13 @@ class Player extends EventEmitter { 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 @@ -124,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 { @@ -245,9 +247,6 @@ class SoXPlayer extends Player { } async kill() { - if (this.process) { - await killProcess(this.process) - } } } @@ -385,9 +384,13 @@ class PlayController extends EventEmitter { this.playFailCount = 0 this.player.on('crashed', () => { - console.log('\x1b[31mFailed to play track \x1b[1m' + - getItemPathString(this.currentTrack) + '\x1b[0m' - ) + 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) { -- cgit 1.3.0-6-gf8a5 From c48e8e5e6f20e056c34996a49628777050454c1b Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 26 Feb 2018 10:18:41 -0400 Subject: Add fancy duration graph utility --- src/cli.js | 1 + src/duration-graph.js | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/play.js | 12 +++ todo.txt | 10 +++ 4 files changed, 233 insertions(+) create mode 100644 src/duration-graph.js diff --git a/src/cli.js b/src/cli.js index 095757f..eeb5e99 100755 --- a/src/cli.js +++ b/src/cli.js @@ -25,6 +25,7 @@ async function main(args) { 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/duration-graph.js b/src/duration-graph.js new file mode 100644 index 0000000..2ffdef7 --- /dev/null +++ b/src/duration-graph.js @@ -0,0 +1,210 @@ +'use strict' + +const fs = require('fs') +const util = require('util') +const processArgv = require('./process-argv') + +const { updatePlaylistFormat, isGroup, isItem, getItemPathString } = require('./playlist-utils') + +const readFile = util.promisify(fs.readFile) + +const cachedDuration = Symbol('Cached duration') + +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 (cachedDuration in item === false) { + item[cachedDuration] = getUncachedDurationOfItem(item) + } + + return item[cachedDuration] +} + +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 makePlaylistGraph(playlist, { + graphWidth = 60, + onlyFirst = 20 +} = {}) { + const output = [] + + const wholePlaylistLength = getDurationOfItem(playlist) + + let topThings = playlist.items.map((item, i) => ({ + item, + duration: getDurationOfItem(item), + digitalDuration: digitalFormatDuration(getDurationOfItem(item)) + })) + + 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 digital durations so they're all the same length. + { + const len = topThings.reduce((a, b) => Math.max(a, b.digitalDuration.length), 0) + for (const obj of topThings) { + obj.digitalDuration = obj.digitalDuration.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: ' + wordFormatDuration(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, digitalDuration, visualWidth, fgColor }) => + ` ${fgColor}${ + // Dim the row if it doesn't show up in the graph. + visualWidth === 0 ? '\x1b[2m- ' : ' ' + }${digitalDuration} ${item.name}\x1b[0m` + )) + + if (ignoredThings.length) { + const dur = wordFormatDuration(ignoredThings.reduce((a, b) => a + b.duration, 0)) + 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 + + await processArgv(args.slice(1), { + '-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'), + + '-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 + })) { + console.log(line) + } +} + +module.exports = main diff --git a/src/play.js b/src/play.js index d54004a..28fd2c7 100755 --- a/src/play.js +++ b/src/play.js @@ -391,6 +391,18 @@ async function main(args) { '-collapse': util => util.alias('-collapse-groups'), + '-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. diff --git a/todo.txt b/todo.txt index 073a18b..8cfdea2 100644 --- a/todo.txt +++ b/todo.txt @@ -433,3 +433,13 @@ TODO: Be a bit more loose (strict?) about what means crashing... Right now if counted as a failure. (An example case of the "play" command failing -- trying to play a track when there is no audio device.) (Done!) + +TODO: Group/album length visualizer thing!!! Colorful and PRETTY. Only work on + the first level of groups since I'm lazy and don't want to figure out how + to nicely display multiple levels. Use --keep + --save to use the viz on + specific albums (or maybe implement --clear, --keep, etc as common code + between the player and the visualizer, shrug). Also using --collapse + would work for comparing album lengths rather than artist lengths. + (Done!) + +TODO: Show mean/media/mode statistics for songs in duration-graph. -- cgit 1.3.0-6-gf8a5 From e9e0348d44fbba238f1321e56555489d7cf56f8a Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 26 Feb 2018 10:20:14 -0400 Subject: Readmeme --- README.md | 4 ++++ screenshots/hm-duration-graph.png | Bin 0 -> 99131 bytes 2 files changed, 4 insertions(+) create mode 100644 screenshots/hm-duration-graph.png diff --git a/README.md b/README.md index 2ded9a6..6e766df 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ $ http-music play ![Clear/keep](screenshots/hm-clearkeep.png) +**Visually look at the longest tracks in a playlist:** + +![Duration graph](screenshots/hm-duration-graph.png) + ## Documentation Check out [the man pages](man/). (Or view them with `man http-music`.) diff --git a/screenshots/hm-duration-graph.png b/screenshots/hm-duration-graph.png new file mode 100644 index 0000000..40ed895 Binary files /dev/null and b/screenshots/hm-duration-graph.png differ -- cgit 1.3.0-6-gf8a5 From 2fe1da71626067b9f477e3ec4b6f11490f7071b1 Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 26 Feb 2018 10:21:02 -0400 Subject: Add todo --- todo.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/todo.txt b/todo.txt index 8cfdea2..871aaa8 100644 --- a/todo.txt +++ b/todo.txt @@ -443,3 +443,6 @@ TODO: Group/album length visualizer thing!!! Colorful and PRETTY. Only work on (Done!) TODO: Show mean/media/mode statistics for songs in duration-graph. + +TODO: In duration-graph, show warning message if *no* items in a playlist have + duration metadata (direct the user to use process-metadata). -- cgit 1.3.0-6-gf8a5 From db9a8b38ed93891e6e77ddf4234600dc57eb2b23 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 3 Mar 2018 08:37:56 -0400 Subject: Add MOD files to crawl-local --- src/crawl-local.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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())) -- cgit 1.3.0-6-gf8a5 From 4ff150e42f03d51ddfed228e7da33b19eb773eaf Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 3 Mar 2018 08:38:19 -0400 Subject: Output sane extnames in local downloader --- src/downloaders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/downloaders.js b/src/downloaders.js index 138b2d6..663e493 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) } -- cgit 1.3.0-6-gf8a5 From 8278392e00ef396abcdabc1d538bebeb20deb70c Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 3 Mar 2018 08:38:40 -0400 Subject: Be more specific about loop-play errors --- src/loop-play.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/loop-play.js b/src/loop-play.js index 9c679c7..b44e83f 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -310,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 } @@ -326,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 -- cgit 1.3.0-6-gf8a5 From c0f76f3de7cad551cd6170dc37f2f7ece6e025c5 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 3 Mar 2018 08:43:54 -0400 Subject: Add track count metric to duration-graph --- src/duration-graph.js | 105 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 20 deletions(-) diff --git a/src/duration-graph.js b/src/duration-graph.js index 2ffdef7..47183c9 100644 --- a/src/duration-graph.js +++ b/src/duration-graph.js @@ -4,11 +4,21 @@ const fs = require('fs') const util = require('util') const processArgv = require('./process-argv') -const { updatePlaylistFormat, isGroup, isItem, getItemPathString } = require('./playlist-utils') +const { + updatePlaylistFormat, + isGroup, isItem, + getItemPathString, + flattenGrouplike +} = require('./playlist-utils') const readFile = util.promisify(fs.readFile) -const cachedDuration = Symbol('Cached duration') +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)) { @@ -26,11 +36,23 @@ function getUncachedDurationOfItem(item) { // This is mostly just to avoid logging out "item missing metadata" warnings // multiple times. function getDurationOfItem(item) { - if (cachedDuration in item === false) { - item[cachedDuration] = getUncachedDurationOfItem(item) + if (metrics.duration in item === false) { + item[metrics.duration] = getUncachedDurationOfItem(item) } - return item[cachedDuration] + 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) @@ -75,19 +97,48 @@ function padStartList(strings) { 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 + onlyFirst = 20, + metric = metrics.duration } = {}) { const output = [] - const wholePlaylistLength = getDurationOfItem(playlist) + const wholePlaylistLength = measureItem(playlist, metric) - let topThings = playlist.items.map((item, i) => ({ - item, - duration: getDurationOfItem(item), - digitalDuration: digitalFormatDuration(getDurationOfItem(item)) - })) + 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) @@ -97,11 +148,11 @@ function makePlaylistGraph(playlist, { const displayLength = topThings.reduce((a, b) => a + b.duration, 0) - // Left-pad the digital durations so they're all the same length. + // Left-pad the brief durations so they're all the same length. { - const len = topThings.reduce((a, b) => Math.max(a, b.digitalDuration.length), 0) + const len = topThings.reduce((a, b) => Math.max(a, b.briefDuration.length), 0) for (const obj of topThings) { - obj.digitalDuration = obj.digitalDuration.padStart(len, ' ') + obj.padDuration = obj.briefDuration.padStart(len, ' ') } } @@ -122,7 +173,7 @@ function makePlaylistGraph(playlist, { topThings[i].visualWidth = w } - output.push(' Whole length: ' + wordFormatDuration(wholePlaylistLength), '') + output.push(' Whole length: ' + longFormatDuration(wholePlaylistLength), '') output.push(' ' + topThings.map(({ bgColor, fgColor, visualWidth }) => { return bgColor + fgColor + '-'.repeat(visualWidth) @@ -130,15 +181,16 @@ function makePlaylistGraph(playlist, { output.push(' Length by item:') - output.push(...topThings.map(({ item, digitalDuration, visualWidth, fgColor }) => + 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- ' : ' ' - }${digitalDuration} ${item.name}\x1b[0m` + }${padDuration} ${item.name}\x1b[0m` )) if (ignoredThings.length) { - const dur = wordFormatDuration(ignoredThings.reduce((a, b) => a + b.duration, 0)) + 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` @@ -163,8 +215,21 @@ async function main(args) { 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) @@ -201,7 +266,7 @@ async function main(args) { const playlist = updatePlaylistFormat(JSON.parse(await readFile(args[0]))) for (const line of makePlaylistGraph(playlist, { - graphWidth, onlyFirst + graphWidth, onlyFirst, metric })) { console.log(line) } -- cgit 1.3.0-6-gf8a5 From 7c7d32d0136092d06f9747cc44c5a37bcc9832b8 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 7 Mar 2018 19:15:26 -0400 Subject: Make (t) key only show information about one track The showTrackInfo keybinding command can now take a number of next/ previous tracks to show. --- man/http-music-play.1 | 9 +++++++-- src/loop-play.js | 10 +++++++--- src/play.js | 7 +++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 978e00c..97e8475 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -54,8 +54,8 @@ Pauses (or resumes) playback. .TP .BR i -Shows information (title, URL/path) on the currently playing track. -(\fBt\fR also works.) +Shows information (title, URL/path) about the currently playing track, as well as the upcoming and previously-played three tracks. +(Use \fBt\fR to see information about just the current track.) .TP .BR p @@ -72,6 +72,11 @@ Quits the http-music process and stops music currently being played. Skips past the track that's currently playing. (\fB\fR also works.) +.TP +.BR t +Shows information about the track that's currently playing. +(Use \fBi\fR to also see previous and upcoming tracks.) + .SH OPTIONS .TP diff --git a/src/loop-play.js b/src/loop-play.js index b44e83f..0fd94e4 100644 --- a/src/loop-play.js +++ b/src/loop-play.js @@ -652,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' @@ -675,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])}`) } } diff --git a/src/play.js b/src/play.js index 28fd2c7..0b521b4 100755 --- a/src/play.js +++ b/src/play.js @@ -114,7 +114,7 @@ async function main(args) { [['delete'], 'skipUpNext'], [['s'], 'skipAhead'], [['S'], 'skipAhead'], [['i'], 'showTrackInfo'], [['I'], 'showTrackInfo'], - [['t'], 'showTrackInfo'], [['T'], 'showTrackInfo'], + [['t'], 'showTrackInfo', 0, 0], [['T'], 'showTrackInfo', 0, 0], [['q'], 'quit'], [['Q'], 'quit'] ] @@ -788,10 +788,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) { -- cgit 1.3.0-6-gf8a5 From 6916be5bb754d8e212087dd15fb7af85115862e4 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 14 Mar 2018 14:06:38 -0300 Subject: (duration-graph) Add --first and -f as aliases to --only-first --- src/duration-graph.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/duration-graph.js b/src/duration-graph.js index 47183c9..5d0bf85 100644 --- a/src/duration-graph.js +++ b/src/duration-graph.js @@ -255,6 +255,8 @@ async function main(args) { '-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 -- cgit 1.3.0-6-gf8a5 From 033109a6bf959541e6855abe613dc29c4cec4bbc Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 14 Mar 2018 14:07:03 -0300 Subject: Add alphabetic sort This automatically flattens the playlist, so you don't need to pass --flatten-tracks if you also pass --sort a-z. Unfortunately this means there's no particularly convenient way to sort groups alphabetically. --- man/http-music-play.1 | 3 ++- src/pickers.js | 39 ++++++++++++++++++++++++++++++++++++++- src/play.js | 1 + 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/man/http-music-play.1 b/man/http-music-play.1 index 97e8475..67d0cd7 100644 --- a/man/http-music-play.1 +++ b/man/http-music-play.1 @@ -186,7 +186,8 @@ A: togglePause will also show up higher in the list than A: showTrackInfo, so th .TP .BR \-\-sort\-mode ", " \-\-sort Sets the mode by which the playback order list is sorted. -Valid options include \fBorder\fR, \fBshuffle\fR (the default), and \fBshuffle-groups\fR. +Valid options include \fBorder\fR, \fBshuffle\fR (the default), \fBshuffle-groups\fR, and \fBalphabet\fR. +(Some variations of these strings, such as \fBa-z\fR and \fBshuffled\fR, are also valid.) See also \fB\-\-loop\-mode\fR. .TP 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 0b521b4..cccb89e 100755 --- a/src/play.js +++ b/src/play.js @@ -115,6 +115,7 @@ async function main(args) { [['s'], 'skipAhead'], [['S'], 'skipAhead'], [['i'], 'showTrackInfo'], [['I'], 'showTrackInfo'], [['t'], 'showTrackInfo', 0, 0], [['T'], 'showTrackInfo', 0, 0], + [['%'], 'showTrackInfo', 20, 0], [['q'], 'quit'], [['Q'], 'quit'] ] -- cgit 1.3.0-6-gf8a5 From 7831b28be25aae1e890ee1f4d3bd6969023c10da Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 14 Mar 2018 14:11:15 -0300 Subject: (play) Add -S as alias --sort-mode --- src/play.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/play.js b/src/play.js index cccb89e..30a151e 100755 --- a/src/play.js +++ b/src/play.js @@ -482,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 (alias: --seed) -- cgit 1.3.0-6-gf8a5