« get me outta code hell

Separate backend from UI - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/backend.js
diff options
context:
space:
mode:
authorFlorrie <towerofnix@gmail.com>2019-07-05 21:10:32 -0300
committerFlorrie <towerofnix@gmail.com>2019-07-05 22:02:46 -0300
commit08a3150fe9c9427bdb9e46d0cde14ad30747008d (patch)
treea0ad6e20e9745997038d4d57821083ae7f21e2e5 /backend.js
parentc2a89f91684df916aaf8f66eab33280ad99b6b1b (diff)
Separate backend from UI
Diffstat (limited to 'backend.js')
-rw-r--r--backend.js520
1 files changed, 520 insertions, 0 deletions
diff --git a/backend.js b/backend.js
new file mode 100644
index 0000000..c59121a
--- /dev/null
+++ b/backend.js
@@ -0,0 +1,520 @@
+// MTUI "server" - this just acts as the backend for mtui, controlling the
+// player, queue, etc. It's entirely independent from tui-lib/UI.
+
+'use strict'
+
+const { getDownloaderFor } = require('./downloaders')
+const { getMetadataReaderFor } = require('./metadata-readers')
+const { getPlayer } = require('./players')
+const RecordStore = require('./record-store')
+
+const {
+  shuffleArray,
+  throttlePromise
+} = require('./general-util')
+
+const {
+  isGroup,
+  isTrack,
+  flattenGrouplike,
+  getItemPathString,
+  parentSymbol
+} = require('./playlist-utils')
+
+const { promisify } = require('util')
+const EventEmitter = require('events')
+const fs = require('fs')
+const writeFile = promisify(fs.writeFile)
+const readFile = promisify(fs.readFile)
+
+class Backend extends EventEmitter {
+  constructor() {
+    super()
+
+    this.player = null
+    this.playingTrack = null
+    this.recordStore = new RecordStore()
+    this.throttleMetadata = throttlePromise(10)
+    this.metadataDictionary = {}
+    this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
+
+    this.rootDirectory = process.env.HOME + '/.mtui'
+    this.metadataPath = this.rootDirectory + '/track-metadata.json'
+  }
+
+
+  async setup() {
+    this.player = await getPlayer()
+
+    if (!this.player) {
+      return {
+        error: "Sorry, it doesn't look like there's an audio player installed on your computer. Can you try installing MPV (https://mpv.io) or SoX?"
+      }
+    }
+
+    await this.loadMetadata()
+
+    this.player.on('printStatusLine', data => {
+      if (this.playingTrack) {
+        this.emit('printStatusLine', data)
+      }
+    })
+
+    return true
+  }
+
+
+  async readMetadata() {
+    try {
+      return JSON.parse(await readFile(this.metadataPath))
+    } catch (error) {
+      // Just stop. It's okay to fail to load metadata.
+      return null
+    }
+  }
+
+  async loadMetadata() {
+    Object.assign(this.metadataDictionary, await this.readMetadata())
+  }
+
+  async saveMetadata() {
+    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
+    await writeFile(this.metadataPath, JSON.stringify(newData))
+  }
+
+  getMetadataFor(item) {
+    const key = this.metadataDictionary[item.downloaderArg]
+    return this.metadataDictionary[key] || null
+  }
+
+  async processMetadata(item, reprocess = false, top = true) {
+    let counter = 0
+
+    if (isGroup(item)) {
+      const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
+      counter += results.reduce((acc, n) => acc + n, 0)
+    } else process: {
+      if (!reprocess && this.getMetadataFor(item)) {
+        break process
+      }
+
+      await this.throttleMetadata(async () => {
+        const filePath = await this.download(item)
+        const metadataReader = getMetadataReaderFor(filePath)
+        const data = await metadataReader(filePath)
+
+        this.metadataDictionary[item.downloaderArg] = filePath
+        this.metadataDictionary[filePath] = data
+      })
+
+      this.emit('processMetadata progress', this.throttleMetadata.queue.length)
+
+      counter++
+    }
+
+    if (top) {
+      await this.saveMetadata()
+    }
+
+    return counter
+  }
+
+
+  getRecordFor(item) {
+    return this.recordStore.getRecord(item)
+  }
+
+
+  queue(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
+    const { items } = this.queueGrouplike
+    const newTrackIndex = items.length
+
+    // The position which new tracks should be added at, if afterItem is
+    // passed.
+    const afterIndex = afterItem && items.indexOf(afterItem)
+
+    // Keeps track of how many tracks have been added; this is used so that
+    // a whole group can be queued in order after a given item.
+    let grouplikeOffset = 0
+
+    const recursivelyAddTracks = item => {
+      // For groups, just queue all children.
+      if (isGroup(item)) {
+        for (const child of item.items) {
+          recursivelyAddTracks(child)
+        }
+
+        return
+      }
+
+      // You can't put the same track in the queue twice - we automatically
+      // remove the old entry. (You can't for a variety of technical reasons,
+      // but basically you either have the display all bork'd, or new tracks
+      // can't be added to the queue in the right order (because Object.assign
+      // is needed to fix the display, but then you end up with a new object
+      // that doesn't work with indexOf).)
+      if (items.includes(item)) {
+        // HOWEVER, if the "moveCurrentTrack" option is false, and that item
+        // is the one that's currently playing, we won't do anything with it
+        // at all.
+        if (!movePlayingTrack && item === this.playingTrack) {
+          return
+        }
+        items.splice(items.indexOf(item), 1)
+      }
+
+      if (afterItem === 'FRONT') {
+        items.unshift(item)
+      } else if (afterItem) {
+        items.splice(afterIndex + 1 + grouplikeOffset, 0, item)
+      } else {
+        items.push(item)
+      }
+
+      grouplikeOffset++
+    }
+
+    recursivelyAddTracks(topItem)
+
+    // This is the first new track, if a group was queued.
+    const newTrack = items[newTrackIndex]
+
+    return newTrack
+  }
+
+  distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) {
+    if (isTrack(grouplike)) {
+      grouplike = {items: [grouplike]}
+    }
+
+    const { items } = this.queueGrouplike
+    const newItems = flattenGrouplike(grouplike).items
+
+    // Expressly do an initial pass and unqueue the items we want to queue -
+    // otherwise they would mess with the math we do afterwords.
+    for (const item of newItems) {
+      if (items.includes(item)) {
+        /*
+        if (!movePlayingTrack && item === this.playingTrack) {
+          // NB: if uncommenting this code, splice item from newItems and do
+          // continue instead of return!
+          return
+        }
+        */
+        items.splice(items.indexOf(item), 1)
+      }
+    }
+
+    const distributeStart = items.indexOf(this.playingTrack) + 1
+
+    let distributeEnd
+    if (rangeEnd === 'end-of-queue') {
+      distributeEnd = items.length
+    } else if (typeof rangeEnd === 'number') {
+      distributeEnd = Math.min(items.length, rangeEnd)
+    } else {
+      throw new Error('Invalid rangeEnd: ' + rangeEnd)
+    }
+
+    const distributeSize = distributeEnd - distributeStart
+
+    const queueItem = (item, insertIndex) => {
+      if (items.includes(item)) {
+        /*
+        if (!movePlayingTrack && item === this.playingTrack) {
+          return
+        }
+        */
+        items.splice(items.indexOf(item), 1)
+      } else {
+        offset++
+      }
+      items.splice(insertIndex, 0, item)
+    }
+
+    if (how === 'evenly') {
+      let offset = 0
+      for (const item of newItems) {
+        const insertIndex = distributeStart + Math.floor(offset)
+        items.splice(insertIndex, 0, item)
+        offset++
+        offset += distributeSize / newItems.length
+      }
+    } else if (how === 'randomly') {
+      const indexes = newItems.map(() => Math.floor(Math.random() * distributeSize))
+      indexes.sort()
+      for (let i = 0; i < newItems.length; i++) {
+        const item = newItems[i]
+        const insertIndex = distributeStart + indexes[i] + i
+        items.splice(insertIndex, 0, item)
+      }
+    }
+  }
+
+  unqueue(topItem, focusItem = null) {
+    // This function has support to unqueue groups - it removes all tracks in
+    // the group recursively. (You can never unqueue a group itself from the
+    // queue listing because groups can't be added directly to the queue.)
+
+    const { items } = this.queueGrouplike
+
+    const recursivelyUnqueueTracks = item => {
+      // For groups, just unqueue all children. (Groups themselves can't be
+      // added to the queue, so we don't need to worry about removing them.)
+      if (isGroup(item)) {
+        for (const child of item.items) {
+          recursivelyUnqueueTracks(child)
+        }
+
+        return
+      }
+
+      // Don't unqueue the currently-playing track - this usually causes more
+      // trouble than it's worth.
+      if (item === this.playingTrack) {
+        return
+      }
+
+      // If we're unqueueing the item which is currently focused by the cursor,
+      // just move the cursor ahead.
+      if (item === focusItem) {
+        focusItem = items[items.indexOf(focusItem) + 1]
+        // ...Unless that puts it at past the end of the list, in which case, move
+        // it behind the item we're removing.
+        if (!focusItem) {
+          focusItem = items[items.length - 2]
+        }
+      }
+
+      if (items.includes(item)) {
+        items.splice(items.indexOf(item), 1)
+      }
+    }
+
+    recursivelyUnqueueTracks(topItem)
+
+    return focusItem
+  }
+
+  playSooner(item) {
+    this.distributeQueue(item, {
+      how: 'randomly',
+      rangeEnd: this.queueGrouplike.items.indexOf(item)
+    })
+  }
+
+  playLater(item) {
+    this.skipIfCurrent(item)
+    this.distributeQueue(item, {
+      how: 'randomly'
+    })
+  }
+
+  skipIfCurrent(track) {
+    if (track === this.playingTrack) {
+      this.playNextTrack(track)
+    }
+  }
+
+  shuffleQueue() {
+    const queue = this.queueGrouplike
+    const index = queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
+    const initialItems = queue.items.slice(0, index)
+    const remainingItems = queue.items.slice(index)
+    const newItems = initialItems.concat(shuffleArray(remainingItems))
+    queue.items = newItems
+  }
+
+  clearQueue() {
+    // Clear the queue so that there aren't any items left in it (except for
+    // the track that's currently playing).
+    this.queueGrouplike.items = this.queueGrouplike.items
+      .filter(item => item === this.playingTrack)
+  }
+
+
+  seekAhead(seconds) {
+    this.player.seekAhead(seconds)
+  }
+
+  seekBack(seconds) {
+    this.player.seekBack(seconds)
+  }
+
+  togglePause() {
+    this.player.togglePause()
+  }
+
+  setPause(value) {
+    this.player.setPause(value)
+  }
+
+  toggleLoop() {
+    this.player.toggleLoop()
+  }
+
+  setLoop(value) {
+    this.player.setLoop(value)
+  }
+
+  volUp(amount = 10) {
+    this.player.volUp(amount)
+  }
+
+  volDown(amount = 10) {
+    this.player.volDown(amount)
+  }
+
+  setVolume(value) {
+    this.player.setVolume(value)
+  }
+
+  stopPlaying() {
+    // We emit this so playTrack doesn't immediately start a new track.
+    // We aren't *actually* about to play a new track.
+    this.emit('playing new track')
+    this.player.kill()
+    this.clearPlayingTrack()
+  }
+
+
+  async play(item) {
+    if (this.player === null) {
+      throw new Error('Attempted to play before a player was loaded')
+    }
+
+    let playingThisTrack = true
+    this.emit('playing new track')
+    this.once('playing new track', () => {
+      playingThisTrack = false
+    })
+
+    // If it's a group, play the first track.
+    if (isGroup(item)) {
+      item = flattenGrouplike(item).items[0]
+    }
+
+    // If there is no item (e.g. an empty group), well.. don't do anything.
+    if (!item) {
+      return
+    }
+
+    playTrack: {
+      // No downloader argument? That's no good - stop here.
+      // TODO: An error icon on this item, or something???
+      if (!item.downloaderArg) {
+        break playTrack
+      }
+
+      // If, by the time the track is downloaded, we're playing something
+      // different from when the download started, assume that we just want to
+      // keep listening to whatever new thing we started.
+
+      const oldTrack = this.playingTrack
+
+      const downloadFile = await this.download(item)
+
+      if (this.playingTrack !== oldTrack) {
+        return
+      }
+
+      this.playingTrack = item
+      this.emit('playing', this.playingTrack, oldTrack)
+
+      await this.player.kill()
+      await this.player.playFile(downloadFile)
+    }
+
+    // playingThisTrack now means whether the track played through to the end
+    // (true), or was stopped by a different track being started (false).
+
+    if (playingThisTrack) {
+      if (!this.playNext(item)) {
+        this.clearPlayingTrack()
+      }
+    }
+  }
+
+  playNext(track, automaticallyQueueNextTrack = false) {
+    if (!track) return false
+
+    const queue = this.queueGrouplike
+    let queueIndex = queue.items.indexOf(track)
+    if (queueIndex === -1) return false
+    queueIndex++
+
+    if (queueIndex >= queue.items.length) {
+      if (automaticallyQueueNextTrack) {
+        const parent = track[parentSymbol]
+        if (!parent) return false
+        const index = parent.items.indexOf(track)
+        const nextItem = parent.items[index + 1]
+        if (!nextItem) return false
+        this.queue(nextItem)
+        queueIndex = queue.items.length - 1
+      } else {
+        return false
+      }
+    }
+
+    this.play(queue.items[queueIndex])
+    return true
+  }
+
+  playPrevious(track, automaticallyQueuePreviousTrack = false) {
+    if (!track) return false
+
+    const queue = this.queueGrouplike
+    let queueIndex = queue.items.indexOf(track)
+    if (queueIndex === -1) return false
+    queueIndex--
+
+    if (queueIndex < 0) {
+      if (automaticallyQueuePreviousTrack) {
+        const parent = track[parentSymbol]
+        if (!parent) return false
+        const index = parent.items.indexOf(track)
+        const previousItem = parent.items[index - 1]
+        if (!previousItem) return false
+        this.queue(previousItem, 'FRONT')
+        queueIndex = 0
+      } else {
+        return false
+      }
+    }
+
+    this.play(queue.items[queueIndex])
+    return true
+  }
+
+  clearPlayingTrack() {
+    if (this.playingTrack !== null) {
+      const oldTrack = this.playingTrack
+      this.playingTrack = null
+      this.emit('playing', null, oldTrack)
+    }
+  }
+
+  async download(item) {
+    if (isGroup(item)) {
+      // TODO: Download all children (recursively), show a confirmation prompt
+      // if there are a lot of items (remember to flatten).
+      return
+    }
+
+    // Don't start downloading an item if we're already downloading it!
+    if (this.getRecordFor(item).downloading) {
+      return
+    }
+
+    const arg = item.downloaderArg
+    this.getRecordFor(item).downloading = true
+    try {
+      return await getDownloaderFor(arg)(arg)
+    } finally {
+      this.getRecordFor(item).downloading = false
+    }
+  }
+}
+
+module.exports = Backend