« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/playlist-utils.js
diff options
context:
space:
mode:
Diffstat (limited to 'playlist-utils.js')
-rw-r--r--playlist-utils.js192
1 files changed, 64 insertions, 128 deletions
diff --git a/playlist-utils.js b/playlist-utils.js
index 979c6d6..dd1d8c8 100644
--- a/playlist-utils.js
+++ b/playlist-utils.js
@@ -1,16 +1,12 @@
 'use strict'
 
-const path = require('path')
-const fs = require('fs')
+import path from 'node:path'
 
-const { promisify } = require('util')
-const unlink = promisify(fs.unlink)
+import {shuffleArray} from './general-util.js'
 
-const { shuffleArray } = require('./general-util')
+export const parentSymbol = Symbol('Parent group')
 
-const parentSymbol = Symbol('Parent group')
-
-function updatePlaylistFormat(playlist) {
+export function updatePlaylistFormat(playlist) {
   const defaultPlaylist = {
     options: [],
     items: []
@@ -43,7 +39,7 @@ function updatePlaylistFormat(playlist) {
   return updateGroupFormat(fullPlaylistObj)
 }
 
-function updateGroupFormat(group) {
+export function updateGroupFormat(group) {
   const defaultGroup = {
     name: '',
     items: []
@@ -61,7 +57,7 @@ function updateGroupFormat(group) {
 
   groupObj.items = groupObj.items.map(item => {
     // Check if it's a group; if not, it's probably a track.
-    if (typeof item[1] === 'array' || item.items) {
+    if (Array.isArray(item[1]) || item.items) {
       item = updateGroupFormat(item)
     } else {
       item = updateTrackFormat(item)
@@ -85,7 +81,7 @@ function updateGroupFormat(group) {
   return groupObj
 }
 
-function updateTrackFormat(track) {
+export function updateTrackFormat(track) {
   const defaultTrack = {
     name: '',
     downloaderArg: ''
@@ -106,7 +102,7 @@ function updateTrackFormat(track) {
   return Object.assign(defaultTrack, trackObj)
 }
 
-function cloneGrouplike(grouplike) {
+export function cloneGrouplike(grouplike) {
   const newGrouplike = {
     name: grouplike.name,
     items: grouplike.items.map(item => {
@@ -128,7 +124,7 @@ function cloneGrouplike(grouplike) {
   return newGrouplike
 }
 
-function filterTracks(grouplike, handleTrack) {
+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
@@ -161,7 +157,7 @@ function filterTracks(grouplike, handleTrack) {
   })
 }
 
-function flattenGrouplike(grouplike) {
+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.
@@ -169,7 +165,7 @@ function flattenGrouplike(grouplike) {
   return {items: getFlatTrackList(grouplike)}
 }
 
-function getFlatTrackList(grouplike) {
+export function getFlatTrackList(grouplike) {
   // Underlying function for flattenGrouplike. Can be used if you just want to
   // get an array and not a grouplike, too.
 
@@ -182,7 +178,7 @@ function getFlatTrackList(grouplike) {
   }).reduce((a, b) => a.concat(b), [])
 }
 
-function getFlatGroupList(grouplike) {
+export function getFlatGroupList(grouplike) {
   // Analogue of getFlatTrackList for groups instead of tracks. Returns a flat
   // array of all the groups in each level of the provided grouplike.
 
@@ -192,7 +188,7 @@ function getFlatGroupList(grouplike) {
     .reduce((a, b) => a.concat(b), [])
 }
 
-function countTotalTracks(item) {
+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.
 
@@ -206,7 +202,7 @@ function countTotalTracks(item) {
   }
 }
 
-function shuffleOrderOfGroups(grouplike) {
+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,
@@ -224,12 +220,12 @@ function shuffleOrderOfGroups(grouplike) {
   return {items: shuffleArray(items)}
 }
 
-function reverseOrderOfGroups(grouplike) {
+export function reverseOrderOfGroups(grouplike) {
   const { items } = collapseGrouplike(grouplike)
   return {items: items.reverse()}
 }
 
-function collectGrouplikeChildren(grouplike, filter = null) {
+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
@@ -252,7 +248,7 @@ function collectGrouplikeChildren(grouplike, filter = null) {
   return items
 }
 
-function partiallyFlattenGrouplike(grouplike, resultDepth) {
+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
@@ -273,7 +269,7 @@ function partiallyFlattenGrouplike(grouplike, resultDepth) {
   return {items}
 }
 
-function collapseGrouplike(grouplike) {
+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.
 
@@ -299,7 +295,7 @@ function collapseGrouplike(grouplike) {
   return {items: ret}
 }
 
-function filterGrouplikeByProperty(grouplike, property, value) {
+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.)
@@ -329,13 +325,13 @@ function filterGrouplikeByProperty(grouplike, property, value) {
   })
 }
 
-function filterPlaylistByPathString(playlist, pathString) {
+export function filterPlaylistByPathString(playlist, pathString) {
   // Calls filterGroupContentsByPath, taking an unparsed path string.
 
   return filterGrouplikeByPath(playlist, parsePathString(pathString))
 }
 
-function filterGrouplikeByPath(grouplike, pathParts) {
+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
@@ -386,13 +382,13 @@ function filterGrouplikeByPath(grouplike, pathParts) {
   }
 }
 
-function removeGroupByPathString(playlist, pathString) {
+export function removeGroupByPathString(playlist, pathString) {
   // Calls removeGroupByPath, taking a path string, rather than a parsed path.
 
   return removeGroupByPath(playlist, parsePathString(pathString))
 }
 
-function removeGroupByPath(playlist, pathParts) {
+export function removeGroupByPath(playlist, pathParts) {
   // Removes the group at the given path from the given playlist.
 
   const groupToRemove = filterGrouplikeByPath(playlist, pathParts)
@@ -433,7 +429,7 @@ function removeGroupByPath(playlist, pathParts) {
   }
 }
 
-function getPlaylistTreeString(playlist, showTracks = false) {
+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))
@@ -469,7 +465,7 @@ function getPlaylistTreeString(playlist, showTracks = false) {
   return recursive(playlist)
 }
 
-function getItemPath(item) {
+export function getItemPath(item) {
   if (item[parentSymbol]) {
     return [...getItemPath(item[parentSymbol]), item]
   } else {
@@ -477,7 +473,7 @@ function getItemPath(item) {
   }
 }
 
-function getItemPathString(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
@@ -504,12 +500,12 @@ function getItemPathString(item) {
   }
 }
 
-function parsePathString(pathString) {
+export function parsePathString(pathString) {
   const pathParts = pathString.split('/').filter(item => item.length)
   return pathParts
 }
 
-function getTrackIndexInParent(track) {
+export function getTrackIndexInParent(track) {
   if (parentSymbol in track === false) {
     throw new Error(
       'getTrackIndexInParent called with a track that has no parent!'
@@ -520,6 +516,11 @@ function getTrackIndexInParent(track) {
 
   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
@@ -534,14 +535,14 @@ function getTrackIndexInParent(track) {
 }
 
 const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number')
-function getNameWithoutTrackNumber(track) {
+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.]/)
+    name = name.replace(/^[-\s.]+$/, '')
+    const match = name.match(/[^0-9-\s.]/)
     if (match) {
       if (match.index === 0) {
         return 0
@@ -553,12 +554,12 @@ function getNameWithoutTrackNumber(track) {
     } else {
       return 0
     }
-    name = name.replace(/[\-\s.]+$/, '')
-    return name.split(/[\-\s.]+/g).length
+    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}}`)
+    const regex = new RegExp(String.raw`[-\s.]{0,}([0-9]+[-\s.]+){${numParts},${numParts}}`)
     return track.name.replace(regex, '')
   }
 
@@ -606,24 +607,24 @@ function getNameWithoutTrackNumber(track) {
   }
 }
 
-function isGroup(obj) {
+export function isGroup(obj) {
   return !!(obj && obj.items)
 }
 
-function isTrack(obj) {
+export function isTrack(obj) {
   return !!(obj && obj.downloaderArg)
 }
 
-function isPlayable(obj) {
+export function isPlayable(obj) {
   return isGroup(obj) || isTrack(obj)
 }
 
-function isOpenable(obj) {
+export function isOpenable(obj) {
   return !!(obj && obj.url)
 }
 
 
-function searchForItem(grouplike, value, preferredStartIndex = -1) {
+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
@@ -663,12 +664,12 @@ function searchForItem(grouplike, value, preferredStartIndex = -1) {
   return null
 }
 
-function getCorrespondingFileForItem(item, extension) {
+export function getCorrespondingFileForItem(item, extension) {
   if (!(item && item.url)) {
     return null
   }
 
-  const checkExtension = item => item.url && path.extname(item.url) === extension
+  const checkExtension = item => item.url && item.url.endsWith(extension)
 
   if (isPlayable(item)) {
     const parent = item[parentSymbol]
@@ -688,7 +689,7 @@ function getCorrespondingFileForItem(item, extension) {
   return null
 }
 
-function getCorrespondingPlayableForFile(item) {
+export function getCorrespondingPlayableForFile(item) {
   if (!(item && item.url)) {
     return null
   }
@@ -707,7 +708,7 @@ function getCorrespondingPlayableForFile(item) {
   return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename)
 }
 
-function getPathScore(path1, path2) {
+export function getPathScore(path1, path2) {
   // This function is basically only used in findTrackObject, but it's kinda
   // huge and I need to test that it works outside of that context, so I'm
   // sticking it on the global scope. Feel free to steal for whatever your
@@ -836,7 +837,7 @@ function getPathScore(path1, path2) {
   return scores.reduce((a, b) => a < b ? a : b)
 }
 
-function getNameScore(name1, name2) {
+export function getNameScore(name1, name2) {
   // Pretty simple algorithm here: we're looking for the longest continuous
   // series of words which is shared between both names. The score is the
   // length of that series, so a higher score is better (and a zero score
@@ -902,7 +903,7 @@ function getNameScore(name1, name2) {
   )
 }
 
-function findItemObject(referenceData, possibleChoices) {
+export function findItemObject(referenceData, possibleChoices) {
   // Finds the item object in the provided choices which most closely resembles
   // the provided reference data. This is used for maintaining the identity of
   // item objects when reloading a playlist (see serialized-backend.js). It's
@@ -977,88 +978,23 @@ function findItemObject(referenceData, possibleChoices) {
   return mostResembles.item
 }
 
-module.exports = {
-  parentSymbol,
-  updatePlaylistFormat, updateGroupFormat, updateTrackFormat,
-  cloneGrouplike,
-  filterTracks,
-  flattenGrouplike,
-  getFlatTrackList,
-  getFlatGroupList,
-  countTotalTracks,
-  shuffleOrderOfGroups,
-  reverseOrderOfGroups,
-  partiallyFlattenGrouplike, collapseGrouplike,
-  filterGrouplikeByProperty,
-  filterPlaylistByPathString, filterGrouplikeByPath,
-  removeGroupByPathString, removeGroupByPath,
-  getPlaylistTreeString,
-  getItemPath, getItemPathString,
-  parsePathString,
-  getTrackIndexInParent,
-  getNameWithoutTrackNumber,
-  searchForItem,
-  getCorrespondingFileForItem,
-  getCorrespondingPlayableForFile,
-  getPathScore,
-  findItemObject,
-  isGroup, isTrack,
-  isOpenable, isPlayable
-}
-
-if (require.main === module) {
-  console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C']))
-  console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C', 'D']))
-  console.log(getPathScore(['A', 'B', 'C', 'E'], ['A', 'B', 'C']))
-  console.log(getPathScore(['W', 'X'], ['Y', 'Z']))
-  console.log(getNameScore('C418 - Vlem', 'Vlem'))
-  console.log(getNameScore('glimmer', 'glimmer'))
-  console.log(getNameScore('C418 - Vlem', 'covet - glimmer'))
-  console.log(findItemObject(
-    // {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']},
-    {name: 'B'},
-    // getFlatTrackList(
-    getFlatGroupList(
-      updateGroupFormat({items: [
-        {id: 1, name: 'T'},
-        {id: 2, name: 'T'},
-        {id: 3, name: 'T'},
-        // {id: 4, name: 'T', downloaderArg: 'foo'},
-        {id: 5, name: 'T'},
-        {id: 6, name: 'Y', downloaderArg: 'foo'},
-        {name: 'A', items: [
-          {name: 'B', items: [
-            {name: 'C', items: [
-              {name: 'T'}
-            ]},
-            {name: 'T'}
-          ]}
-        ]}
-      ]})
-    )
-  ))
-
-  {
-    const group = updateGroupFormat({items: [
-      {name: '- 1.01 Hello World 425', downloaderArg: 'x'},
-      {name: '1.02 Aww Yeah 371', downloaderArg: 'x'},
-      {name: ' 1.03 Here Goes 472', downloaderArg: 'x'}
-    ]})
+export function walkSharedStructure(modelGrouplike, ...additionalGrouplikesAndCallback) {
+  // Recursively traverse (aka "walk") a model grouplike and follow the same
+  // path through one or more additional grouplikes, running a callback with
+  // the item at that path from each of the grouplikes (model and additional).
 
-    for (let i = 0; i < group.items.length; i++) {
-      console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i]))
-    }
-  }
+  const additionalGrouplikes = additionalGrouplikesAndFunction.slice(0, -1)
+  const callback = additionalGrouplikesAndCallback[additionalGrouplikesAndFunction.length - 1]
 
-  {
-    const group = updateGroupFormat({items: [
-      {name: 'BAM #1', downloaderArg: 'x'},
-      {name: 'BAM #2', downloaderArg: 'x'},
-      {name: 'BAM #3.1 - no', downloaderArg: 'x'}
-    ]})
+  const recursive = (model, ...additional) => {
+    for (let i = 0; i < model.items.length; i++) {
+      const modelItem = model.items[i]
+      const additionalItems = additional.map(a => a.items[i])
+      callback(modelItem, ...additionalItems)
 
-    for (let i = 0; i < group.items.length; i++) {
-      console.log(group.items[i].name, '->', getNameWithoutTrackNumber(group.items[i]))
+      if (isGroup(modelItem)) {
+        recursive(modelItem, ...additionalItems)
+      }
     }
   }
 }