A music player that connects to your cloud/distributed storage.
1import { cachedConsult } from "~/components/input/common.js";
2
3/**
4 * @import {Track} from "~/definitions/types.d.ts"
5 */
6
7/**
8 * Group tracks by host.
9 *
10 * @param {Track[]} tracks
11 * @returns {Record<string, { host: string; tracks: Track[] }>}
12 *
13 * @example Group tracks by domain
14 * ```ts
15 * import { expect } from "@std/expect";
16 * import { groupTracksByHost } from "./common.js";
17 * import type { Track } from "~/definitions/types.d.ts";
18 *
19 * const tracks: Track[] = [
20 * {
21 * $type: "sh.diffuse.output.track",
22 * id: "1",
23 * uri: "https://example.com/a.mp3",
24 * },
25 * {
26 * $type: "sh.diffuse.output.track",
27 * id: "2",
28 * uri: "https://cdn.example.com/b.mp3",
29 * },
30 * {
31 * $type: "sh.diffuse.output.track",
32 * id: "3",
33 * uri: "https://example.com/c.mp3",
34 * },
35 * ];
36 *
37 * const groups = groupTracksByHost(tracks);
38 * expect(Object.keys(groups).length).toBe(2);
39 * expect(groups["example.com"].tracks.length).toBe(2);
40 * expect(groups["cdn.example.com"].tracks.length).toBe(1);
41 * ```
42 *
43 * @example Group tracks by host including port
44 * ```ts
45 * import { expect } from "@std/expect";
46 * import { groupTracksByHost } from "./common.js";
47 * import type { Track } from "~/definitions/types.d.ts";
48 *
49 * const tracks: Track[] = [
50 * {
51 * $type: "sh.diffuse.output.track",
52 * id: "1",
53 * uri: "https://example.com/a.mp3",
54 * },
55 * {
56 * $type: "sh.diffuse.output.track",
57 * id: "2",
58 * uri: "https://example.com:8443/b.mp3",
59 * },
60 * ];
61 *
62 * const groups = groupTracksByHost(tracks);
63 * expect(Object.keys(groups).length).toBe(2);
64 * expect(groups["example.com"].tracks.length).toBe(1);
65 * expect(groups["example.com:8443"].tracks.length).toBe(1);
66 * ```
67 */
68export function groupTracksByHost(tracks) {
69 /** @type {Record<string, { host: string; tracks: Track[] }>} */
70 const acc = {};
71
72 tracks.forEach((track) => {
73 const parsed = parseURI(track.uri);
74 if (!parsed) return;
75
76 const host = parsed.host;
77
78 if (acc[host]) {
79 acc[host].tracks.push(track);
80 } else {
81 acc[host] = { host, tracks: [track] };
82 }
83 });
84
85 return acc;
86}
87
88/**
89 * Group URIs by host.
90 *
91 * @param {string[]} uris
92 * @returns {Record<string, { host: string; uris: string[] }>}
93 */
94export function groupUrisByHost(uris) {
95 /** @type {Record<string, { host: string; uris: string[] }>} */
96 const acc = {};
97
98 uris.forEach((uri) => {
99 const parsed = parseURI(uri);
100 if (!parsed) return;
101
102 const host = parsed.host;
103
104 if (acc[host]) {
105 acc[host].uris.push(uri);
106 } else {
107 acc[host] = { host, uris: [uri] };
108 }
109 });
110
111 return acc;
112}
113
114/**
115 * Extract unique hosts from tracks.
116 *
117 * @param {Track[]} tracks
118 * @returns {Record<string, string>}
119 *
120 * @example Extract unique hosts
121 * ```ts
122 * import { expect } from "@std/expect";
123 * import { hostsFromTracks } from "./common.js";
124 * import type { Track } from "~/definitions/types.d.ts";
125 *
126 * const tracks: Track[] = [
127 * {
128 * $type: "sh.diffuse.output.track",
129 * id: "1",
130 * uri: "https://example.com/a.mp3",
131 * },
132 * {
133 * $type: "sh.diffuse.output.track",
134 * id: "2",
135 * uri: "https://example.com/b.mp3",
136 * },
137 * {
138 * $type: "sh.diffuse.output.track",
139 * id: "3",
140 * uri: "https://cdn.example.com/c.mp3",
141 * },
142 * ];
143 *
144 * const hosts = hostsFromTracks(tracks);
145 * expect(Object.keys(hosts).length).toBe(2);
146 * expect(hosts["example.com"]).toBe("example.com");
147 * expect(hosts["cdn.example.com"]).toBe("cdn.example.com");
148 * ```
149 */
150export function hostsFromTracks(tracks) {
151 /** @type {Record<string, string>} */
152 const acc = {};
153
154 tracks.forEach((track) => {
155 const parsed = parseURI(track.uri);
156 if (!parsed) return;
157
158 const host = parsed.host;
159 if (acc[host]) return;
160
161 acc[host] = host;
162 });
163
164 return acc;
165}
166
167/**
168 * Parse an HTTPS URI.
169 * Validates and extracts components from a standard HTTPS URL.
170 *
171 * @param {string} uriString
172 * @returns {{ url: string; domain: string; path: string; host: string } | undefined}
173 *
174 * @example Parse a valid HTTPS URI
175 * ```ts
176 * import { expect } from "@std/expect";
177 * import { parseURI } from "./common.js";
178 *
179 * const result = parseURI("https://example.com/song.mp3");
180 * expect(result?.domain).toBe("example.com");
181 * expect(result?.host).toBe("example.com");
182 * expect(result?.path).toBe("/song.mp3");
183 * expect(result?.url).toBe("https://example.com/song.mp3");
184 * ```
185 *
186 * @example Parse HTTPS URI with port
187 * ```ts
188 * import { expect } from "@std/expect";
189 * import { parseURI } from "./common.js";
190 *
191 * const result = parseURI("https://example.com:8443/audio.mp3");
192 * expect(result?.domain).toBe("example.com");
193 * expect(result?.host).toBe("example.com:8443");
194 * expect(result?.path).toBe("/audio.mp3");
195 * ```
196 *
197 * @example Parse HTTPS URI with query parameters
198 * ```ts
199 * import { expect } from "@std/expect";
200 * import { parseURI } from "./common.js";
201 *
202 * const result = parseURI("https://example.com/song.mp3?token=abc123");
203 * expect(result?.domain).toBe("example.com");
204 * expect(result?.path).toBe("/song.mp3");
205 * expect(result?.url).toContain("token=abc123");
206 * ```
207 *
208 * @example Reject non-HTTPS URI
209 * ```ts
210 * import { expect } from "@std/expect";
211 * import { parseURI } from "./common.js";
212 *
213 * const result = parseURI("http://example.com/song.mp3");
214 * expect(result).toBeUndefined();
215 * ```
216 *
217 * @example Reject invalid URI
218 * ```ts
219 * import { expect } from "@std/expect";
220 * import { parseURI } from "./common.js";
221 *
222 * const result = parseURI("not-a-url");
223 * expect(result).toBeUndefined();
224 * ```
225 */
226export function parseURI(uriString) {
227 try {
228 const url = new URL(uriString);
229 if (url.protocol !== "https:") return undefined;
230
231 return {
232 url: url.href,
233 domain: url.hostname,
234 host: url.host, // includes port if present
235 path: url.pathname,
236 };
237 } catch {
238 return undefined;
239 }
240}
241
242/** @param {string} uri */
243async function consultHost(uri) {
244 try {
245 const controller = new AbortController();
246 const timeoutId = setTimeout(() => controller.abort(), 5000);
247 const response = await fetch(uri, {
248 method: "HEAD",
249 signal: controller.signal,
250 });
251 clearTimeout(timeoutId);
252 return response.ok;
253 } catch {
254 return false;
255 }
256}
257
258export const consultHostCached = cachedConsult(
259 consultHost,
260 (uri) => new URL(uri).host,
261);