A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 351 lines 12 kB view raw
1/** 2 * @import {PlaylistItem, Track} from "~/definitions/types.d.ts" 3 */ 4 5import { compareTimestamps } from "~/common/temporal.js"; 6 7/** 8 * Filter tracks by playlist membership using an indexed lookup. 9 * 10 * @param {Track[]} tracks 11 * @param {PlaylistItem[]} playlistItems 12 * 13 * @example Returns only tracks matching playlist criteria 14 * ```js 15 * import { filterByPlaylist } from "~/common/playlist.js"; 16 * 17 * const tracks = [ 18 * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, 19 * { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } }, 20 * ]; 21 * const items = [ 22 * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, 23 * ]; 24 * // @ts-ignore 25 * const result = filterByPlaylist(tracks, items); 26 * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'"); 27 * ``` 28 * 29 * @example Returns empty array when no tracks match or no items given 30 * ```js 31 * import { filterByPlaylist } from "~/common/playlist.js"; 32 * 33 * const tracks = [ 34 * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, 35 * ]; 36 * const noMatchItems = [ 37 * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, 38 * ]; 39 * // @ts-ignore 40 * if (filterByPlaylist(tracks, noMatchItems).length !== 0) throw new Error("expected no matches"); 41 * // @ts-ignore 42 * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items"); 43 * ``` 44 * 45 * @example Applies transformations before comparing 46 * ```js 47 * import { filterByPlaylist } from "~/common/playlist.js"; 48 * 49 * const tracks = [{ $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }]; 50 * const items = [{ 51 * $type: "sh.diffuse.output.playlistItem", 52 * id: "i1", 53 * playlist: "p", 54 * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], 55 * }]; 56 * // @ts-ignore 57 * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match"); 58 * ``` 59 */ 60export function filterByPlaylist(tracks, playlistItems) { 61 // Group playlist items by criteria shape, building a Set index per shape. 62 const shapes = playlistItems 63 .reduce( 64 (acc, playlistItem) => { 65 const shapeKey = playlistItem.criteria 66 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 67 .join("\0\0"); 68 69 const group = acc.get(shapeKey) ?? acc 70 .set(shapeKey, { criteria: playlistItem.criteria, keys: new Set() }) 71 .get(shapeKey); 72 73 group?.keys.add( 74 playlistItem.criteria.map((c) => 75 transform(c.value, c.transformations) 76 ).join( 77 "\0", 78 ), 79 ); 80 81 return acc; 82 }, 83 /** @type {Map<string, { criteria: PlaylistItem["criteria"], keys: Set<string> }>} */ (new Map()), 84 ) 85 .values() 86 .map((group) => ({ 87 fields: group.criteria.map((c) => ({ 88 parts: c.field.split("."), 89 transformations: c.transformations, 90 })), 91 keys: group.keys, 92 })) 93 .toArray(); 94 95 return tracks.filter((track) => 96 shapes.some((shape) => 97 shape.keys.has( 98 shape.fields 99 .map(({ parts, transformations }) => 100 transform( 101 parts.reduce((v, f) => v?.[f], /** @type {any} */ (track)), 102 transformations, 103 ) 104 ) 105 .join("\0"), 106 ) 107 ) 108 ); 109} 110 111/** 112 * Bundle playlist items into their respective playlists. 113 * 114 * @param {PlaylistItem[]} items 115 * 116 * @example Groups items by playlist name and tracks ordered/unordered state 117 * ```js 118 * import { gather } from "~/common/playlist.js"; 119 * 120 * const items = [ 121 * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 122 * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, 123 * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, 124 * ]; 125 * // @ts-ignore 126 * const map = gather(items); 127 * const rock = map.get("Rock"); 128 * const pop = map.get("Pop"); 129 * if (!rock || !pop) throw new Error("expected Rock and Pop playlists"); 130 * if (rock.items.length !== 2) throw new Error("expected 2 rock items"); 131 * if (pop.items.length !== 1) throw new Error("expected 1 pop item"); 132 * 133 * // @ts-ignore 134 * const unordered = gather([ 135 * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, 136 * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, 137 * ]); 138 * const unorderedMix = unordered.get("Mix"); 139 * if (!unorderedMix) throw new Error("expected Mix playlist"); 140 * if (!unorderedMix.unordered) throw new Error("playlist without positionedAfter should be unordered"); 141 * 142 * // @ts-ignore 143 * const ordered = gather([ 144 * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: undefined }, 145 * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, 146 * ]); 147 * const orderedMix = ordered.get("Mix"); 148 * if (!orderedMix) throw new Error("expected Mix playlist"); 149 * if (orderedMix.unordered) throw new Error("playlist with positionedAfter should be ordered"); 150 * ``` 151 */ 152export function gather(items) { 153 /** 154 * @type {Map<string, { items: PlaylistItem[]; name: string; unordered: boolean }>} 155 */ 156 const playlistMap = new Map(); 157 158 for (const item of items) { 159 const existing = playlistMap.get(item.playlist); 160 161 if (!existing) { 162 playlistMap.set(item.playlist, { 163 items: [item], 164 name: item.playlist, 165 unordered: item.positionedAfter == null, 166 }); 167 } else { 168 existing.items.push(item); 169 existing.unordered = existing.unordered === false 170 ? false 171 : item.positionedAfter == null; 172 } 173 } 174 175 return playlistMap; 176} 177 178/** 179 * Check if a track matches the criteria of a playlist item. 180 * 181 * @param {Track} track 182 * @param {PlaylistItem} item 183 * 184 * @example Returns true when all criteria match, false when any criterion fails 185 * ```js 186 * import { match } from "~/common/playlist.js"; 187 * 188 * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A", title: "Song A" } }; 189 * const item = { 190 * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", 191 * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }], 192 * }; 193 * // @ts-ignore 194 * if (!match(track, item)) throw new Error("should match when all criteria pass"); 195 * 196 * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] }; 197 * // @ts-ignore 198 * if (match(track, mismatch)) throw new Error("should not match when a criterion fails"); 199 * ``` 200 * 201 * @example Applies transformations before comparing 202 * ```js 203 * import { match } from "~/common/playlist.js"; 204 * 205 * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A" } }; 206 * const item = { 207 * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", 208 * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }], 209 * }; 210 * // @ts-ignore 211 * if (!match(track, item)) throw new Error("transformation should match lowercase"); 212 * ``` 213 */ 214export function match(track, item) { 215 return item.criteria.every((c) => { 216 /** @type {any} */ 217 let value = track; 218 219 /** @type {any} */ 220 let critValue = c.value; 221 222 c.field.split(".").forEach((f) => { 223 if (value) value = value[f]; 224 }); 225 226 if (value && c.transformations) { 227 c.transformations.forEach((t) => { 228 try { 229 value = value[t](); 230 critValue = critValue[t](); 231 } catch (err) {} 232 }); 233 } 234 235 return critValue === value; 236 }); 237} 238 239/** 240 * Sort playlist items by their `positionedAfter` linked-list order. 241 * Items with no `positionedAfter` are placed first. 242 * 243 * @param {PlaylistItem[]} items 244 * @returns {PlaylistItem[]} 245 * 246 * @example Sorts a linked list in order and appends orphaned items at end 247 * ```js 248 * import { sort } from "~/common/playlist.js"; 249 * 250 * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }]; 251 * // @ts-ignore 252 * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged"); 253 * 254 * const linked = [ 255 * { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, 256 * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, 257 * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 258 * ]; 259 * // @ts-ignore 260 * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order"); 261 * 262 * const withOrphan = [ 263 * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, 264 * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, 265 * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, 266 * ]; 267 * // @ts-ignore 268 * const sorted = sort(withOrphan); 269 * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last"); 270 * ``` 271 * 272 * @example Sorts multiple heads by updatedAt ascending 273 * ```js 274 * import { sort } from "~/common/playlist.js"; 275 * 276 * const items = [ 277 * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-06-01T00:00:00.000Z" }, 278 * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-01-01T00:00:00.000Z" }, 279 * ]; 280 * // @ts-ignore 281 * const result = sort(items); 282 * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt"); 283 * ``` 284 */ 285export function sort(items) { 286 if (items.length <= 1) return items; 287 288 /** @type {Map<string | null, PlaylistItem[]>} */ 289 const afterMap = new Map(); 290 291 for (const item of items) { 292 const key = item.positionedAfter ?? null; 293 const group = afterMap.get(key); 294 if (group) { 295 group.push(item); 296 } else { 297 afterMap.set(key, [item]); 298 } 299 } 300 301 // Sort each group by updatedAt so collisions have a deterministic order. 302 for (const group of afterMap.values()) { 303 if (group.length > 1) { 304 group.sort((a, b) => { 305 if (!a.updatedAt || !b.updatedAt) return a.updatedAt ? 1 : -1; 306 return compareTimestamps(a.updatedAt, b.updatedAt); 307 }); 308 } 309 } 310 311 /** @type {PlaylistItem[]} */ 312 const sorted = []; 313 const visited = new Set(); 314 315 /** @type {PlaylistItem[]} */ 316 const queue = [...(afterMap.get(null) ?? [])]; 317 318 while (queue.length > 0) { 319 const current = /** @type {PlaylistItem} */ (queue.shift()); 320 if (visited.has(current.id)) continue; 321 visited.add(current.id); 322 sorted.push(current); 323 324 const next = afterMap.get(current.id); 325 if (next) queue.unshift(...next); 326 } 327 328 // Append any items not reachable from a head (e.g. broken chains). 329 for (const item of items) { 330 if (!visited.has(item.id)) { 331 sorted.push(item); 332 } 333 } 334 335 return sorted; 336} 337 338/** 339 * @param {any} val 340 * @param {string[] | undefined} transformations 341 */ 342export function transform(val, transformations) { 343 if (!val || !transformations) return val; 344 return transformations.reduce((v, t) => { 345 try { 346 return v[t](); 347 } catch (_) { 348 return v; 349 } 350 }, val); 351}