From 75251bb2309505c20dc7500117a17649d41412d8 Mon Sep 17 00:00:00 2001 From: Florrie Date: Mon, 25 Feb 2019 11:27:11 -0400 Subject: Metadata (in memory) --- general-util.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++ metadata-readers.js | 46 +++++++++++++++++++++++++++++ players.js | 41 +------------------------- todo.txt | 12 ++++++++ ui.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 224 insertions(+), 45 deletions(-) create mode 100644 metadata-readers.js diff --git a/general-util.js b/general-util.js index 0b9f081..708e150 100644 --- a/general-util.js +++ b/general-util.js @@ -92,3 +92,88 @@ module.exports.shuffleArray = function(array) { return workingArray } +module.exports.throttlePromise = function(maximumAtOneTime = 10) { + // Returns a function that takes a callback to create a promise and either + // runs it now, if there is an available slot, or enqueues it to be run + // later, if there is not. + + let activeCount = 0 + const queue = [] + + const execute = function(callback) { + activeCount++ + return callback().finally(() => { + activeCount-- + + if (queue.length) { + return execute(queue.shift()) + } + }) + } + + return function(callback) { + if (activeCount >= maximumAtOneTime) { + return new Promise((resolve, reject) => { + queue.push(function() { + return callback().then(resolve, reject) + }) + }) + } else { + return execute(callback) + } + } +} + +module.exports.getTimeStringsFromSec = function(curSecTotal, lenSecTotal) { + const percentVal = (100 / lenSecTotal) * curSecTotal + const percentDone = ( + (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' + ) + + const leftSecTotal = lenSecTotal - curSecTotal + let leftHour = Math.floor(leftSecTotal / 3600) + let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60) + let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60) + + // Yeah, yeah, duplicate math. + let curHour = Math.floor(curSecTotal / 3600) + let curMin = Math.floor((curSecTotal - curHour * 3600) / 60) + let curSec = Math.floor(curSecTotal - curHour * 3600 - curMin * 60) + + // Wee! + let lenHour = Math.floor(lenSecTotal / 3600) + let lenMin = Math.floor((lenSecTotal - lenHour * 3600) / 60) + let lenSec = Math.floor(lenSecTotal - lenHour * 3600 - lenMin * 60) + + const pad = val => val.toString().padStart(2, '0') + curMin = pad(curMin) + curSec = pad(curSec) + lenMin = pad(lenMin) + lenSec = pad(lenSec) + leftMin = pad(leftMin) + leftSec = pad(leftSec) + + // We don't want to display hour counters if the total length is less + // than an hour. + let timeDone, timeLeft, duration + if (parseInt(lenHour) > 0) { + timeDone = `${curHour}:${curMin}:${curSec}` + timeLeft = `${leftHour}:${leftMin}:${leftSec}` + duration = `${lenHour}:${lenMin}:${lenSec}` + } else { + timeDone = `${curMin}:${curSec}` + timeLeft = `${leftMin}:${leftSec}` + duration = `${lenMin}:${lenSec}` + } + + return {percentDone, timeDone, timeLeft, duration, curSecTotal, lenSecTotal} +} + +module.exports.getTimeStrings = function({curHour, curMin, curSec, lenHour, lenMin, lenSec}) { + // Multiplication casts to numbers; addition prioritizes strings. + // Thanks, JavaScript! + const curSecTotal = (3600 * curHour) + (60 * curMin) + (1 * curSec) + const lenSecTotal = (3600 * lenHour) + (60 * lenMin) + (1 * lenSec) + + return module.exports.getTimeStringsFromSec(curSecTotal, lenSecTotal) +} diff --git a/metadata-readers.js b/metadata-readers.js new file mode 100644 index 0000000..1e6eb1b --- /dev/null +++ b/metadata-readers.js @@ -0,0 +1,46 @@ +const { promisifyProcess } = require('./general-util') +const { spawn } = require('child_process') + +const metadataReaders = { + ffprobe: async 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) + + let data + + try { + data = JSON.parse(probeDataString) + } catch (error) { + return null + } + + if (typeof data !== 'object' || typeof data.format !== 'object') { + return null + } + + return { + duration: parseFloat(data.format.duration), + fileSize: parseInt(data.format.size), + bitrate: parseInt(data.format.bit_rate) + } + }, + + getMetadataReaderFor: arg => { + return metadataReaders.ffprobe + } +} + +module.exports = metadataReaders diff --git a/players.js b/players.js index e9cf76e..0c980e7 100644 --- a/players.js +++ b/players.js @@ -3,46 +3,7 @@ const { spawn } = require('child_process') const FIFO = require('fifo-js') const EventEmitter = require('events') -const { commandExists, killProcess } = require('./general-util') - -function getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}) { - // Multiplication casts to numbers; addition prioritizes strings. - // Thanks, JavaScript! - const curSecTotal = (3600 * curHour) + (60 * curMin) + (1 * curSec) - const lenSecTotal = (3600 * lenHour) + (60 * lenMin) + (1 * lenSec) - const percentVal = (100 / lenSecTotal) * curSecTotal - const percentDone = ( - (Math.trunc(percentVal * 100) / 100).toFixed(2) + '%' - ) - - const leftSecTotal = lenSecTotal - curSecTotal - let leftHour = Math.floor(leftSecTotal / 3600) - let leftMin = Math.floor((leftSecTotal - leftHour * 3600) / 60) - let leftSec = Math.floor(leftSecTotal - leftHour * 3600 - leftMin * 60) - - const pad = val => val.toString().padStart(2, '0') - curMin = pad(curMin) - curSec = pad(curSec) - lenMin = pad(lenMin) - lenSec = pad(lenSec) - leftMin = pad(leftMin) - leftSec = pad(leftSec) - - // We don't want to display hour counters if the total length is less - // than an hour. - let timeDone, timeLeft, duration - if (parseInt(lenHour) > 0) { - timeDone = `${curHour}:${curMin}:${curSec}` - timeLeft = `${leftHour}:${leftMin}:${leftSec}` - duration = `${lenHour}:${lenMin}:${lenSec}` - } else { - timeDone = `${curMin}:${curSec}` - timeLeft = `${leftMin}:${leftSec}` - duration = `${lenMin}:${lenSec}` - } - - return {percentDone, timeDone, timeLeft, duration, curSecTotal, lenSecTotal} -} +const { commandExists, killProcess, getTimeStrings } = require('./general-util') class Player extends EventEmitter { constructor() { diff --git a/todo.txt b/todo.txt index 13db542..c47b815 100644 --- a/todo.txt +++ b/todo.txt @@ -194,3 +194,15 @@ TODO: Loop one song! TODO: Volume controls! (Done!) + +TODO: Metadata, in memory. + (Done!) + +TODO: Load metadata from storage. + +TODO: Restore metadata, if it's recognized as similar to an old path? + +TODO: Don't store duplicate metadata entries (prereq for tags, custom metadata, + etc) for the same track. Do it symlink-style -- map downloader arg to + actual key used for metadata (downloaded file path). + (Done!) diff --git a/ui.js b/ui.js index 12ef4b2..3fbaa51 100644 --- a/ui.js +++ b/ui.js @@ -1,8 +1,9 @@ const { getAllCrawlersForArg } = require('./crawlers') +const { getMetadataReaderFor } = require('./metadata-readers') const { getDownloaderFor } = require('./downloaders') const { getPlayer } = require('./players') const { parentSymbol, isGroup, isTrack, getItemPath, getItemPathString, flattenGrouplike, countTotalItems, shuffleOrderOfGroups, cloneGrouplike } = require('./playlist-utils') -const { shuffleArray } = require('./general-util') +const { shuffleArray, throttlePromise, getTimeStringsFromSec } = require('./general-util') const processSmartPlaylist = require('./smart-playlist') const UndoManager = require('./undo-manager') const RecordStore = require('./record-store') @@ -40,6 +41,8 @@ class AppElement extends FocusElement { this.player = null this.recordStore = new RecordStore() this.undoManager = new UndoManager() + this.throttleMetadata = throttlePromise(10) + this.metadataDictionary = {} this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} this.markGrouplike = {name: 'Marked', items: []} this.editMode = false @@ -284,6 +287,7 @@ class AppElement extends FocusElement { {label: 'Queue!', action: emitControls(false)}, {divider: true}, + {label: 'Process metadata', action: () => this.processMetadata(item)}, {label: 'Remove from queue', action: () => this.unqueueGrouplikeItem(item)} ] } @@ -867,6 +871,26 @@ class AppElement extends FocusElement { this.stopPlaying() this.playbackInfoElement.clearInfo() } + + processMetadata(item) { + if (isGroup(item)) { + return Promise.all(item.items.map(x => this.processMetadata(x))) + } + + return this.throttleMetadata(async () => { + const filePath = await this.downloadGrouplikeItem(item) + const metadataReader = getMetadataReaderFor(filePath) + const data = await metadataReader(filePath) + + this.metadataDictionary[item.downloaderArg] = filePath + this.metadataDictionary[filePath] = data + }) + } + + getMetadataFor(item) { + const key = this.metadataDictionary[item.downloaderArg] + return this.metadataDictionary[key] || null + } } class GrouplikeListingElement extends Form { @@ -1033,6 +1057,10 @@ class GrouplikeListingElement extends Form { const itemElement = new InteractiveGrouplikeItemElement(item, this.app) this.addEventListeners(itemElement) form.addInput(itemElement) + + if (this.grouplike.isTheQueue) { + itemElement.hideMetadata = true + } } } else if (!this.grouplike.isTheQueue) { form.addInput(new BasicGrouplikeItemElement('(This group is empty)')) @@ -1213,7 +1241,10 @@ class BasicGrouplikeItemElement extends Button { constructor(text) { super() + this._text = this._rightText = '' + this.text = text + this.rightText = '' this.drawText = '' } @@ -1224,11 +1255,45 @@ class BasicGrouplikeItemElement extends Button { this.computeText() } + set text(val) { + if (this._text !== val) { + this._text = val + this.computeText() + } + } + + get text() { + return this._text + } + + set rightText(val) { + if (this._rightText !== val) { + this._rightText = val + this.computeText() + } + } + + get rightText() { + return this._rightText + } + computeText() { let text = '' let done = false let heckingWatchOut = false + // TODO: Hide right text if there's not enough columns (plus some padding) + + // 3 = width of status line, basically + let w = this.w - this.x - 3 + + // Also make space for the right text - if we choose to show it. + const rightTextCols = ansi.measureColumns(this.rightText) + const showRightText = (w - rightTextCols > 12) + if (showRightText) { + w -= rightTextCols + } + const writable = { write: characters => { if (heckingWatchOut && done) { @@ -1237,8 +1302,7 @@ class BasicGrouplikeItemElement extends Button { for (const char of characters) { if (heckingWatchOut) { - // 3 = width of status line, basically - if (ansi.measureColumns(text + char) + 3 <= this.w - this.x) { + if (ansi.measureColumns(text + char) <= w) { text += char } else { done = true @@ -1258,8 +1322,10 @@ class BasicGrouplikeItemElement extends Button { heckingWatchOut = false const width = ansi.measureColumns(this.text) - // again, 3 = width of status bar - writable.write(' '.repeat(Math.max(0, this.w - width - 3))) + writable.write(' '.repeat(Math.max(0, w - width))) + if (showRightText) { + writable.write(this.rightText) + } writable.write(ansi.resetAttributes()) this.drawText = text @@ -1422,9 +1488,18 @@ class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement { super(item.name) this.item = item this.app = app + this.hideMetadata = false } drawTo(writable) { + if (!this.hideMetadata) { + const metadata = this.app.getMetadataFor(this.item) + if (metadata) { + const durationString = getTimeStringsFromSec(0, metadata.duration).duration + this.rightText = ` (${durationString}) ` + } + } + this.text = this.item.name super.drawTo(writable) } -- cgit 1.3.0-6-gf8a5