backend.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  1. // MTUI "server" - this just acts as the backend for mtui, controlling the
  2. // player, queue, etc. It's entirely independent from tui-lib/UI.
  3. 'use strict'
  4. import {readFile, writeFile} from 'node:fs/promises'
  5. import EventEmitter from 'node:events'
  6. import os from 'node:os'
  7. import {getDownloaderFor} from './downloaders.js'
  8. import {getMetadataReaderFor} from './metadata-readers.js'
  9. import {getPlayer} from './players.js'
  10. import RecordStore from './record-store.js'
  11. import {
  12. getTimeStringsFromSec,
  13. shuffleArray,
  14. throttlePromise,
  15. } from './general-util.js'
  16. import {
  17. isGroup,
  18. isTrack,
  19. flattenGrouplike,
  20. parentSymbol,
  21. } from './playlist-utils.js'
  22. async function download(item, record) {
  23. if (isGroup(item)) {
  24. // TODO: Download all children (recursively), show a confirmation prompt
  25. // if there are a lot of items (remember to flatten).
  26. return
  27. }
  28. // You can't download things that aren't tracks!
  29. if (!isTrack(item)) {
  30. return
  31. }
  32. // Don't start downloading an item if we're already downloading it!
  33. if (record.downloading) {
  34. return
  35. }
  36. const arg = item.downloaderArg
  37. record.downloading = true
  38. try {
  39. return await getDownloaderFor(arg)(arg)
  40. } finally {
  41. record.downloading = false
  42. }
  43. }
  44. class QueuePlayer extends EventEmitter {
  45. constructor({
  46. getPlayer,
  47. getRecordFor
  48. }) {
  49. super()
  50. this.player = null
  51. this.playingTrack = null
  52. this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
  53. this.pauseNextTrack = false
  54. this.queueEndMode = 'end' // end, loop, shuffle
  55. this.playedTrackToEnd = false
  56. this.timeData = null
  57. this.getPlayer = getPlayer
  58. this.getRecordFor = getRecordFor
  59. }
  60. async setup() {
  61. this.player = await this.getPlayer()
  62. if (!this.player) {
  63. return {
  64. 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?"
  65. }
  66. }
  67. this.player.on('printStatusLine', data => {
  68. if (this.playingTrack) {
  69. const oldTimeData = this.timeData
  70. this.timeData = data
  71. this.emit('received time data', data, oldTimeData, this)
  72. }
  73. })
  74. return true
  75. }
  76. queue(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
  77. const { items } = this.queueGrouplike
  78. const newTrackIndex = items.length
  79. // The position which new tracks should be added at, if afterItem is
  80. // passed.
  81. const afterIndex = afterItem && items.indexOf(afterItem)
  82. // Keeps track of how many tracks have been added; this is used so that
  83. // a whole group can be queued in order after a given item.
  84. let grouplikeOffset = 0
  85. // Keeps track of how many tracks have been removed (times -1); this is
  86. // used so we queue tracks at the intended spot.
  87. let removeOffset = 0
  88. const recursivelyAddTracks = item => {
  89. // For groups, just queue all children.
  90. if (isGroup(item)) {
  91. for (const child of item.items) {
  92. recursivelyAddTracks(child)
  93. }
  94. return
  95. }
  96. // If the item isn't a track, it can't be queued.
  97. if (!isTrack(item)) {
  98. return
  99. }
  100. // You can't put the same track in the queue twice - we automatically
  101. // remove the old entry. (You can't for a variety of technical reasons,
  102. // but basically you either have the display all bork'd, or new tracks
  103. // can't be added to the queue in the right order (because Object.assign
  104. // is needed to fix the display, but then you end up with a new object
  105. // that doesn't work with indexOf).)
  106. if (items.includes(item)) {
  107. // HOWEVER, if the "moveCurrentTrack" option is false, and that item
  108. // is the one that's currently playing, we won't do anything with it
  109. // at all.
  110. if (!movePlayingTrack && item === this.playingTrack) {
  111. return
  112. }
  113. const removeIndex = items.indexOf(item)
  114. items.splice(removeIndex, 1)
  115. // If the item we removed was positioned before the insertion index,
  116. // we need to shift that index back one, so it's placed after the same
  117. // intended track.
  118. if (removeIndex <= afterIndex) {
  119. removeOffset--
  120. }
  121. }
  122. if (afterItem === 'FRONT') {
  123. items.unshift(item)
  124. } else if (afterItem) {
  125. items.splice(afterIndex + 1 + grouplikeOffset + removeOffset, 0, item)
  126. } else {
  127. items.push(item)
  128. }
  129. grouplikeOffset++
  130. }
  131. recursivelyAddTracks(topItem)
  132. this.emitQueueUpdated()
  133. // This is the first new track, if a group was queued.
  134. const newTrack = items[newTrackIndex]
  135. return newTrack
  136. }
  137. distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) {
  138. if (isTrack(grouplike)) {
  139. grouplike = {items: [grouplike]}
  140. }
  141. const { items } = this.queueGrouplike
  142. const newTracks = flattenGrouplike(grouplike).items.filter(isTrack)
  143. // Expressly do an initial pass and unqueue the items we want to queue -
  144. // otherwise they would mess with the math we do afterwords.
  145. for (const item of newTracks) {
  146. if (items.includes(item)) {
  147. /*
  148. if (!movePlayingTrack && item === this.playingTrack) {
  149. // NB: if uncommenting this code, splice item from newTracks and do
  150. // continue instead of return!
  151. return
  152. }
  153. */
  154. items.splice(items.indexOf(item), 1)
  155. }
  156. }
  157. const distributeStart = items.indexOf(this.playingTrack) + 1
  158. let distributeEnd
  159. if (rangeEnd === 'end-of-queue') {
  160. distributeEnd = items.length
  161. } else if (typeof rangeEnd === 'number') {
  162. distributeEnd = Math.min(items.length, rangeEnd)
  163. } else {
  164. throw new Error('Invalid rangeEnd: ' + rangeEnd)
  165. }
  166. const distributeSize = distributeEnd - distributeStart
  167. if (how === 'evenly') {
  168. let offset = 0
  169. for (const item of newTracks) {
  170. const insertIndex = distributeStart + Math.floor(offset)
  171. items.splice(insertIndex, 0, item)
  172. offset++
  173. offset += distributeSize / newTracks.length
  174. }
  175. } else if (how === 'randomly') {
  176. const indexes = newTracks.map(() => Math.floor(Math.random() * distributeSize))
  177. indexes.sort()
  178. for (let i = 0; i < newTracks.length; i++) {
  179. const item = newTracks[i]
  180. const insertIndex = distributeStart + indexes[i] + i
  181. items.splice(insertIndex, 0, item)
  182. }
  183. }
  184. this.emitQueueUpdated()
  185. }
  186. unqueue(topItem, focusItem = null) {
  187. // This function has support to unqueue groups - it removes all tracks in
  188. // the group recursively. (You can never unqueue a group itself from the
  189. // queue listing because groups can't be added directly to the queue.)
  190. const { items } = this.queueGrouplike
  191. const recursivelyUnqueueTracks = item => {
  192. // For groups, just unqueue all children. (Groups themselves can't be
  193. // added to the queue, so we don't need to worry about removing them.)
  194. if (isGroup(item)) {
  195. for (const child of item.items) {
  196. recursivelyUnqueueTracks(child)
  197. }
  198. return
  199. }
  200. // Don't unqueue the currently-playing track - this usually causes more
  201. // trouble than it's worth.
  202. if (item === this.playingTrack) {
  203. return
  204. }
  205. // If we're unqueueing the item which is currently focused by the cursor,
  206. // just move the cursor ahead.
  207. if (item === focusItem) {
  208. focusItem = items[items.indexOf(focusItem) + 1]
  209. // ...Unless that puts it at past the end of the list, in which case, move
  210. // it behind the item we're removing.
  211. if (!focusItem) {
  212. focusItem = items[items.length - 2]
  213. }
  214. }
  215. if (items.includes(item)) {
  216. items.splice(items.indexOf(item), 1)
  217. }
  218. }
  219. recursivelyUnqueueTracks(topItem)
  220. this.emitQueueUpdated()
  221. return focusItem
  222. }
  223. clearQueuePast(track) {
  224. const { items } = this.queueGrouplike
  225. const index = items.indexOf(track) + 1
  226. if (index < 0) {
  227. return
  228. } else if (index < items.indexOf(this.playingTrack)) {
  229. items.splice(index, items.length - index, this.playingTrack)
  230. } else {
  231. items.splice(index)
  232. }
  233. this.emitQueueUpdated()
  234. }
  235. clearQueueUpTo(track) {
  236. const { items } = this.queueGrouplike
  237. const endIndex = items.indexOf(track)
  238. const startIndex = (this.playingTrack ? items.indexOf(this.playingTrack) + 1 : 0)
  239. if (endIndex < 0) {
  240. return
  241. } else if (endIndex < startIndex) {
  242. return
  243. } else {
  244. items.splice(startIndex, endIndex - startIndex)
  245. }
  246. this.emitQueueUpdated()
  247. }
  248. playSooner(item) {
  249. this.distributeQueue(item, {
  250. how: 'randomly',
  251. rangeEnd: this.queueGrouplike.items.indexOf(item)
  252. })
  253. }
  254. playLater(item) {
  255. this.skipIfCurrent(item)
  256. this.distributeQueue(item, {
  257. how: 'randomly'
  258. })
  259. }
  260. skipIfCurrent(track) {
  261. if (track === this.playingTrack) {
  262. this.playNext(track)
  263. }
  264. }
  265. shuffleQueue(pastPlayingTrackOnly = true) {
  266. const queue = this.queueGrouplike
  267. const index = (pastPlayingTrackOnly
  268. ? queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
  269. : 0)
  270. const initialItems = queue.items.slice(0, index)
  271. const remainingItems = queue.items.slice(index)
  272. const newItems = initialItems.concat(shuffleArray(remainingItems))
  273. queue.items = newItems
  274. this.emitQueueUpdated()
  275. }
  276. clearQueue() {
  277. // Clear the queue so that there aren't any items left in it (except for
  278. // the track that's currently playing).
  279. this.queueGrouplike.items = this.queueGrouplike.items
  280. .filter(item => item === this.playingTrack)
  281. this.emitQueueUpdated()
  282. }
  283. emitQueueUpdated() {
  284. this.emit('queue updated')
  285. }
  286. async stopPlaying() {
  287. // We emit this so the active play() call doesn't immediately start a new
  288. // track. We aren't *actually* about to play a new track.
  289. this.emit('playing new track')
  290. await this.player.kill()
  291. this.clearPlayingTrack()
  292. }
  293. async play(item, startTime = 0) {
  294. if (this.player === null) {
  295. throw new Error('Attempted to play before a player was loaded')
  296. }
  297. let playingThisTrack = true
  298. this.emit('playing new track')
  299. this.once('playing new track', () => {
  300. playingThisTrack = false
  301. })
  302. // If it's a group, play the first track.
  303. if (isGroup(item)) {
  304. item = flattenGrouplike(item).items[0]
  305. }
  306. // If there is no item (e.g. an empty group), well.. don't do anything.
  307. if (!item) {
  308. return
  309. }
  310. // If it's not a track, you can't play it.
  311. if (!isTrack(item)) {
  312. return
  313. }
  314. playTrack: {
  315. // No downloader argument? That's no good - stop here.
  316. // TODO: An error icon on this item, or something???
  317. if (!item.downloaderArg) {
  318. break playTrack
  319. }
  320. // If, by the time the track is downloaded, we're playing something
  321. // different from when the download started, assume that we just want to
  322. // keep listening to whatever new thing we started.
  323. const oldTrack = this.playingTrack
  324. const downloadFile = await this.download(item)
  325. if (this.playingTrack !== oldTrack) {
  326. return
  327. }
  328. this.timeData = null
  329. this.playingTrack = item
  330. this.emit('playing', this.playingTrack, oldTrack, startTime, this)
  331. await this.player.kill()
  332. if (this.playedTrackToEnd) {
  333. this.player.setPause(this.pauseNextTrack)
  334. this.pauseNextTrack = false
  335. this.playedTrackToEnd = false
  336. } else {
  337. this.player.setPause(false)
  338. }
  339. await this.player.playFile(downloadFile, startTime)
  340. }
  341. // playingThisTrack now means whether the track played through to the end
  342. // (true), or was stopped by a different track being started (false).
  343. if (playingThisTrack) {
  344. this.playedTrackToEnd = true
  345. this.playNext(item)
  346. }
  347. }
  348. playNext(track, automaticallyQueueNextTrack = false) {
  349. if (!track) return false
  350. // Auto-queue is nice but it should only happen when the queue hasn't been
  351. // explicitly set to loop.
  352. automaticallyQueueNextTrack = (
  353. automaticallyQueueNextTrack &&
  354. this.queueEndMode === 'end')
  355. const queue = this.queueGrouplike
  356. let queueIndex = queue.items.indexOf(track)
  357. if (queueIndex === -1) return false
  358. queueIndex++
  359. if (queueIndex >= queue.items.length) {
  360. if (automaticallyQueueNextTrack) {
  361. const parent = track[parentSymbol]
  362. if (!parent) return false
  363. let index = parent.items.indexOf(track)
  364. let nextItem
  365. do {
  366. nextItem = parent.items[++index]
  367. } while (nextItem && !(isTrack(nextItem) || isGroup(nextItem)))
  368. if (!nextItem) return false
  369. this.queue(nextItem)
  370. queueIndex = queue.items.length - 1
  371. } else {
  372. return this.playNextAtQueueEnd()
  373. }
  374. }
  375. this.play(queue.items[queueIndex])
  376. return true
  377. }
  378. playPrevious(track, automaticallyQueuePreviousTrack = false) {
  379. if (!track) return false
  380. const queue = this.queueGrouplike
  381. let queueIndex = queue.items.indexOf(track)
  382. if (queueIndex === -1) return false
  383. queueIndex--
  384. if (queueIndex < 0) {
  385. if (automaticallyQueuePreviousTrack) {
  386. const parent = track[parentSymbol]
  387. if (!parent) return false
  388. let index = parent.items.indexOf(track)
  389. let previousItem
  390. do {
  391. previousItem = parent.items[--index]
  392. } while (previousItem && !(isTrack(previousItem) || isGroup(previousItem)))
  393. if (!previousItem) return false
  394. this.queue(previousItem, 'FRONT')
  395. queueIndex = 0
  396. } else {
  397. return false
  398. }
  399. }
  400. this.play(queue.items[queueIndex])
  401. return true
  402. }
  403. playFirst() {
  404. const queue = this.queueGrouplike
  405. if (queue.items.length) {
  406. this.play(queue.items[0])
  407. return true
  408. }
  409. return false
  410. }
  411. playNextAtQueueEnd() {
  412. switch (this.queueEndMode) {
  413. case 'loop':
  414. this.playFirst()
  415. return true
  416. case 'shuffle':
  417. this.shuffleQueue(false)
  418. this.playFirst()
  419. return true
  420. case 'end':
  421. default:
  422. this.clearPlayingTrack()
  423. return false
  424. }
  425. }
  426. async playOrSeek(item, time) {
  427. if (!isTrack(item)) {
  428. // This only makes sense to call with individual tracks!
  429. return
  430. }
  431. if (item === this.playingTrack) {
  432. this.seekTo(time)
  433. } else {
  434. // Queue the track, but only if it's not already in the queue, so that we
  435. // respect an existing queue order.
  436. const queue = this.queueGrouplike
  437. const queueIndex = queue.items.indexOf(item)
  438. if (queueIndex === -1) {
  439. this.queue(item, this.playingTrack)
  440. }
  441. this.play(item, time)
  442. }
  443. }
  444. clearPlayingTrack() {
  445. if (this.playingTrack !== null) {
  446. const oldTrack = this.playingTrack
  447. this.playingTrack = null
  448. this.timeData = null
  449. this.emit('playing', null, oldTrack, 0, this)
  450. }
  451. }
  452. async download(item) {
  453. return download(item, this.getRecordFor(item))
  454. }
  455. seekAhead(seconds) {
  456. this.player.seekAhead(seconds)
  457. }
  458. seekBack(seconds) {
  459. this.player.seekBack(seconds)
  460. }
  461. seekTo(seconds) {
  462. this.player.seekTo(seconds)
  463. }
  464. seekToStart() {
  465. this.player.seekToStart()
  466. }
  467. togglePause() {
  468. this.player.togglePause()
  469. }
  470. setPause(value) {
  471. this.player.setPause(value)
  472. }
  473. toggleLoop() {
  474. this.player.toggleLoop()
  475. }
  476. setLoop(value) {
  477. this.player.setLoop(value)
  478. }
  479. volUp(amount = 10) {
  480. this.player.volUp(amount)
  481. }
  482. volDown(amount = 10) {
  483. this.player.volDown(amount)
  484. }
  485. setVolume(value) {
  486. this.player.setVolume(value)
  487. }
  488. setVolumeMultiplier(value) {
  489. this.player.setVolumeMultiplier(value);
  490. }
  491. fadeIn() {
  492. return this.player.fadeIn();
  493. }
  494. setPauseNextTrack(value) {
  495. this.pauseNextTrack = !!value
  496. }
  497. setLoopQueueAtEnd(value) {
  498. this.loopQueueAtEnd = !!value
  499. this.emit('set-loop-queue-at-end', !!value)
  500. }
  501. get remainingTracks() {
  502. const index = this.queueGrouplike.items.indexOf(this.playingTrack)
  503. const length = this.queueGrouplike.items.length
  504. if (index === -1) {
  505. return length
  506. } else {
  507. return length - index - 1
  508. }
  509. }
  510. get playSymbol() {
  511. if (this.player && this.playingTrack) {
  512. if (this.player.isPaused) {
  513. return '⏸'
  514. } else {
  515. return '▶'
  516. }
  517. } else {
  518. return '.'
  519. }
  520. }
  521. }
  522. export default class Backend extends EventEmitter {
  523. constructor({
  524. playerName = null,
  525. playerOptions = []
  526. } = {}) {
  527. super()
  528. this.playerName = playerName;
  529. this.playerOptions = playerOptions;
  530. if (playerOptions.length && !playerName) {
  531. throw new Error(`Must specify playerName to specify playerOptions`);
  532. }
  533. this.queuePlayers = []
  534. this.recordStore = new RecordStore()
  535. this.throttleMetadata = throttlePromise(10)
  536. this.metadataDictionary = {}
  537. this.rootDirectory = os.homedir() + '/.mtui'
  538. this.metadataPath = this.rootDirectory + '/track-metadata.json'
  539. }
  540. async setup() {
  541. const error = await this.addQueuePlayer()
  542. if (error.error) {
  543. return error
  544. }
  545. await this.loadMetadata()
  546. return true
  547. }
  548. async addQueuePlayer() {
  549. const queuePlayer = new QueuePlayer({
  550. getPlayer: () => getPlayer(this.playerName, this.playerOptions),
  551. getRecordFor: item => this.getRecordFor(item)
  552. })
  553. const error = await queuePlayer.setup()
  554. if (error.error) {
  555. return error
  556. }
  557. this.queuePlayers.push(queuePlayer)
  558. this.emit('added queue player', queuePlayer)
  559. for (const event of [
  560. 'playing',
  561. 'done playing',
  562. 'queue',
  563. 'distribute-queue',
  564. 'unqueue',
  565. 'clear-queue-past',
  566. 'clear-queue-up-to',
  567. 'shuffle-queue',
  568. 'clear-queue',
  569. 'queue updated',
  570. 'seek-ahead',
  571. 'seek-back',
  572. 'toggle-pause',
  573. 'set-pause',
  574. 'toggle-loop',
  575. 'set-loop',
  576. 'vol-up',
  577. 'vol-down',
  578. 'set-volume',
  579. 'set-pause-next-track',
  580. 'set-loop-queue-at-end'
  581. ]) {
  582. queuePlayer.on(event, (...data) => {
  583. this.emit(event, queuePlayer, ...data)
  584. })
  585. }
  586. return queuePlayer
  587. }
  588. removeQueuePlayer(queuePlayer) {
  589. if (this.queuePlayers.length > 1) {
  590. this.queuePlayers.splice(this.queuePlayers.indexOf(queuePlayer), 1)
  591. this.emit('removed queue player', queuePlayer)
  592. }
  593. }
  594. async readMetadata() {
  595. try {
  596. return JSON.parse(await readFile(this.metadataPath))
  597. } catch (error) {
  598. // Just stop. It's okay to fail to load metadata.
  599. return null
  600. }
  601. }
  602. async loadMetadata() {
  603. Object.assign(this.metadataDictionary, await this.readMetadata())
  604. }
  605. async saveMetadata() {
  606. const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
  607. await writeFile(this.metadataPath, JSON.stringify(newData))
  608. }
  609. getMetadataFor(item) {
  610. const key = this.metadataDictionary[item.downloaderArg]
  611. return this.metadataDictionary[key] || null
  612. }
  613. async processMetadata(item, reprocess = false, top = true) {
  614. let counter = 0
  615. if (isGroup(item)) {
  616. const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
  617. counter += results.reduce((acc, n) => acc + n, 0)
  618. } else if (isTrack(item)) process: {
  619. if (!reprocess && this.getMetadataFor(item)) {
  620. break process
  621. }
  622. await this.throttleMetadata(async () => {
  623. const filePath = await this.download(item)
  624. const metadataReader = getMetadataReaderFor(filePath)
  625. const data = await metadataReader(filePath)
  626. this.metadataDictionary[item.downloaderArg] = filePath
  627. this.metadataDictionary[filePath] = data
  628. })
  629. this.emit('processMetadata progress', this.throttleMetadata.queue.length)
  630. counter++
  631. }
  632. if (top) {
  633. await this.saveMetadata()
  634. }
  635. return counter
  636. }
  637. getRecordFor(item) {
  638. return this.recordStore.getRecord(item)
  639. }
  640. getDuration(item) {
  641. let noticedMissingMetadata = false
  642. const durationFn = (acc, track) => {
  643. const metadata = this.getMetadataFor(track)
  644. if (!metadata) noticedMissingMetadata = true
  645. return acc + (metadata && metadata.duration) || 0
  646. }
  647. let items
  648. if (isGroup(item)) {
  649. items = flattenGrouplike(item).items
  650. } else {
  651. items = [item]
  652. }
  653. const tracks = items.filter(isTrack)
  654. const seconds = tracks.reduce(durationFn, 0)
  655. let { duration: string } = getTimeStringsFromSec(0, seconds)
  656. const approxSymbol = noticedMissingMetadata ? '+' : ''
  657. string += approxSymbol
  658. return {seconds, string, noticedMissingMetadata, approxSymbol}
  659. }
  660. async stopPlayingAll() {
  661. for (const queuePlayer of this.queuePlayers) {
  662. await queuePlayer.stopPlaying()
  663. }
  664. }
  665. async download(item) {
  666. return download(item, this.getRecordFor(item))
  667. }
  668. }