A music player that connects to your cloud/distributed storage.
at v4 199 lines 4.8 kB view raw
1/** 2 * @import {PlaylistItem, Track} from "~/definitions/types.d.ts" 3 */ 4 5import { compareTimestamps } from "~/common/utils.js"; 6 7/** 8 * Filter tracks by playlist membership using an indexed lookup. 9 * 10 * @param {Track[]} tracks 11 * @param {PlaylistItem[]} playlistItems 12 */ 13export function filterByPlaylist(tracks, playlistItems) { 14 // Group playlist items by criteria shape, building a Set index per shape. 15 const shapes = playlistItems 16 .reduce( 17 (acc, playlistItem) => { 18 const shapeKey = playlistItem.criteria 19 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 20 .join("\0\0"); 21 22 const group = acc.get(shapeKey) ?? acc 23 .set(shapeKey, { criteria: playlistItem.criteria, keys: new Set() }) 24 .get(shapeKey); 25 26 group?.keys.add( 27 playlistItem.criteria.map((c) => 28 transform(c.value, c.transformations) 29 ).join( 30 "\0", 31 ), 32 ); 33 34 return acc; 35 }, 36 /** @type {Map<string, { criteria: PlaylistItem["criteria"], keys: Set<string> }>} */ (new Map()), 37 ) 38 .values() 39 .map((group) => ({ 40 fields: group.criteria.map((c) => ({ 41 parts: c.field.split("."), 42 transformations: c.transformations, 43 })), 44 keys: group.keys, 45 })) 46 .toArray(); 47 48 return tracks.filter((track) => 49 shapes.some((shape) => 50 shape.keys.has( 51 shape.fields 52 .map(({ parts, transformations }) => 53 transform( 54 parts.reduce((v, f) => v?.[f], /** @type {any} */ (track)), 55 transformations, 56 ) 57 ) 58 .join("\0"), 59 ) 60 ) 61 ); 62} 63 64/** 65 * Bundle playlist items into their respective playlists. 66 * 67 * @param {PlaylistItem[]} items 68 */ 69export function gather(items) { 70 /** 71 * @type {Map<string, { items: PlaylistItem[]; name: string; unordered: boolean }>} 72 */ 73 const playlistMap = new Map(); 74 75 for (const item of items) { 76 const existing = playlistMap.get(item.playlist); 77 78 if (!existing) { 79 playlistMap.set(item.playlist, { 80 items: [item], 81 name: item.playlist, 82 unordered: item.positionedAfter == null, 83 }); 84 } else { 85 existing.items.push(item); 86 existing.unordered = existing.unordered === false 87 ? false 88 : item.positionedAfter == null; 89 } 90 } 91 92 return playlistMap; 93} 94 95/** 96 * Check if a track matches the criteria of a playlist item. 97 * 98 * @param {Track} track 99 * @param {PlaylistItem} item 100 */ 101export function match(track, item) { 102 return item.criteria.every((c) => { 103 /** @type {any} */ 104 let value = track; 105 106 /** @type {any} */ 107 let critValue = c.value; 108 109 c.field.split(".").forEach((f) => { 110 if (value) value = value[f]; 111 }); 112 113 if (value && c.transformations) { 114 c.transformations.forEach((t) => { 115 try { 116 value = value[t](); 117 critValue = critValue[t](); 118 } catch (err) {} 119 }); 120 } 121 122 return critValue === value; 123 }); 124} 125 126/** 127 * Sort playlist items by their `positionedAfter` linked-list order. 128 * Items with no `positionedAfter` are placed first. 129 * 130 * @param {PlaylistItem[]} items 131 * @returns {PlaylistItem[]} 132 */ 133export function sort(items) { 134 if (items.length <= 1) return items; 135 136 /** @type {Map<string | null, PlaylistItem[]>} */ 137 const afterMap = new Map(); 138 139 for (const item of items) { 140 const key = item.positionedAfter ?? null; 141 const group = afterMap.get(key); 142 if (group) { 143 group.push(item); 144 } else { 145 afterMap.set(key, [item]); 146 } 147 } 148 149 // Sort each group by updatedAt so collisions have a deterministic order. 150 for (const group of afterMap.values()) { 151 if (group.length > 1) { 152 group.sort((a, b) => { 153 if (!a.updatedAt || !b.updatedAt) return a.updatedAt ? 1 : -1; 154 return compareTimestamps(a.updatedAt, b.updatedAt); 155 }); 156 } 157 } 158 159 /** @type {PlaylistItem[]} */ 160 const sorted = []; 161 const visited = new Set(); 162 163 /** @type {PlaylistItem[]} */ 164 const queue = [...(afterMap.get(null) ?? [])]; 165 166 while (queue.length > 0) { 167 const current = /** @type {PlaylistItem} */ (queue.shift()); 168 if (visited.has(current.id)) continue; 169 visited.add(current.id); 170 sorted.push(current); 171 172 const next = afterMap.get(current.id); 173 if (next) queue.unshift(...next); 174 } 175 176 // Append any items not reachable from a head (e.g. broken chains). 177 for (const item of items) { 178 if (!visited.has(item.id)) { 179 sorted.push(item); 180 } 181 } 182 183 return sorted; 184} 185 186/** 187 * @param {any} val 188 * @param {string[] | undefined} transformations 189 */ 190function transform(val, transformations) { 191 if (!val || !transformations) return val; 192 return transformations.reduce((v, t) => { 193 try { 194 return v[t](); 195 } catch (_) { 196 return v; 197 } 198 }, val); 199}