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/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}