/** * @import {PlaylistItem, Track} from "~/definitions/types.d.ts" */ import { compareTimestamps } from "~/common/temporal.js"; /** * Filter tracks by playlist membership using an indexed lookup. * * @param {Track[]} tracks * @param {PlaylistItem[]} playlistItems * * @example Returns only tracks matching playlist criteria * ```js * import { filterByPlaylist } from "~/common/playlist.js"; * * const tracks = [ * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } }, * { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } }, * ]; * const items = [ * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] }, * ]; * // @ts-ignore * const result = filterByPlaylist(tracks, items); * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'"); * ``` * * @example Returns empty array when no tracks match or no items given * ```js * import { filterByPlaylist } from "~/common/playlist.js"; * * const tracks = [ * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } }, * ]; * const noMatchItems = [ * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] }, * ]; * // @ts-ignore * if (filterByPlaylist(tracks, noMatchItems).length !== 0) throw new Error("expected no matches"); * // @ts-ignore * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items"); * ``` * * @example Applies transformations before comparing * ```js * import { filterByPlaylist } from "~/common/playlist.js"; * * const tracks = [{ $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }]; * const items = [{ * $type: "sh.diffuse.output.playlistItem", * id: "i1", * playlist: "p", * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }], * }]; * // @ts-ignore * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match"); * ``` */ export function filterByPlaylist(tracks, playlistItems) { // Group playlist items by criteria shape, building a Set index per shape. const shapes = playlistItems .reduce( (acc, playlistItem) => { const shapeKey = playlistItem.criteria .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) .join("\0\0"); const group = acc.get(shapeKey) ?? acc .set(shapeKey, { criteria: playlistItem.criteria, keys: new Set() }) .get(shapeKey); group?.keys.add( playlistItem.criteria.map((c) => transform(c.value, c.transformations) ).join( "\0", ), ); return acc; }, /** @type {Map }>} */ (new Map()), ) .values() .map((group) => ({ fields: group.criteria.map((c) => ({ parts: c.field.split("."), transformations: c.transformations, })), keys: group.keys, })) .toArray(); return tracks.filter((track) => shapes.some((shape) => shape.keys.has( shape.fields .map(({ parts, transformations }) => transform( parts.reduce((v, f) => v?.[f], /** @type {any} */ (track)), transformations, ) ) .join("\0"), ) ) ); } /** * Bundle playlist items into their respective playlists. * * @param {PlaylistItem[]} items * * @example Groups items by playlist name and tracks ordered/unordered state * ```js * import { gather } from "~/common/playlist.js"; * * const items = [ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" }, * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" }, * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" }, * ]; * // @ts-ignore * const map = gather(items); * const rock = map.get("Rock"); * const pop = map.get("Pop"); * if (!rock || !pop) throw new Error("expected Rock and Pop playlists"); * if (rock.items.length !== 2) throw new Error("expected 2 rock items"); * if (pop.items.length !== 1) throw new Error("expected 1 pop item"); * * // @ts-ignore * const unordered = gather([ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] }, * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] }, * ]); * const unorderedMix = unordered.get("Mix"); * if (!unorderedMix) throw new Error("expected Mix playlist"); * if (!unorderedMix.unordered) throw new Error("playlist without positionedAfter should be unordered"); * * // @ts-ignore * const ordered = gather([ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: undefined }, * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" }, * ]); * const orderedMix = ordered.get("Mix"); * if (!orderedMix) throw new Error("expected Mix playlist"); * if (orderedMix.unordered) throw new Error("playlist with positionedAfter should be ordered"); * ``` */ export function gather(items) { /** * @type {Map} */ const playlistMap = new Map(); for (const item of items) { const existing = playlistMap.get(item.playlist); if (!existing) { playlistMap.set(item.playlist, { items: [item], name: item.playlist, unordered: item.positionedAfter == null, }); } else { existing.items.push(item); existing.unordered = existing.unordered === false ? false : item.positionedAfter == null; } } return playlistMap; } /** * Check if a track matches the criteria of a playlist item. * * @param {Track} track * @param {PlaylistItem} item * * @example Returns true when all criteria match, false when any criterion fails * ```js * import { match } from "~/common/playlist.js"; * * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A", title: "Song A" } }; * const item = { * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }], * }; * // @ts-ignore * if (!match(track, item)) throw new Error("should match when all criteria pass"); * * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] }; * // @ts-ignore * if (match(track, mismatch)) throw new Error("should not match when a criterion fails"); * ``` * * @example Applies transformations before comparing * ```js * import { match } from "~/common/playlist.js"; * * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A" } }; * const item = { * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p", * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }], * }; * // @ts-ignore * if (!match(track, item)) throw new Error("transformation should match lowercase"); * ``` */ export function match(track, item) { return item.criteria.every((c) => { /** @type {any} */ let value = track; /** @type {any} */ let critValue = c.value; c.field.split(".").forEach((f) => { if (value) value = value[f]; }); if (value && c.transformations) { c.transformations.forEach((t) => { try { value = value[t](); critValue = critValue[t](); } catch (err) {} }); } return critValue === value; }); } /** * Sort playlist items by their `positionedAfter` linked-list order. * Items with no `positionedAfter` are placed first. * * @param {PlaylistItem[]} items * @returns {PlaylistItem[]} * * @example Sorts a linked list in order and appends orphaned items at end * ```js * import { sort } from "~/common/playlist.js"; * * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }]; * // @ts-ignore * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged"); * * const linked = [ * { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" }, * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, * ]; * // @ts-ignore * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order"); * * const withOrphan = [ * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined }, * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" }, * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" }, * ]; * // @ts-ignore * const sorted = sort(withOrphan); * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last"); * ``` * * @example Sorts multiple heads by updatedAt ascending * ```js * import { sort } from "~/common/playlist.js"; * * const items = [ * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-06-01T00:00:00.000Z" }, * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: undefined, updatedAt: "2024-01-01T00:00:00.000Z" }, * ]; * // @ts-ignore * const result = sort(items); * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt"); * ``` */ export function sort(items) { if (items.length <= 1) return items; /** @type {Map} */ const afterMap = new Map(); for (const item of items) { const key = item.positionedAfter ?? null; const group = afterMap.get(key); if (group) { group.push(item); } else { afterMap.set(key, [item]); } } // Sort each group by updatedAt so collisions have a deterministic order. for (const group of afterMap.values()) { if (group.length > 1) { group.sort((a, b) => { if (!a.updatedAt || !b.updatedAt) return a.updatedAt ? 1 : -1; return compareTimestamps(a.updatedAt, b.updatedAt); }); } } /** @type {PlaylistItem[]} */ const sorted = []; const visited = new Set(); /** @type {PlaylistItem[]} */ const queue = [...(afterMap.get(null) ?? [])]; while (queue.length > 0) { const current = /** @type {PlaylistItem} */ (queue.shift()); if (visited.has(current.id)) continue; visited.add(current.id); sorted.push(current); const next = afterMap.get(current.id); if (next) queue.unshift(...next); } // Append any items not reachable from a head (e.g. broken chains). for (const item of items) { if (!visited.has(item.id)) { sorted.push(item); } } return sorted; } /** * @param {any} val * @param {string[] | undefined} transformations */ export function transform(val, transformations) { if (!val || !transformations) return val; return transformations.reduce((v, t) => { try { return v[t](); } catch (_) { return v; } }, val); }