« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--general-util.js85
-rw-r--r--metadata-readers.js46
-rw-r--r--players.js41
-rw-r--r--todo.txt12
-rw-r--r--ui.js85
5 files changed, 224 insertions, 45 deletions
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)
   }