forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { defineElement, DiffuseElement, query } from "~/common/element.js";
2import { computed, signal } from "~/common/signal.js";
3
4/**
5 * @import {SignalReader} from "~/common/signal.d.ts"
6 * @import {Track} from "~/definitions/types.d.ts"
7 */
8
9////////////////////////////////////////////
10// ELEMENT
11////////////////////////////////////////////
12
13class CoverGroupsOrchestrator extends DiffuseElement {
14 static NAME = "diffuse/orchestrator/cover-groups";
15
16 // SIGNALS
17
18 #provider = signal(
19 /** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | null} */ (null),
20 );
21
22 // STATE
23
24 artistGroups = computed(() => {
25 const provider = this.#provider.value;
26 const groups = /** @type {any} */ (provider)?.groups?.();
27
28 /** @type {{ label: string; groups: ArtistGroup[] }[]} */
29 const result = [];
30
31 if (groups?.length) {
32 const allTracks = provider?.tracks() ?? [];
33
34 // Total track counts per artist across all groups
35 /** @type {Map<string, number>} */
36 const totalCounts = new Map();
37 for (const track of allTracks) {
38 const key = String(track.tags?.artist ?? "").toLowerCase();
39 totalCounts.set(key, (totalCounts.get(key) ?? 0) + 1);
40 }
41
42 for (
43 const group
44 of /** @type {{ label: string; tracks: Track[] }[]} */ (groups)
45 ) {
46 const artists = deduplicateArtists(group.tracks).map((a) => ({
47 ...a,
48 trackCount: totalCounts.get(a.artistKey) ?? a.trackCount,
49 }));
50 if (artists.length) result.push({ label: group.label, groups: artists });
51 }
52 } else {
53 const allTracks = provider?.tracks() ?? [];
54 const artists = deduplicateArtists(allTracks);
55 if (artists.length) result.push({ label: "", groups: artists });
56 }
57
58 return result;
59 });
60
61 coverGroups = computed(() => {
62 const provider = this.#provider.value;
63 const groups = /** @type {any} */ (provider)?.groups?.();
64
65 /** @type {{ label: string; groups: CoverGroup[] }[]} */
66 const result = [];
67
68 if (groups?.length) {
69 for (
70 const group
71 of /** @type {{ label: string; tracks: Track[] }[]} */ (groups)
72 ) {
73 const albums = deduplicateAlbums(group.tracks);
74 if (albums.length) result.push({ label: group.label, groups: albums });
75 }
76 } else {
77 const tracks = provider?.tracks() ?? [];
78 const albums = deduplicateAlbums(tracks);
79 if (albums.length) result.push({ label: "", groups: albums });
80 }
81
82 return result;
83 });
84
85 // LIFECYCLE
86
87 /**
88 * @override
89 */
90 async connectedCallback() {
91 super.connectedCallback();
92
93 /** @type {DiffuseElement & { tracks: SignalReader<Track[]> }} */
94 const provider = query(this, "tracks-selector");
95
96 await customElements.whenDefined(provider.localName);
97 this.#provider.value = provider;
98 }
99}
100
101export default CoverGroupsOrchestrator;
102
103////////////////////////////////////////////
104// HELPERS
105////////////////////////////////////////////
106
107/**
108 * @typedef {{ albumKey: string; albumName: string; artist: string; track: Track }} CoverGroup
109 */
110
111/**
112 * @typedef {{ artistKey: string; artistName: string; trackCount: number; track: Track }} ArtistGroup
113 */
114
115/**
116 * @param {Track[]} tracks
117 * @returns {CoverGroup[]}
118 */
119function deduplicateAlbums(tracks) {
120 /** @type {Map<string, { track: Track; artists: Set<string> }>} */
121 const albumMap = new Map();
122
123 for (const track of tracks) {
124 const albumKey = String(track.tags?.album ?? "").toLowerCase();
125 const existing = albumMap.get(albumKey);
126 if (existing) {
127 existing.artists.add(track.tags?.artist ?? "Unknown artist");
128 } else {
129 albumMap.set(albumKey, {
130 track,
131 artists: new Set([track.tags?.artist ?? "Unknown artist"]),
132 });
133 }
134 }
135
136 return [...albumMap.entries()]
137 .sort(([a], [b]) => a.localeCompare(b))
138 .map(([albumKey, { track, artists }]) => ({
139 albumKey,
140 albumName: track.tags?.album ?? "Unknown album",
141 artist: artists.size > 1 ? "Various Artists" : /** @type {string} */ (artists.values().next().value),
142 track,
143 }));
144}
145
146/**
147 * @param {Track[]} tracks
148 * @returns {ArtistGroup[]}
149 */
150function deduplicateArtists(tracks) {
151 /** @type {Map<string, { artistName: string; count: number; track: Track }>} */
152 const map = new Map();
153
154 for (const track of tracks) {
155 const artistKey = String(track.tags?.artist ?? "").toLowerCase();
156 const existing = map.get(artistKey);
157 if (existing) {
158 existing.count++;
159 } else {
160 map.set(artistKey, {
161 artistName: track.tags?.artist ?? "Unknown artist",
162 count: 1,
163 track,
164 });
165 }
166 }
167
168 return [...map.entries()]
169 .sort(([a], [b]) => a.localeCompare(b))
170 .map(([artistKey, { artistName, count, track }]) => ({
171 artistKey,
172 artistName,
173 trackCount: count,
174 track,
175 }));
176}
177
178////////////////////////////////////////////
179// REGISTER
180////////////////////////////////////////////
181
182export const CLASS = CoverGroupsOrchestrator;
183export const NAME = "do-cover-groups";
184
185defineElement(NAME, CLASS);