123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695 |
- 'use strict'
- import path from 'node:path'
- import {shuffleArray} from './general-util.js'
- export const parentSymbol = Symbol('Parent group')
- export function updatePlaylistFormat(playlist) {
- const defaultPlaylist = {
- options: [],
- items: []
- }
- let playlistObj = {}
- // Playlists can be in two formats...
- if (Array.isArray(playlist)) {
- // ..the first, a simple array of tracks and groups;
- playlistObj = {items: playlist}
- } else {
- // ..or an object including metadata and configuration as well as the
- // array described in the first.
- playlistObj = playlist
- // The 'tracks' property was used for a while, but it doesn't really make
- // sense, since we also store groups in the 'tracks' property. So it was
- // renamed to 'items'.
- if ('tracks' in playlistObj) {
- playlistObj.items = playlistObj.tracks
- delete playlistObj.tracks
- }
- }
- const fullPlaylistObj = Object.assign(defaultPlaylist, playlistObj)
- return updateGroupFormat(fullPlaylistObj)
- }
- export function updateGroupFormat(group) {
- const defaultGroup = {
- name: '',
- items: []
- }
- let groupObj = {}
- if (Array.isArray(group[1])) {
- groupObj = {name: group[0], items: group[1]}
- } else {
- groupObj = group
- }
- groupObj = Object.assign(defaultGroup, groupObj)
- groupObj.items = groupObj.items.map(item => {
- // Check if it's a group; if not, it's probably a track.
- if (Array.isArray(item[1]) || item.items) {
- item = updateGroupFormat(item)
- } else {
- item = updateTrackFormat(item)
- // TODO: Should this also apply to groups? Is recursion good? Probably
- // not!
- //
- // TODO: How should saving/serializing handle this? For now it just saves
- // the result, after applying. (I.e., "apply": {"foo": "baz"} will save
- // child tracks with {"foo": "baz"}.)
- if (groupObj.apply) {
- Object.assign(item, groupObj.apply)
- }
- }
- item[parentSymbol] = groupObj
- return item
- })
- return groupObj
- }
- export function updateTrackFormat(track) {
- const defaultTrack = {
- name: '',
- downloaderArg: ''
- }
- let trackObj = {}
- if (Array.isArray(track)) {
- if (track.length === 2) {
- trackObj = {name: track[0], downloaderArg: track[1]}
- } else {
- throw new Error("Unexpected non-length 2 array-format track")
- }
- } else {
- trackObj = track
- }
- return Object.assign(defaultTrack, trackObj)
- }
- export function cloneGrouplike(grouplike) {
- const newGrouplike = {
- name: grouplike.name,
- items: grouplike.items.map(item => {
- if (isGroup(item)) {
- return cloneGrouplike(item)
- } else {
- return {
- name: item.name,
- downloaderArg: item.downloaderArg
- }
- }
- })
- }
- for (const item of newGrouplike.items) {
- item[parentSymbol] = newGrouplike
- }
- return newGrouplike
- }
- export function filterTracks(grouplike, handleTrack) {
- // Recursively filters every track in the passed grouplike. The track-handler
- // function passed should either return true (to keep a track) or false (to
- // remove the track). After tracks are filtered, groups which contain no
- // items are removed.
- if (typeof handleTrack !== 'function') {
- throw new Error("Missing track handler function")
- }
- return Object.assign({}, grouplike, {
- items: grouplike.items.filter(item => {
- if (isTrack(item)) {
- return handleTrack(item)
- } else {
- return true
- }
- }).map(item => {
- if (isGroup(item)) {
- return filterTracks(item, handleTrack)
- } else {
- return item
- }
- }).filter(item => {
- if (isGroup(item)) {
- return item.items.length > 0
- } else {
- return true
- }
- })
- })
- }
- export function flattenGrouplike(grouplike) {
- // Flattens a group-like, taking all of the non-group items (tracks) at all
- // levels in the group tree and returns them as a new group containing those
- // tracks.
- return {
- items: grouplike.items.map(item => {
- if (isGroup(item)) {
- return flattenGrouplike(item).items
- } else {
- return [item]
- }
- }).reduce((a, b) => a.concat(b), [])
- }
- }
- export function countTotalTracks(item) {
- // Returns the total number of tracks in a grouplike, including tracks in any
- // descendant groups. Basically the same as flattenGrouplike().items.length.
- if (isGroup(item)) {
- return item.items.map(countTotalTracks)
- .reduce((a, b) => a + b, 0)
- } else if (isTrack(item)) {
- return 1
- } else {
- return 0
- }
- }
- export function shuffleOrderOfGroups(grouplike) {
- // OK, this is opinionated on how it should work, but I think it Makes Sense.
- // Also sorry functional-programming friends, I'm sure this is a horror.
- // (FYI, this is the same as how http-music used to work with shuffle-groups,
- // *if* you also did --collapse-groups first. That was how shuffle-groups was
- // usually used (by me) anyway, so I figure bringing it over (with simpler
- // code) is reasonable. The only potentially confusing part is the behavior
- // when a group contains both tracks and groups (the extra tracks in each
- // group are collected together and considered "leftover", and are treated as
- // their own ordered flat groups).
- // Shuffle the list of groups (and potentially tracks). This won't shuffle
- // the *contents* of the groups; only the order in which the whole list of
- // groups (and tracks) plays.
- const { items } = collapseGrouplike(grouplike)
- return {items: shuffleArray(items)}
- }
- export function reverseOrderOfGroups(grouplike) {
- const { items } = collapseGrouplike(grouplike)
- return {items: items.reverse()}
- }
- export function collectGrouplikeChildren(grouplike, filter = null) {
- // Collects all descendants of a grouplike into a single flat array.
- // Can be passed a filter function, which will decide whether or not to add
- // an item to the return array. However, note that all descendants will be
- // checked against this function; a group will be descended through even if
- // the filter function checks false against it.
- // Returns an array, not a grouplike.
- const items = []
- for (const item of grouplike.items) {
- if (filter === null || filter(item) === true) {
- items.push(item)
- }
- if (isGroup(item)) {
- items.push(...collectGrouplikeChildren(item, filter))
- }
- }
- return items
- }
- export function partiallyFlattenGrouplike(grouplike, resultDepth) {
- // Flattens a grouplike so that it is never more than a given number of
- // groups deep, INCLUDING the "top" group -- e.g. a resultDepth of 2
- // means that there can be one level of groups remaining in the resulting
- // grouplike, plus the top group.
- if (resultDepth <= 1) {
- return flattenGrouplike(grouplike)
- }
- const items = grouplike.items.map(item => {
- if (isGroup(item)) {
- return {items: partiallyFlattenGrouplike(item, resultDepth - 1).items}
- } else {
- return item
- }
- })
- return {items}
- }
- export function collapseGrouplike(grouplike) {
- // Similar to partiallyFlattenGrouplike, but doesn't discard the individual
- // ordering of tracks; rather, it just collapses them all to one level.
- // Gather the groups. The result is an array of groups.
- // Collapsing [Kar/Baz/Foo, Kar/Baz/Lar] results in [Foo, Lar].
- // Aha! Just collect the top levels.
- // Only trouble is what to do with groups that contain both groups and
- // tracks. Maybe give them their own separate group (e.g. Baz).
- const subgroups = grouplike.items.filter(x => isGroup(x))
- const nonGroups = grouplike.items.filter(x => !isGroup(x))
- // Get each group's own collapsed groups, and store them all in one big
- // array.
- const ret = subgroups.map(group => {
- return collapseGrouplike(group).items
- }).reduce((a, b) => a.concat(b), [])
- if (nonGroups.length) {
- ret.unshift({name: grouplike.name, items: nonGroups})
- }
- return {items: ret}
- }
- export function filterGrouplikeByProperty(grouplike, property, value) {
- // Returns a copy of the original grouplike, only keeping tracks with the
- // given property-value pair. (If the track's value for the given property
- // is an array, this will check if that array includes the given value.)
- return Object.assign({}, grouplike, {
- items: grouplike.items.map(item => {
- if (isGroup(item)) {
- const newGroup = filterGrouplikeByProperty(item, property, value)
- if (newGroup.items.length) {
- return newGroup
- } else {
- return false
- }
- } else if (isTrack(item)) {
- const itemValue = item[property]
- if (Array.isArray(itemValue) && itemValue.includes(value)) {
- return item
- } else if (item[property] === value) {
- return item
- } else {
- return false
- }
- } else {
- return item
- }
- }).filter(item => item !== false)
- })
- }
- export function filterPlaylistByPathString(playlist, pathString) {
- // Calls filterGroupContentsByPath, taking an unparsed path string.
- return filterGrouplikeByPath(playlist, parsePathString(pathString))
- }
- export function filterGrouplikeByPath(grouplike, pathParts) {
- // Finds a group by following the given group path and returns it. If the
- // function encounters an item in the group path that is not found, it logs
- // a warning message and returns the group found up to that point. If the
- // pathParts array is empty, it returns the group given to the function.
- if (pathParts.length === 0) {
- return grouplike
- }
- let firstPart = pathParts[0]
- let possibleMatches
- if (firstPart.startsWith('?')) {
- possibleMatches = collectGrouplikeChildren(grouplike)
- firstPart = firstPart.slice(1)
- } else {
- possibleMatches = grouplike.items
- }
- const titleMatch = (group, caseInsensitive = false) => {
- let a = group.name
- let b = firstPart
- if (caseInsensitive) {
- a = a.toLowerCase()
- b = b.toLowerCase()
- }
- return a === b || a === b + '/'
- }
- let match = possibleMatches.find(g => titleMatch(g, false))
- if (!match) {
- match = possibleMatches.find(g => titleMatch(g, true))
- }
- if (match) {
- if (pathParts.length > 1) {
- const rest = pathParts.slice(1)
- return filterGrouplikeByPath(match, rest)
- } else {
- return match
- }
- } else {
- console.warn(`Not found: "${firstPart}"`)
- return null
- }
- }
- export function removeGroupByPathString(playlist, pathString) {
- // Calls removeGroupByPath, taking a path string, rather than a parsed path.
- return removeGroupByPath(playlist, parsePathString(pathString))
- }
- export function removeGroupByPath(playlist, pathParts) {
- // Removes the group at the given path from the given playlist.
- const groupToRemove = filterGrouplikeByPath(playlist, pathParts)
- if (groupToRemove === null) {
- return
- }
- if (playlist === groupToRemove) {
- console.error(
- 'You can\'t remove the playlist from itself! Instead, try --clear' +
- ' (shorthand -c).'
- )
- return
- }
- if (!(parentSymbol in groupToRemove)) {
- console.error(
- `Group ${pathParts.join('/')} doesn't have a parent, so we can't` +
- ' remove it from the playlist.'
- )
- return
- }
- const parent = groupToRemove[parentSymbol]
- const index = parent.items.indexOf(groupToRemove)
- if (index >= 0) {
- parent.items.splice(index, 1)
- } else {
- console.error(
- `Group ${pathParts.join('/')} doesn't exist, so we can't explicitly ` +
- 'ignore it.'
- )
- }
- }
- export function getPlaylistTreeString(playlist, showTracks = false) {
- function recursive(group) {
- const groups = group.items.filter(x => isGroup(x))
- const nonGroups = group.items.filter(x => !isGroup(x))
- const childrenString = groups.map(group => {
- const name = group.name
- const groupString = recursive(group)
- if (groupString) {
- const indented = groupString.split('\n').map(l => '| ' + l).join('\n')
- return '\n' + name + '\n' + indented
- } else {
- return name
- }
- }).join('\n')
- let tracksString = ''
- if (showTracks) {
- tracksString = nonGroups.map(g => g.name).join('\n')
- }
- if (tracksString && childrenString) {
- return tracksString + '\n' + childrenString
- } else if (childrenString) {
- return childrenString
- } else if (tracksString) {
- return tracksString
- } else {
- return ''
- }
- }
- return recursive(playlist)
- }
- export function getItemPath(item) {
- if (item[parentSymbol]) {
- return [...getItemPath(item[parentSymbol]), item]
- } else {
- return [item]
- }
- }
- export function getItemPathString(item) {
- // Gets the playlist path of an item by following its parent chain.
- //
- // Returns a string in format Foo/Bar/Baz, where Foo and Bar are group
- // names, and Baz is the name of the item.
- //
- // Unnamed parents are given the name '(Unnamed)'.
- // Always ignores the root (top) group.
- //
- // Requires that the given item be from a playlist processed by
- // updateGroupFormat.
- // Check if the parent is not the top level group.
- // The top-level group is included in the return path as '/'.
- if (item[parentSymbol]) {
- const displayName = item.name || '(Unnamed)'
- if (item[parentSymbol][parentSymbol]) {
- return getItemPathString(item[parentSymbol]) + '/' + displayName
- } else {
- return '/' + displayName
- }
- } else {
- return '/'
- }
- }
- export function parsePathString(pathString) {
- const pathParts = pathString.split('/').filter(item => item.length)
- return pathParts
- }
- export function getTrackIndexInParent(track) {
- if (parentSymbol in track === false) {
- throw new Error(
- 'getTrackIndexInParent called with a track that has no parent!'
- )
- }
- const parent = track[parentSymbol]
- let i = 0, foundTrack = false;
- for (; i < parent.items.length; i++) {
- // TODO: Port isSameTrack from http-music, if it makes sense - doing
- // so involves porting the [oldSymbol] property on all tracks and groups,
- // so may or may not be the right call. This function isn't used anywhere
- // in mtui so it'll take a little extra investigation.
- /* eslint-disable-next-line no-undef */
- if (isSameTrack(track, parent.items[i])) {
- foundTrack = true
- break
- }
- }
- if (foundTrack === false) {
- return [-1, parent.items.length]
- } else {
- return [i, parent.items.length]
- }
- }
- const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number')
- export function getNameWithoutTrackNumber(track) {
- // A "part" is a series of numeric digits, separated from other parts by
- // whitespace, dashes, and dots, always preceding either the first non-
- // numeric/separator character or (if there are no such characters) the
- // first word (i.e. last whitespace).
- const getNumberOfParts = ({ name }) => {
- name = name.replace(/^[-\s.]+$/, '')
- const match = name.match(/[^0-9-\s.]/)
- if (match) {
- if (match.index === 0) {
- return 0
- } else {
- name = name.slice(0, match.index)
- }
- } else if (name.includes(' ')) {
- name = name.slice(0, name.lastIndexOf(' '))
- } else {
- return 0
- }
- name = name.replace(/[-\s.]+$/, '')
- return name.split(/[-\s.]+/g).length
- }
- const removeParts = (name, numParts) => {
- const regex = new RegExp(String.raw`[-\s.]{0,}([0-9]+[-\s.]+){${numParts},${numParts}}`)
- return track.name.replace(regex, '')
- }
- // Despite this function returning a single string for one track, that value
- // depends on the names of all other tracks under the same parent. We still
- // store individual track -> name data on the track object, but the parent
- // gets an additional cache for the names of its children tracks as well as
- // the number of "parts" (the value directly based upon those names, and
- // useful in computing the name data for other children tracks).
- const parent = track[parentSymbol]
- if (parent) {
- const [trackNames, cachedNumParts] = parent[nameWithoutTrackNumberSymbol] || []
- const tracks = parent.items.filter(isTrack)
- if (trackNames && tracks.length === trackNames.length && tracks.every((t, i) => t.name === trackNames[i])) {
- const [, oldName, oldNumParts, cachedValue] = track[nameWithoutTrackNumberSymbol] || []
- if (cachedValue && track.name === oldName && cachedNumParts === oldNumParts) {
- return cachedValue
- } else {
- // Individual track cache outdated.
- const value = removeParts(track.name, cachedNumParts)
- track[nameWithoutTrackNumberSymbol] = [true, track.name, cachedNumParts, value]
- return value
- }
- } else {
- // Group (parent) cache outdated.
- const numParts = Math.min(...tracks.map(getNumberOfParts))
- parent[nameWithoutTrackNumberSymbol] = [tracks.map(t => t.name), numParts]
- // Parent changed so track cache changed is outdated too.
- const value = removeParts(track.name, numParts)
- track[nameWithoutTrackNumberSymbol] = [true, track.name, numParts, value]
- return value
- }
- } else {
- const [oldHadParent, oldName, , cachedValue] = track[nameWithoutTrackNumberSymbol] || []
- if (cachedValue && !oldHadParent && track.name === oldName) {
- return cachedValue
- } else {
- // Track cache outdated.
- const numParts = getNumberOfParts(track)
- const value = removeParts(track.name, numParts)
- track[nameWithoutTrackNumberSymbol] = [false, track.name, numParts, value]
- return value
- }
- }
- }
- export function isGroup(obj) {
- return !!(obj && obj.items)
- }
- export function isTrack(obj) {
- return !!(obj && obj.downloaderArg)
- }
- export function isPlayable(obj) {
- return isGroup(obj) || isTrack(obj)
- }
- export function isOpenable(obj) {
- return !!(obj && obj.url)
- }
- export function searchForItem(grouplike, value, preferredStartIndex = -1) {
- if (value.length) {
- // We prioritize searching past the index that the user opened the jump
- // element from (oldFocusedIndex). This is so that it's more practical
- // to do a "repeated" search, wherein the user searches for the same
- // value over and over, each time jumping to the next match, until they
- // have found the one they're looking for.
- const lower = value.toLowerCase()
- const getName = item => (item && item.name) ? item.name.toLowerCase().trim() : ''
- const testStartsWith = item => getName(item).startsWith(lower)
- const testIncludes = item => getName(item).includes(lower)
- const searchPastCurrentIndex = test => {
- const start = preferredStartIndex + 1
- const match = grouplike.items.slice(start).findIndex(test)
- if (match === -1) {
- return -1
- } else {
- return start + match
- }
- }
- const allIndexes = [
- searchPastCurrentIndex(testStartsWith),
- searchPastCurrentIndex(testIncludes),
- grouplike.items.findIndex(testStartsWith),
- grouplike.items.findIndex(testIncludes)
- ]
- const matchedIndex = allIndexes.find(value => value >= 0)
- if (typeof matchedIndex !== 'undefined') {
- return grouplike.items[matchedIndex]
- }
- }
- return null
- }
- export function getCorrespondingFileForItem(item, extension) {
- if (!(item && item.url)) {
- return null
- }
- const checkExtension = item => item.url && item.url.endsWith(extension)
- if (isPlayable(item)) {
- const parent = item[parentSymbol]
- if (!parent) {
- return null
- }
- const basename = path.basename(item.url, path.extname(item.url))
- return parent.items.find(item => checkExtension(item) && path.basename(item.url, extension) === basename)
- }
- if (checkExtension(item)) {
- return item
- }
- return null
- }
- export function getCorrespondingPlayableForFile(item) {
- if (!(item && item.url)) {
- return null
- }
- if (isPlayable(item)) {
- return item
- }
- const parent = item[parentSymbol]
- if (!parent) {
- return null
- }
- const basename = path.basename(item.url, path.extname(item.url))
- return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename)
- }
|