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