playlist-utils.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. 'use strict'
  2. import path from 'node:path'
  3. import {shuffleArray} from './general-util.js'
  4. export const parentSymbol = Symbol('Parent group')
  5. export function updatePlaylistFormat(playlist) {
  6. const defaultPlaylist = {
  7. options: [],
  8. items: []
  9. }
  10. let playlistObj = {}
  11. // Playlists can be in two formats...
  12. if (Array.isArray(playlist)) {
  13. // ..the first, a simple array of tracks and groups;
  14. playlistObj = {items: playlist}
  15. } else {
  16. // ..or an object including metadata and configuration as well as the
  17. // array described in the first.
  18. playlistObj = playlist
  19. // The 'tracks' property was used for a while, but it doesn't really make
  20. // sense, since we also store groups in the 'tracks' property. So it was
  21. // renamed to 'items'.
  22. if ('tracks' in playlistObj) {
  23. playlistObj.items = playlistObj.tracks
  24. delete playlistObj.tracks
  25. }
  26. }
  27. const fullPlaylistObj = Object.assign(defaultPlaylist, playlistObj)
  28. return updateGroupFormat(fullPlaylistObj)
  29. }
  30. export function updateGroupFormat(group) {
  31. const defaultGroup = {
  32. name: '',
  33. items: []
  34. }
  35. let groupObj = {}
  36. if (Array.isArray(group[1])) {
  37. groupObj = {name: group[0], items: group[1]}
  38. } else {
  39. groupObj = group
  40. }
  41. groupObj = Object.assign(defaultGroup, groupObj)
  42. groupObj.items = groupObj.items.map(item => {
  43. // Check if it's a group; if not, it's probably a track.
  44. if (Array.isArray(item[1]) || item.items) {
  45. item = updateGroupFormat(item)
  46. } else {
  47. item = updateTrackFormat(item)
  48. // TODO: Should this also apply to groups? Is recursion good? Probably
  49. // not!
  50. //
  51. // TODO: How should saving/serializing handle this? For now it just saves
  52. // the result, after applying. (I.e., "apply": {"foo": "baz"} will save
  53. // child tracks with {"foo": "baz"}.)
  54. if (groupObj.apply) {
  55. Object.assign(item, groupObj.apply)
  56. }
  57. }
  58. item[parentSymbol] = groupObj
  59. return item
  60. })
  61. return groupObj
  62. }
  63. export function updateTrackFormat(track) {
  64. const defaultTrack = {
  65. name: '',
  66. downloaderArg: ''
  67. }
  68. let trackObj = {}
  69. if (Array.isArray(track)) {
  70. if (track.length === 2) {
  71. trackObj = {name: track[0], downloaderArg: track[1]}
  72. } else {
  73. throw new Error("Unexpected non-length 2 array-format track")
  74. }
  75. } else {
  76. trackObj = track
  77. }
  78. return Object.assign(defaultTrack, trackObj)
  79. }
  80. export function cloneGrouplike(grouplike) {
  81. const newGrouplike = {
  82. name: grouplike.name,
  83. items: grouplike.items.map(item => {
  84. if (isGroup(item)) {
  85. return cloneGrouplike(item)
  86. } else {
  87. return {
  88. name: item.name,
  89. downloaderArg: item.downloaderArg
  90. }
  91. }
  92. })
  93. }
  94. for (const item of newGrouplike.items) {
  95. item[parentSymbol] = newGrouplike
  96. }
  97. return newGrouplike
  98. }
  99. export function filterTracks(grouplike, handleTrack) {
  100. // Recursively filters every track in the passed grouplike. The track-handler
  101. // function passed should either return true (to keep a track) or false (to
  102. // remove the track). After tracks are filtered, groups which contain no
  103. // items are removed.
  104. if (typeof handleTrack !== 'function') {
  105. throw new Error("Missing track handler function")
  106. }
  107. return Object.assign({}, grouplike, {
  108. items: grouplike.items.filter(item => {
  109. if (isTrack(item)) {
  110. return handleTrack(item)
  111. } else {
  112. return true
  113. }
  114. }).map(item => {
  115. if (isGroup(item)) {
  116. return filterTracks(item, handleTrack)
  117. } else {
  118. return item
  119. }
  120. }).filter(item => {
  121. if (isGroup(item)) {
  122. return item.items.length > 0
  123. } else {
  124. return true
  125. }
  126. })
  127. })
  128. }
  129. export function flattenGrouplike(grouplike) {
  130. // Flattens a group-like, taking all of the non-group items (tracks) at all
  131. // levels in the group tree and returns them as a new group containing those
  132. // tracks.
  133. return {
  134. items: grouplike.items.map(item => {
  135. if (isGroup(item)) {
  136. return flattenGrouplike(item).items
  137. } else {
  138. return [item]
  139. }
  140. }).reduce((a, b) => a.concat(b), [])
  141. }
  142. }
  143. export function countTotalTracks(item) {
  144. // Returns the total number of tracks in a grouplike, including tracks in any
  145. // descendant groups. Basically the same as flattenGrouplike().items.length.
  146. if (isGroup(item)) {
  147. return item.items.map(countTotalTracks)
  148. .reduce((a, b) => a + b, 0)
  149. } else if (isTrack(item)) {
  150. return 1
  151. } else {
  152. return 0
  153. }
  154. }
  155. export function shuffleOrderOfGroups(grouplike) {
  156. // OK, this is opinionated on how it should work, but I think it Makes Sense.
  157. // Also sorry functional-programming friends, I'm sure this is a horror.
  158. // (FYI, this is the same as how http-music used to work with shuffle-groups,
  159. // *if* you also did --collapse-groups first. That was how shuffle-groups was
  160. // usually used (by me) anyway, so I figure bringing it over (with simpler
  161. // code) is reasonable. The only potentially confusing part is the behavior
  162. // when a group contains both tracks and groups (the extra tracks in each
  163. // group are collected together and considered "leftover", and are treated as
  164. // their own ordered flat groups).
  165. // Shuffle the list of groups (and potentially tracks). This won't shuffle
  166. // the *contents* of the groups; only the order in which the whole list of
  167. // groups (and tracks) plays.
  168. const { items } = collapseGrouplike(grouplike)
  169. return {items: shuffleArray(items)}
  170. }
  171. export function reverseOrderOfGroups(grouplike) {
  172. const { items } = collapseGrouplike(grouplike)
  173. return {items: items.reverse()}
  174. }
  175. export function collectGrouplikeChildren(grouplike, filter = null) {
  176. // Collects all descendants of a grouplike into a single flat array.
  177. // Can be passed a filter function, which will decide whether or not to add
  178. // an item to the return array. However, note that all descendants will be
  179. // checked against this function; a group will be descended through even if
  180. // the filter function checks false against it.
  181. // Returns an array, not a grouplike.
  182. const items = []
  183. for (const item of grouplike.items) {
  184. if (filter === null || filter(item) === true) {
  185. items.push(item)
  186. }
  187. if (isGroup(item)) {
  188. items.push(...collectGrouplikeChildren(item, filter))
  189. }
  190. }
  191. return items
  192. }
  193. export function partiallyFlattenGrouplike(grouplike, resultDepth) {
  194. // Flattens a grouplike so that it is never more than a given number of
  195. // groups deep, INCLUDING the "top" group -- e.g. a resultDepth of 2
  196. // means that there can be one level of groups remaining in the resulting
  197. // grouplike, plus the top group.
  198. if (resultDepth <= 1) {
  199. return flattenGrouplike(grouplike)
  200. }
  201. const items = grouplike.items.map(item => {
  202. if (isGroup(item)) {
  203. return {items: partiallyFlattenGrouplike(item, resultDepth - 1).items}
  204. } else {
  205. return item
  206. }
  207. })
  208. return {items}
  209. }
  210. export function collapseGrouplike(grouplike) {
  211. // Similar to partiallyFlattenGrouplike, but doesn't discard the individual
  212. // ordering of tracks; rather, it just collapses them all to one level.
  213. // Gather the groups. The result is an array of groups.
  214. // Collapsing [Kar/Baz/Foo, Kar/Baz/Lar] results in [Foo, Lar].
  215. // Aha! Just collect the top levels.
  216. // Only trouble is what to do with groups that contain both groups and
  217. // tracks. Maybe give them their own separate group (e.g. Baz).
  218. const subgroups = grouplike.items.filter(x => isGroup(x))
  219. const nonGroups = grouplike.items.filter(x => !isGroup(x))
  220. // Get each group's own collapsed groups, and store them all in one big
  221. // array.
  222. const ret = subgroups.map(group => {
  223. return collapseGrouplike(group).items
  224. }).reduce((a, b) => a.concat(b), [])
  225. if (nonGroups.length) {
  226. ret.unshift({name: grouplike.name, items: nonGroups})
  227. }
  228. return {items: ret}
  229. }
  230. export function filterGrouplikeByProperty(grouplike, property, value) {
  231. // Returns a copy of the original grouplike, only keeping tracks with the
  232. // given property-value pair. (If the track's value for the given property
  233. // is an array, this will check if that array includes the given value.)
  234. return Object.assign({}, grouplike, {
  235. items: grouplike.items.map(item => {
  236. if (isGroup(item)) {
  237. const newGroup = filterGrouplikeByProperty(item, property, value)
  238. if (newGroup.items.length) {
  239. return newGroup
  240. } else {
  241. return false
  242. }
  243. } else if (isTrack(item)) {
  244. const itemValue = item[property]
  245. if (Array.isArray(itemValue) && itemValue.includes(value)) {
  246. return item
  247. } else if (item[property] === value) {
  248. return item
  249. } else {
  250. return false
  251. }
  252. } else {
  253. return item
  254. }
  255. }).filter(item => item !== false)
  256. })
  257. }
  258. export function filterPlaylistByPathString(playlist, pathString) {
  259. // Calls filterGroupContentsByPath, taking an unparsed path string.
  260. return filterGrouplikeByPath(playlist, parsePathString(pathString))
  261. }
  262. export function filterGrouplikeByPath(grouplike, pathParts) {
  263. // Finds a group by following the given group path and returns it. If the
  264. // function encounters an item in the group path that is not found, it logs
  265. // a warning message and returns the group found up to that point. If the
  266. // pathParts array is empty, it returns the group given to the function.
  267. if (pathParts.length === 0) {
  268. return grouplike
  269. }
  270. let firstPart = pathParts[0]
  271. let possibleMatches
  272. if (firstPart.startsWith('?')) {
  273. possibleMatches = collectGrouplikeChildren(grouplike)
  274. firstPart = firstPart.slice(1)
  275. } else {
  276. possibleMatches = grouplike.items
  277. }
  278. const titleMatch = (group, caseInsensitive = false) => {
  279. let a = group.name
  280. let b = firstPart
  281. if (caseInsensitive) {
  282. a = a.toLowerCase()
  283. b = b.toLowerCase()
  284. }
  285. return a === b || a === b + '/'
  286. }
  287. let match = possibleMatches.find(g => titleMatch(g, false))
  288. if (!match) {
  289. match = possibleMatches.find(g => titleMatch(g, true))
  290. }
  291. if (match) {
  292. if (pathParts.length > 1) {
  293. const rest = pathParts.slice(1)
  294. return filterGrouplikeByPath(match, rest)
  295. } else {
  296. return match
  297. }
  298. } else {
  299. console.warn(`Not found: "${firstPart}"`)
  300. return null
  301. }
  302. }
  303. export function removeGroupByPathString(playlist, pathString) {
  304. // Calls removeGroupByPath, taking a path string, rather than a parsed path.
  305. return removeGroupByPath(playlist, parsePathString(pathString))
  306. }
  307. export function removeGroupByPath(playlist, pathParts) {
  308. // Removes the group at the given path from the given playlist.
  309. const groupToRemove = filterGrouplikeByPath(playlist, pathParts)
  310. if (groupToRemove === null) {
  311. return
  312. }
  313. if (playlist === groupToRemove) {
  314. console.error(
  315. 'You can\'t remove the playlist from itself! Instead, try --clear' +
  316. ' (shorthand -c).'
  317. )
  318. return
  319. }
  320. if (!(parentSymbol in groupToRemove)) {
  321. console.error(
  322. `Group ${pathParts.join('/')} doesn't have a parent, so we can't` +
  323. ' remove it from the playlist.'
  324. )
  325. return
  326. }
  327. const parent = groupToRemove[parentSymbol]
  328. const index = parent.items.indexOf(groupToRemove)
  329. if (index >= 0) {
  330. parent.items.splice(index, 1)
  331. } else {
  332. console.error(
  333. `Group ${pathParts.join('/')} doesn't exist, so we can't explicitly ` +
  334. 'ignore it.'
  335. )
  336. }
  337. }
  338. export function getPlaylistTreeString(playlist, showTracks = false) {
  339. function recursive(group) {
  340. const groups = group.items.filter(x => isGroup(x))
  341. const nonGroups = group.items.filter(x => !isGroup(x))
  342. const childrenString = groups.map(group => {
  343. const name = group.name
  344. const groupString = recursive(group)
  345. if (groupString) {
  346. const indented = groupString.split('\n').map(l => '| ' + l).join('\n')
  347. return '\n' + name + '\n' + indented
  348. } else {
  349. return name
  350. }
  351. }).join('\n')
  352. let tracksString = ''
  353. if (showTracks) {
  354. tracksString = nonGroups.map(g => g.name).join('\n')
  355. }
  356. if (tracksString && childrenString) {
  357. return tracksString + '\n' + childrenString
  358. } else if (childrenString) {
  359. return childrenString
  360. } else if (tracksString) {
  361. return tracksString
  362. } else {
  363. return ''
  364. }
  365. }
  366. return recursive(playlist)
  367. }
  368. export function getItemPath(item) {
  369. if (item[parentSymbol]) {
  370. return [...getItemPath(item[parentSymbol]), item]
  371. } else {
  372. return [item]
  373. }
  374. }
  375. export function getItemPathString(item) {
  376. // Gets the playlist path of an item by following its parent chain.
  377. //
  378. // Returns a string in format Foo/Bar/Baz, where Foo and Bar are group
  379. // names, and Baz is the name of the item.
  380. //
  381. // Unnamed parents are given the name '(Unnamed)'.
  382. // Always ignores the root (top) group.
  383. //
  384. // Requires that the given item be from a playlist processed by
  385. // updateGroupFormat.
  386. // Check if the parent is not the top level group.
  387. // The top-level group is included in the return path as '/'.
  388. if (item[parentSymbol]) {
  389. const displayName = item.name || '(Unnamed)'
  390. if (item[parentSymbol][parentSymbol]) {
  391. return getItemPathString(item[parentSymbol]) + '/' + displayName
  392. } else {
  393. return '/' + displayName
  394. }
  395. } else {
  396. return '/'
  397. }
  398. }
  399. export function parsePathString(pathString) {
  400. const pathParts = pathString.split('/').filter(item => item.length)
  401. return pathParts
  402. }
  403. export function getTrackIndexInParent(track) {
  404. if (parentSymbol in track === false) {
  405. throw new Error(
  406. 'getTrackIndexInParent called with a track that has no parent!'
  407. )
  408. }
  409. const parent = track[parentSymbol]
  410. let i = 0, foundTrack = false;
  411. for (; i < parent.items.length; i++) {
  412. // TODO: Port isSameTrack from http-music, if it makes sense - doing
  413. // so involves porting the [oldSymbol] property on all tracks and groups,
  414. // so may or may not be the right call. This function isn't used anywhere
  415. // in mtui so it'll take a little extra investigation.
  416. /* eslint-disable-next-line no-undef */
  417. if (isSameTrack(track, parent.items[i])) {
  418. foundTrack = true
  419. break
  420. }
  421. }
  422. if (foundTrack === false) {
  423. return [-1, parent.items.length]
  424. } else {
  425. return [i, parent.items.length]
  426. }
  427. }
  428. const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number')
  429. export function getNameWithoutTrackNumber(track) {
  430. // A "part" is a series of numeric digits, separated from other parts by
  431. // whitespace, dashes, and dots, always preceding either the first non-
  432. // numeric/separator character or (if there are no such characters) the
  433. // first word (i.e. last whitespace).
  434. const getNumberOfParts = ({ name }) => {
  435. name = name.replace(/^[-\s.]+$/, '')
  436. const match = name.match(/[^0-9-\s.]/)
  437. if (match) {
  438. if (match.index === 0) {
  439. return 0
  440. } else {
  441. name = name.slice(0, match.index)
  442. }
  443. } else if (name.includes(' ')) {
  444. name = name.slice(0, name.lastIndexOf(' '))
  445. } else {
  446. return 0
  447. }
  448. name = name.replace(/[-\s.]+$/, '')
  449. return name.split(/[-\s.]+/g).length
  450. }
  451. const removeParts = (name, numParts) => {
  452. const regex = new RegExp(String.raw`[-\s.]{0,}([0-9]+[-\s.]+){${numParts},${numParts}}`)
  453. return track.name.replace(regex, '')
  454. }
  455. // Despite this function returning a single string for one track, that value
  456. // depends on the names of all other tracks under the same parent. We still
  457. // store individual track -> name data on the track object, but the parent
  458. // gets an additional cache for the names of its children tracks as well as
  459. // the number of "parts" (the value directly based upon those names, and
  460. // useful in computing the name data for other children tracks).
  461. const parent = track[parentSymbol]
  462. if (parent) {
  463. const [trackNames, cachedNumParts] = parent[nameWithoutTrackNumberSymbol] || []
  464. const tracks = parent.items.filter(isTrack)
  465. if (trackNames && tracks.length === trackNames.length && tracks.every((t, i) => t.name === trackNames[i])) {
  466. const [, oldName, oldNumParts, cachedValue] = track[nameWithoutTrackNumberSymbol] || []
  467. if (cachedValue && track.name === oldName && cachedNumParts === oldNumParts) {
  468. return cachedValue
  469. } else {
  470. // Individual track cache outdated.
  471. const value = removeParts(track.name, cachedNumParts)
  472. track[nameWithoutTrackNumberSymbol] = [true, track.name, cachedNumParts, value]
  473. return value
  474. }
  475. } else {
  476. // Group (parent) cache outdated.
  477. const numParts = Math.min(...tracks.map(getNumberOfParts))
  478. parent[nameWithoutTrackNumberSymbol] = [tracks.map(t => t.name), numParts]
  479. // Parent changed so track cache changed is outdated too.
  480. const value = removeParts(track.name, numParts)
  481. track[nameWithoutTrackNumberSymbol] = [true, track.name, numParts, value]
  482. return value
  483. }
  484. } else {
  485. const [oldHadParent, oldName, , cachedValue] = track[nameWithoutTrackNumberSymbol] || []
  486. if (cachedValue && !oldHadParent && track.name === oldName) {
  487. return cachedValue
  488. } else {
  489. // Track cache outdated.
  490. const numParts = getNumberOfParts(track)
  491. const value = removeParts(track.name, numParts)
  492. track[nameWithoutTrackNumberSymbol] = [false, track.name, numParts, value]
  493. return value
  494. }
  495. }
  496. }
  497. export function isGroup(obj) {
  498. return !!(obj && obj.items)
  499. }
  500. export function isTrack(obj) {
  501. return !!(obj && obj.downloaderArg)
  502. }
  503. export function isPlayable(obj) {
  504. return isGroup(obj) || isTrack(obj)
  505. }
  506. export function isOpenable(obj) {
  507. return !!(obj && obj.url)
  508. }
  509. export function searchForItem(grouplike, value, preferredStartIndex = -1) {
  510. if (value.length) {
  511. // We prioritize searching past the index that the user opened the jump
  512. // element from (oldFocusedIndex). This is so that it's more practical
  513. // to do a "repeated" search, wherein the user searches for the same
  514. // value over and over, each time jumping to the next match, until they
  515. // have found the one they're looking for.
  516. const lower = value.toLowerCase()
  517. const getName = item => (item && item.name) ? item.name.toLowerCase().trim() : ''
  518. const testStartsWith = item => getName(item).startsWith(lower)
  519. const testIncludes = item => getName(item).includes(lower)
  520. const searchPastCurrentIndex = test => {
  521. const start = preferredStartIndex + 1
  522. const match = grouplike.items.slice(start).findIndex(test)
  523. if (match === -1) {
  524. return -1
  525. } else {
  526. return start + match
  527. }
  528. }
  529. const allIndexes = [
  530. searchPastCurrentIndex(testStartsWith),
  531. searchPastCurrentIndex(testIncludes),
  532. grouplike.items.findIndex(testStartsWith),
  533. grouplike.items.findIndex(testIncludes)
  534. ]
  535. const matchedIndex = allIndexes.find(value => value >= 0)
  536. if (typeof matchedIndex !== 'undefined') {
  537. return grouplike.items[matchedIndex]
  538. }
  539. }
  540. return null
  541. }
  542. export function getCorrespondingFileForItem(item, extension) {
  543. if (!(item && item.url)) {
  544. return null
  545. }
  546. const checkExtension = item => item.url && item.url.endsWith(extension)
  547. if (isPlayable(item)) {
  548. const parent = item[parentSymbol]
  549. if (!parent) {
  550. return null
  551. }
  552. const basename = path.basename(item.url, path.extname(item.url))
  553. return parent.items.find(item => checkExtension(item) && path.basename(item.url, extension) === basename)
  554. }
  555. if (checkExtension(item)) {
  556. return item
  557. }
  558. return null
  559. }
  560. export function getCorrespondingPlayableForFile(item) {
  561. if (!(item && item.url)) {
  562. return null
  563. }
  564. if (isPlayable(item)) {
  565. return item
  566. }
  567. const parent = item[parentSymbol]
  568. if (!parent) {
  569. return null
  570. }
  571. const basename = path.basename(item.url, path.extname(item.url))
  572. return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename)
  573. }