playlist-utils.js 20 KB

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