A music player that connects to your cloud/distributed storage.
1import { IcyParser } from "@cloudradio/icy-parser";
2import { cachedConsult } from "~/components/input/common.js";
3
4import { SCHEME } from "./constants.js";
5
6/**
7 * @import {Track} from "~/definitions/types.d.ts"
8 */
9
10/**
11 * Build an icecast:// URI from an HTTP or HTTPS URL.
12 * HTTP streams are encoded with a `tls=0` query parameter; HTTPS is the default.
13 *
14 * @param {string} streamUrl
15 * @returns {string}
16 *
17 * @example Build URI from HTTPS URL
18 * ```ts
19 * import { expect } from "@std/expect";
20 * import { buildURI } from "./common.js";
21 *
22 * const uri = buildURI("https://radio.example.com/stream.mp3");
23 * expect(uri).toBe("icecast://radio.example.com/stream.mp3");
24 * ```
25 *
26 * @example Build URI from HTTP URL
27 * ```ts
28 * import { expect } from "@std/expect";
29 * import { buildURI } from "./common.js";
30 *
31 * const uri = buildURI("http://radio.example.com:8000/live");
32 * expect(uri).toBe("icecast://radio.example.com:8000/live?tls=0");
33 * ```
34 */
35export function buildURI(streamUrl) {
36 const url = new URL(streamUrl);
37 const tls = url.protocol === "https:";
38 const query = tls ? url.search : `${url.search ? url.search + "&" : "?"}tls=0`;
39 return `${SCHEME}://${url.host}${url.pathname}${query}`;
40}
41
42/**
43 * Parse an icecast:// URI.
44 * Returns the resolved HTTP or HTTPS stream URL based on the `tls` query param
45 * (absent or `tls=1` → HTTPS; `tls=0` → HTTP).
46 *
47 * @param {string} uriString
48 * @returns {{ host: string; path: string; streamUrl: string } | undefined}
49 *
50 * @example Parse a valid icecast URI (defaults to HTTPS)
51 * ```ts
52 * import { expect } from "@std/expect";
53 * import { parseURI } from "./common.js";
54 *
55 * const result = parseURI("icecast://radio.example.com/stream.mp3");
56 * expect(result?.host).toBe("radio.example.com");
57 * expect(result?.path).toBe("/stream.mp3");
58 * expect(result?.streamUrl).toBe("https://radio.example.com/stream.mp3");
59 * ```
60 *
61 * @example Parse icecast URI for an HTTP stream
62 * ```ts
63 * import { expect } from "@std/expect";
64 * import { parseURI } from "./common.js";
65 *
66 * const result = parseURI("icecast://radio.example.com:8000/live?tls=0");
67 * expect(result?.host).toBe("radio.example.com:8000");
68 * expect(result?.streamUrl).toBe("http://radio.example.com:8000/live");
69 * ```
70 *
71 * @example Reject non-icecast URI
72 * ```ts
73 * import { expect } from "@std/expect";
74 * import { parseURI } from "./common.js";
75 *
76 * const result = parseURI("https://radio.example.com/stream.mp3");
77 * expect(result).toBeUndefined();
78 * ```
79 */
80export function parseURI(uriString) {
81 try {
82 const url = new URL(uriString);
83 if (url.protocol !== `${SCHEME}:`) return undefined;
84
85 const tls = url.searchParams.get("tls") !== "0";
86 const protocol = tls ? "https" : "http";
87
88 // Strip the tls param from the forwarded URL's search string
89 const params = new URLSearchParams(url.search);
90 params.delete("tls");
91 const search = params.size > 0 ? `?${params}` : "";
92
93 return {
94 host: url.host,
95 path: url.pathname,
96 streamUrl: `${protocol}://${url.host}${url.pathname}${search}`,
97 };
98 } catch {
99 return undefined;
100 }
101}
102
103/**
104 * Group tracks by host.
105 *
106 * @param {Track[]} tracks
107 * @returns {Record<string, { host: string; tracks: Track[] }>}
108 */
109export function groupTracksByHost(tracks) {
110 /** @type {Record<string, { host: string; tracks: Track[] }>} */
111 const acc = {};
112
113 tracks.forEach((track) => {
114 const parsed = parseURI(track.uri);
115 if (!parsed) return;
116
117 const { host } = parsed;
118 if (acc[host]) {
119 acc[host].tracks.push(track);
120 } else {
121 acc[host] = { host, tracks: [track] };
122 }
123 });
124
125 return acc;
126}
127
128/**
129 * Group URIs by host.
130 *
131 * @param {string[]} uris
132 * @returns {Record<string, { host: string; uris: string[] }>}
133 */
134export function groupUrisByHost(uris) {
135 /** @type {Record<string, { host: string; uris: string[] }>} */
136 const acc = {};
137
138 uris.forEach((uri) => {
139 const parsed = parseURI(uri);
140 if (!parsed) return;
141
142 const { host } = parsed;
143 if (acc[host]) {
144 acc[host].uris.push(uri);
145 } else {
146 acc[host] = { host, uris: [uri] };
147 }
148 });
149
150 return acc;
151}
152
153/**
154 * Extract unique hosts from tracks.
155 *
156 * @param {Track[]} tracks
157 * @returns {Record<string, string>}
158 */
159export function hostsFromTracks(tracks) {
160 /** @type {Record<string, string>} */
161 const acc = {};
162
163 tracks.forEach((track) => {
164 const parsed = parseURI(track.uri);
165 if (!parsed) return;
166
167 const { host } = parsed;
168 if (acc[host]) return;
169 acc[host] = host;
170 });
171
172 return acc;
173}
174
175/**
176 * Fetch ICY metadata from an Icecast stream.
177 * Returns undefined if the stream is unreachable or does not support ICY metadata.
178 *
179 * @param {string} streamUrl
180 * @returns {Promise<import("@cloudradio/icy-parser").IcyMetadata | undefined>}
181 */
182export async function fetchMetadata(streamUrl) {
183 try {
184 const parser = new IcyParser(streamUrl);
185 return await parser.parseOnce();
186 } catch {
187 return undefined;
188 }
189}
190
191/** @param {string} uri */
192async function consultStream(uri) {
193 const parsed = parseURI(uri);
194 if (!parsed) return false;
195 const metadata = await fetchMetadata(parsed.streamUrl);
196 return metadata !== undefined;
197}
198
199export const consultStreamCached = cachedConsult(
200 consultStream,
201 (uri) => parseURI(uri)?.host ?? uri,
202);