forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { html, render as litRender } from "lit-html";
2
3import * as Output from "~/common/output.js";
4import * as Playlist from "~/common/playlist.js";
5import foundation from "~/common/foundation.js";
6import { effect } from "~/common/signal.js";
7
8/**
9 * @import { PlaylistItem, Track } from "~/definitions/types.d.ts"
10 */
11
12foundation.setup({ title: "Playlists | Diffuse" });
13
14////////////////////////////////////////////
15// SETUP
16////////////////////////////////////////////
17
18const outputOrchestrator = await foundation.orchestrator.output();
19
20await customElements.whenDefined(outputOrchestrator.localName);
21
22////////////////////////////////////////////
23// UI
24////////////////////////////////////////////
25
26const list =
27 /** @type {HTMLElement} */ (document.querySelector("#playlists-list"));
28const empty =
29 /** @type {HTMLElement} */ (document.querySelector("#playlists-empty"));
30const dialog =
31 /** @type {HTMLDialogElement} */ (document.querySelector("#playlists-dialog"));
32
33effect(() => {
34 const playlistItemsCol = outputOrchestrator.playlistItems.collection();
35 const playlistItems =
36 playlistItemsCol.state === "loaded" ? playlistItemsCol.data : [];
37
38 const tracksCol = outputOrchestrator.tracks.collection();
39 const tracks = tracksCol.state === "loaded" ? tracksCol.data : [];
40
41 const playlists = [...Playlist.gather(playlistItems).values()]
42 .sort((a, b) => a.name.localeCompare(b.name));
43 const stats = computeStats(tracks, playlists);
44
45 list.hidden = playlists.length === 0;
46 empty.hidden = playlists.length > 0;
47
48 litRender(
49 html`
50 ${playlists.map(({ name, items }, index) => {
51 const { matchedCount, missingCount } = stats.get(name) ??
52 { matchedCount: 0, missingCount: 0 };
53 const menuId = `playlists-menu-${index}`;
54 return html`
55 <li class="playlists-item">
56 <div class="playlists-item__info">
57 <span class="playlists-item__name">${name}</span>
58 <span class="playlists-item__detail">${matchedCount} found · ${missingCount} not found</span>
59 </div>
60 <button
61 class="button--plain button--icon"
62 aria-label="More"
63 popovertarget="${menuId}"
64 >
65 <i class="ph-fill ph-dots-three-outline-vertical"></i>
66 </button>
67 <div id="${menuId}" class="dropdown" popover>
68 <button
69 @click="${(/** @type {MouseEvent} */ e) => {
70 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover();
71 showDetails(name, tracks, items);
72 }}"
73 >
74 <i class="ph-fill ph-binoculars"></i>
75 View tracks
76 </button>
77 <button
78 @click="${(/** @type {MouseEvent} */ e) => {
79 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover();
80 removeDuplicates(name, items);
81 }}"
82 >
83 <i class="ph-fill ph-copy"></i>
84 Remove duplicates
85 </button>
86 <button
87 @click="${(/** @type {MouseEvent} */ e) => {
88 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover();
89 removePlaylist(name);
90 }}"
91 >
92 <i class="ph-fill ph-skull"></i>
93 Delete
94 </button>
95 </div>
96 </li>
97 `;
98 })}
99 `,
100 list,
101 );
102});
103
104////////////////////////////////////////////
105// ACTIONS
106////////////////////////////////////////////
107
108/** @param {string} name */
109async function removePlaylist(name) {
110 const playlistItems = await Output.data(outputOrchestrator.playlistItems);
111 await outputOrchestrator.playlistItems.save(
112 playlistItems.filter((item) => item.playlist !== name),
113 );
114}
115
116/**
117 * @param {string} name
118 * @param {Track[]} tracks
119 * @param {PlaylistItem[]} items
120 */
121/**
122 * @param {string} name
123 * @param {PlaylistItem[]} items
124 */
125async function removeDuplicates(name, items) {
126 const seen = new Set();
127 const duplicateIds = new Set();
128
129 for (const item of items) {
130 const key = item.criteria
131 .map((c) => `${c.field}\0${String(c.value)}`)
132 .join("\0\0");
133 if (seen.has(key)) {
134 duplicateIds.add(item.id);
135 } else {
136 seen.add(key);
137 }
138 }
139
140 if (duplicateIds.size === 0) return;
141
142 const allItems = await Output.data(outputOrchestrator.playlistItems);
143 await outputOrchestrator.playlistItems.save(
144 allItems.filter((item) => !duplicateIds.has(item.id)),
145 );
146}
147
148/**
149 * @param {string} name
150 * @param {Track[]} tracks
151 * @param {PlaylistItem[]} items
152 */
153function showDetails(name, tracks, items) {
154 const seenIds = new Set();
155 const found = /** @type {Track[]} */ (items
156 .map((item) => tracks.find((t) => Playlist.match(t, item)))
157 .filter((t) => t != null && !seenIds.has(t.id) && seenIds.add(t.id))
158 .sort((a, b) => (a?.tags?.title ?? "").localeCompare(b?.tags?.title ?? "")));
159
160 const notFound = items
161 .filter((item) => !tracks.some((t) => Playlist.match(t, item)))
162 .map((item) => ({
163 title: String(item.criteria.find((c) => c.field === "tags.title")?.value ?? ""),
164 artist: String(item.criteria.find((c) => c.field === "tags.artist")?.value ?? "") || null,
165 album: String(item.criteria.find((c) => c.field === "tags.album")?.value ?? "") || null,
166 }))
167 .sort((a, b) => a.title.localeCompare(b.title));
168
169 litRender(
170 html`
171 <div class="dialog-header">
172 <strong>${name}</strong>
173 <button
174 class="button--plain button--icon"
175 @click="${() => dialog.close()}"
176 >
177 <i class="ph-fill ph-x"></i>
178 </button>
179 </div>
180 <div class="dialog-body">
181 <div class="tracks-section">
182 <span class="tracks-section__heading">${found.length} found</span>
183 <ul class="tracks-list">
184 ${found.map((t) => html`
185 <li>
186 <span class="tracks-list__name">${t.tags?.title ?? t.uri}</span>
187 ${t.tags?.artist
188 ? html`<span class="tracks-list__artist">${t.tags.artist}</span>`
189 : null}
190 </li>
191 `)}
192 </ul>
193 </div>
194 ${notFound.length > 0 ? html`
195 <div class="tracks-section">
196 <span class="tracks-section__heading">${notFound.length} not found</span>
197 <ul class="tracks-list">
198 ${notFound.map(({ title, artist, album }) => html`
199 <li>
200 <span class="tracks-list__name">${title}</span>
201 ${artist ? html`<span class="tracks-list__artist">${artist}${album ? html` · ${album}` : null}</span>` : null}
202 </li>
203 `)}
204 </ul>
205 </div>
206 ` : null}
207 </div>
208 `,
209 dialog,
210 );
211
212 dialog.showModal();
213}
214
215////////////////////////////////////////////
216// STATS
217////////////////////////////////////////////
218
219/**
220 * Compute matched/missing counts for all playlists in a single pass over tracks.
221 * O(tracks × playlists + items_total) instead of O(tracks × items × playlists).
222 *
223 * @param {Track[]} tracks
224 * @param {Array<{ name: string, items: PlaylistItem[] }>} playlists
225 * @returns {Map<string, { matchedCount: number, missingCount: number }>}
226 */
227function computeStats(tracks, playlists) {
228 // Build a shape index per playlist.
229 const indexes = playlists.map(({ name, items }) => {
230 /** @type {Map<string, { fields: { parts: string[], transformations: string[] | undefined }[], trackKeys: Set<string>, itemKeys: Set<string> }>} */
231 const shapeMap = new Map();
232
233 for (const item of items) {
234 const shapeKey = item.criteria
235 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`)
236 .join("\0\0");
237
238 if (!shapeMap.has(shapeKey)) {
239 shapeMap.set(shapeKey, {
240 fields: item.criteria.map((c) => ({
241 parts: c.field.split("."),
242 transformations: /** @type {string[] | undefined} */ (c.transformations),
243 })),
244 trackKeys: new Set(),
245 itemKeys: new Set(),
246 });
247 }
248
249 const shape = shapeMap.get(shapeKey);
250 const itemKey = item.criteria
251 .map((c) => Playlist.transform(c.value, c.transformations))
252 .join("\0");
253 shape?.itemKeys.add(itemKey);
254 }
255
256 return { name, shapeMap, shapes: [...shapeMap.values()], items };
257 });
258
259 // Single pass over tracks — update all playlist indexes at once.
260 const matchedCounts = new Map(playlists.map((p) => [p.name, 0]));
261 for (const track of tracks) {
262 for (const { name, shapes } of indexes) {
263 let trackMatched = false;
264 for (const shape of shapes) {
265 const trackKey = shape.fields
266 .map(({ parts, transformations }) =>
267 Playlist.transform(
268 parts.reduce((v, f) => /** @type {any} */ (v)?.[f], /** @type {any} */ (track)),
269 transformations,
270 )
271 )
272 .join("\0");
273 if (shape.itemKeys.has(trackKey)) {
274 shape.trackKeys.add(trackKey);
275 trackMatched = true;
276 }
277 }
278 if (trackMatched) matchedCounts.set(name, (matchedCounts.get(name) ?? 0) + 1);
279 }
280 }
281
282 // Derive missing counts from the now-populated trackKeys sets.
283 const result = new Map();
284 for (const { name, shapeMap, items } of indexes) {
285 let missingCount = 0;
286 for (const item of items) {
287 const shapeKey = item.criteria
288 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`)
289 .join("\0\0");
290 const itemKey = item.criteria
291 .map((c) => Playlist.transform(c.value, c.transformations))
292 .join("\0");
293 if (!shapeMap.get(shapeKey)?.trackKeys.has(itemKey)) missingCount++;
294 }
295 result.set(name, { matchedCount: matchedCounts.get(name) ?? 0, missingCount });
296 }
297
298 return result;
299}
300
301
302////////////////////////////////////////////
303// 🚀
304////////////////////////////////////////////
305
306foundation.ready();