From 08a3150fe9c9427bdb9e46d0cde14ad30747008d Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 5 Jul 2019 21:10:32 -0300 Subject: Separate backend from UI --- backend.js | 520 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 backend.js (limited to 'backend.js') 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 -- cgit 1.3.0-6-gf8a5