A music player that connects to your cloud/distributed storage.
at v4 202 lines 5.3 kB view raw
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);