A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: icecast input

+409 -1
+5
deno.jsonc
··· 13 13 "@automerge/automerge": "npm:@automerge/automerge@^3.2.3", 14 14 "@awesome.me/webawesome": "npm:@awesome.me/webawesome@^3.2.1", 15 15 "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.9.5", 16 + "@cloudradio/icy-parser": "jsr:@cloudradio/icy-parser@^1.0.2", 16 17 "@char/cbor": "jsr:@char/cbor@^0.1.4", 17 18 "@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.20.0", 18 19 "@codemirror/lang-css": "npm:@codemirror/lang-css@^6.3.1", ··· 99 100 "./components/input/https/constants.js": "./src/components/input/https/constants.js", 100 101 "./components/input/https/element.js": "./src/components/input/https/element.js", 101 102 "./components/input/https/worker.js": "./src/components/input/https/worker.js", 103 + "./components/input/icecast/common.js": "./src/components/input/icecast/common.js", 104 + "./components/input/icecast/constants.js": "./src/components/input/icecast/constants.js", 105 + "./components/input/icecast/element.js": "./src/components/input/icecast/element.js", 106 + "./components/input/icecast/worker.js": "./src/components/input/icecast/worker.js", 102 107 "./components/input/s3/common.js": "./src/components/input/s3/common.js", 103 108 "./components/input/s3/constants.js": "./src/components/input/s3/constants.js", 104 109 "./components/input/s3/element.js": "./src/components/input/s3/element.js",
+189
src/components/input/icecast/common.js
··· 1 + import { IcyParser } from "@cloudradio/icy-parser"; 2 + import { cachedConsult } from "~/components/input/common.js"; 3 + 4 + import { SCHEME } from "./constants.js"; 5 + 6 + /** 7 + * @import {Track} from "~/definitions/types.d.ts" 8 + */ 9 + 10 + /** 11 + * Build an icecast:// URI from an HTTPS URL. 12 + * 13 + * @param {string} httpsUrl 14 + * @returns {string} 15 + * 16 + * @example Build URI from HTTPS URL 17 + * ```ts 18 + * import { expect } from "@std/expect"; 19 + * import { buildURI } from "./common.js"; 20 + * 21 + * const uri = buildURI("https://radio.example.com/stream.mp3"); 22 + * expect(uri).toBe("icecast://radio.example.com/stream.mp3"); 23 + * ``` 24 + * 25 + * @example Build URI with port 26 + * ```ts 27 + * import { expect } from "@std/expect"; 28 + * import { buildURI } from "./common.js"; 29 + * 30 + * const uri = buildURI("https://radio.example.com:8000/live"); 31 + * expect(uri).toBe("icecast://radio.example.com:8000/live"); 32 + * ``` 33 + */ 34 + export function buildURI(httpsUrl) { 35 + const url = new URL(httpsUrl); 36 + return `${SCHEME}://${url.host}${url.pathname}${url.search}`; 37 + } 38 + 39 + /** 40 + * Parse an icecast:// URI. 41 + * 42 + * @param {string} uriString 43 + * @returns {{ host: string; path: string; httpsUrl: string } | undefined} 44 + * 45 + * @example Parse a valid icecast URI 46 + * ```ts 47 + * import { expect } from "@std/expect"; 48 + * import { parseURI } from "./common.js"; 49 + * 50 + * const result = parseURI("icecast://radio.example.com/stream.mp3"); 51 + * expect(result?.host).toBe("radio.example.com"); 52 + * expect(result?.path).toBe("/stream.mp3"); 53 + * expect(result?.httpsUrl).toBe("https://radio.example.com/stream.mp3"); 54 + * ``` 55 + * 56 + * @example Parse icecast URI with port 57 + * ```ts 58 + * import { expect } from "@std/expect"; 59 + * import { parseURI } from "./common.js"; 60 + * 61 + * const result = parseURI("icecast://radio.example.com:8000/live"); 62 + * expect(result?.host).toBe("radio.example.com:8000"); 63 + * expect(result?.httpsUrl).toBe("https://radio.example.com:8000/live"); 64 + * ``` 65 + * 66 + * @example Reject non-icecast URI 67 + * ```ts 68 + * import { expect } from "@std/expect"; 69 + * import { parseURI } from "./common.js"; 70 + * 71 + * const result = parseURI("https://radio.example.com/stream.mp3"); 72 + * expect(result).toBeUndefined(); 73 + * ``` 74 + */ 75 + export function parseURI(uriString) { 76 + try { 77 + const url = new URL(uriString); 78 + if (url.protocol !== `${SCHEME}:`) return undefined; 79 + 80 + return { 81 + host: url.host, 82 + path: url.pathname, 83 + httpsUrl: `https://${url.host}${url.pathname}${url.search}`, 84 + }; 85 + } catch { 86 + return undefined; 87 + } 88 + } 89 + 90 + /** 91 + * Group tracks by host. 92 + * 93 + * @param {Track[]} tracks 94 + * @returns {Record<string, { host: string; tracks: Track[] }>} 95 + */ 96 + export function groupTracksByHost(tracks) { 97 + /** @type {Record<string, { host: string; tracks: Track[] }>} */ 98 + const acc = {}; 99 + 100 + tracks.forEach((track) => { 101 + const parsed = parseURI(track.uri); 102 + if (!parsed) return; 103 + 104 + const { host } = parsed; 105 + if (acc[host]) { 106 + acc[host].tracks.push(track); 107 + } else { 108 + acc[host] = { host, tracks: [track] }; 109 + } 110 + }); 111 + 112 + return acc; 113 + } 114 + 115 + /** 116 + * Group URIs by host. 117 + * 118 + * @param {string[]} uris 119 + * @returns {Record<string, { host: string; uris: string[] }>} 120 + */ 121 + export function groupUrisByHost(uris) { 122 + /** @type {Record<string, { host: string; uris: string[] }>} */ 123 + const acc = {}; 124 + 125 + uris.forEach((uri) => { 126 + const parsed = parseURI(uri); 127 + if (!parsed) return; 128 + 129 + const { host } = parsed; 130 + if (acc[host]) { 131 + acc[host].uris.push(uri); 132 + } else { 133 + acc[host] = { host, uris: [uri] }; 134 + } 135 + }); 136 + 137 + return acc; 138 + } 139 + 140 + /** 141 + * Extract unique hosts from tracks. 142 + * 143 + * @param {Track[]} tracks 144 + * @returns {Record<string, string>} 145 + */ 146 + export function hostsFromTracks(tracks) { 147 + /** @type {Record<string, string>} */ 148 + const acc = {}; 149 + 150 + tracks.forEach((track) => { 151 + const parsed = parseURI(track.uri); 152 + if (!parsed) return; 153 + 154 + const { host } = parsed; 155 + if (acc[host]) return; 156 + acc[host] = host; 157 + }); 158 + 159 + return acc; 160 + } 161 + 162 + /** 163 + * Fetch ICY metadata from an Icecast stream. 164 + * Returns undefined if the stream is unreachable or does not support ICY metadata. 165 + * 166 + * @param {string} httpsUrl 167 + * @returns {Promise<import("@cloudradio/icy-parser").IcyMetadata | undefined>} 168 + */ 169 + export async function fetchMetadata(httpsUrl) { 170 + try { 171 + const parser = new IcyParser(httpsUrl); 172 + return await parser.parseOnce(); 173 + } catch { 174 + return undefined; 175 + } 176 + } 177 + 178 + /** @param {string} uri */ 179 + async function consultStream(uri) { 180 + const parsed = parseURI(uri); 181 + if (!parsed) return false; 182 + const metadata = await fetchMetadata(parsed.httpsUrl); 183 + return metadata !== undefined; 184 + } 185 + 186 + export const consultStreamCached = cachedConsult( 187 + consultStream, 188 + (uri) => new URL(uri.replace(/^icecast:/, "https:")).host, 189 + );
+1
src/components/input/icecast/constants.js
··· 1 + export const SCHEME = "icecast";
+60
src/components/input/icecast/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + import { hostsFromTracks } from "./common.js"; 3 + import { SCHEME } from "./constants.js"; 4 + 5 + /** 6 + * @import {InputActions, InputSchemeProvider} from "~/components/input/types.d.ts" 7 + * @import {ProxiedActions} from "~/common/worker.d.ts" 8 + * @import {Track} from "~/definitions/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // ELEMENT 13 + //////////////////////////////////////////// 14 + 15 + /** 16 + * @implements {ProxiedActions<InputActions>} 17 + * @implements {InputSchemeProvider} 18 + */ 19 + class IcecastInput extends DiffuseElement { 20 + static NAME = "diffuse/input/icecast"; 21 + static WORKER_URL = "components/input/icecast/worker.js"; 22 + 23 + SCHEME = SCHEME; 24 + 25 + constructor() { 26 + super(); 27 + 28 + /** @type {ProxiedActions<InputActions>} */ 29 + this.proxy = this.workerProxy(); 30 + 31 + this.consult = this.proxy.consult; 32 + this.detach = this.proxy.detach; 33 + this.groupConsult = this.proxy.groupConsult; 34 + this.list = this.proxy.list; 35 + this.resolve = this.proxy.resolve; 36 + } 37 + 38 + // 🛠️ 39 + 40 + /** @param {Track[]} tracks */ 41 + sources(tracks) { 42 + const hosts = Object.values(hostsFromTracks(tracks)); 43 + 44 + return hosts.map((host) => ({ 45 + label: host, 46 + uri: `${SCHEME}://${host}`, 47 + })); 48 + } 49 + } 50 + 51 + export default IcecastInput; 52 + 53 + //////////////////////////////////////////// 54 + // REGISTER 55 + //////////////////////////////////////////// 56 + 57 + export const CLASS = IcecastInput; 58 + export const NAME = "di-icecast"; 59 + 60 + customElements.define(NAME, CLASS);
+151
src/components/input/icecast/worker.js
··· 1 + import { ostiary, rpc } from "~/common/worker.js"; 2 + import { detach as detachUtil, groupKey } from "~/components/input/common.js"; 3 + 4 + import { 5 + consultStreamCached, 6 + fetchMetadata, 7 + groupTracksByHost, 8 + groupUrisByHost, 9 + parseURI, 10 + } from "./common.js"; 11 + import { SCHEME } from "./constants.js"; 12 + 13 + /** 14 + * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; 15 + */ 16 + 17 + //////////////////////////////////////////// 18 + // ACTIONS 19 + //////////////////////////////////////////// 20 + 21 + /** 22 + * @type {Actions['consult']} 23 + */ 24 + export async function consult(fileUriOrScheme) { 25 + if (!fileUriOrScheme.includes(":")) { 26 + return { supported: true, consult: "undetermined" }; 27 + } 28 + 29 + const parsed = parseURI(fileUriOrScheme); 30 + if (!parsed) { 31 + return { supported: false, reason: "Invalid Icecast URI" }; 32 + } 33 + 34 + const available = await consultStreamCached(fileUriOrScheme); 35 + return { supported: true, consult: available }; 36 + } 37 + 38 + /** 39 + * @type {Actions['detach']} 40 + */ 41 + export async function detach(args) { 42 + return detachUtil({ 43 + ...args, 44 + 45 + inputScheme: SCHEME, 46 + handleFileUri: ({ fileURI, tracks }) => { 47 + const result = parseURI(fileURI); 48 + if (!result) return tracks; 49 + 50 + const groups = groupTracksByHost(tracks); 51 + delete groups[result.host]; 52 + 53 + return Object.values(groups).map((g) => g.tracks).flat(1); 54 + }, 55 + }); 56 + } 57 + 58 + /** 59 + * @type {Actions['groupConsult']} 60 + */ 61 + export async function groupConsult(uris) { 62 + const groups = groupUrisByHost(uris); 63 + 64 + const promises = Object.entries(groups).map( 65 + async ([_hostId, { host, uris }]) => { 66 + const testUri = uris[0]; 67 + const available = testUri ? await consultStreamCached(testUri) : false; 68 + 69 + /** @type {ConsultGrouping} */ 70 + const grouping = available 71 + ? { available, scheme: SCHEME, uris } 72 + : { available, reason: "Stream unreachable", scheme: SCHEME, uris }; 73 + 74 + return { 75 + key: groupKey(SCHEME, host), 76 + grouping, 77 + }; 78 + }, 79 + ); 80 + 81 + const entries = (await Promise.all(promises)).map((entry) => [ 82 + entry.key, 83 + entry.grouping, 84 + ]); 85 + 86 + return Object.fromEntries(entries); 87 + } 88 + 89 + /** 90 + * @type {Actions['list']} 91 + */ 92 + export async function list(cachedTracks = []) { 93 + const refreshed = await Promise.all( 94 + cachedTracks.map(async (track) => { 95 + const parsed = parseURI(track.uri); 96 + if (!parsed) return track; 97 + 98 + const metadata = await fetchMetadata(parsed.httpsUrl); 99 + if (!metadata) return track; 100 + 101 + return { 102 + ...track, 103 + kind: /** @type {"stream"} */ ("stream"), 104 + tags: { 105 + ...track.tags, 106 + title: metadata.name ?? track.tags?.title, 107 + genres: metadata.genre ? [metadata.genre] : track.tags?.genres, 108 + }, 109 + stats: { 110 + ...track.stats, 111 + // IcyMetadata.bitrate is in kbps; stats.bitrate is in bps 112 + bitrate: metadata.bitrate 113 + ? metadata.bitrate * 1000 114 + : track.stats?.bitrate, 115 + }, 116 + }; 117 + }), 118 + ); 119 + 120 + return refreshed; 121 + } 122 + 123 + /** 124 + * @type {Actions['resolve']} 125 + */ 126 + export async function resolve({ uri }) { 127 + const parsed = parseURI(uri); 128 + if (!parsed) return undefined; 129 + 130 + const expiresInSeconds = 60 * 60 * 24 * 365; // 1 year 131 + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 132 + 133 + return { 134 + url: parsed.httpsUrl, 135 + expiresAt: expiresAtSeconds, 136 + }; 137 + } 138 + 139 + //////////////////////////////////////////// 140 + // ⚡️ 141 + //////////////////////////////////////////// 142 + 143 + ostiary((context) => { 144 + rpc(context, { 145 + consult, 146 + detach, 147 + groupConsult, 148 + list, 149 + resolve, 150 + }); 151 + });
+2
src/components/orchestrator/input/element.js
··· 2 2 3 3 import "~/components/configurator/input/element.js"; 4 4 import "~/components/input/https/element.js"; 5 + import "~/components/input/icecast/element.js"; 5 6 import "~/components/input/local/element.js"; 6 7 import "~/components/input/opensubsonic/element.js"; 7 8 import "~/components/input/s3/element.js"; ··· 63 64 return html` 64 65 <dc-input> 65 66 <di-https></di-https> 67 + <di-icecast></di-icecast> 66 68 <di-local></di-local> 67 69 <di-opensubsonic></di-opensubsonic> 68 70 <di-s3></di-s3>
+1 -1
src/definitions/output/track.json
··· 13 13 "ephemeral": { "type": "boolean" }, 14 14 "kind": { 15 15 "type": "string", 16 - "enum": ["audiobook", "miscellaneous", "music", "placeholder", "podcast"] 16 + "enum": ["audiobook", "miscellaneous", "music", "placeholder", "podcast", "stream"] 17 17 }, 18 18 "stats": { 19 19 "type": "ref",