forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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}