diff --git a/deno.jsonc b/deno.jsonc index cd5fa1de..9c6d1f3d 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -13,6 +13,7 @@ "@automerge/automerge": "npm:@automerge/automerge@^3.2.3", "@awesome.me/webawesome": "npm:@awesome.me/webawesome@^3.2.1", "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.9.5", + "@cloudradio/icy-parser": "jsr:@cloudradio/icy-parser@^1.0.2", "@char/cbor": "jsr:@char/cbor@^0.1.4", "@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.20.0", "@codemirror/lang-css": "npm:@codemirror/lang-css@^6.3.1", @@ -99,6 +100,10 @@ "./components/input/https/constants.js": "./src/components/input/https/constants.js", "./components/input/https/element.js": "./src/components/input/https/element.js", "./components/input/https/worker.js": "./src/components/input/https/worker.js", + "./components/input/icecast/common.js": "./src/components/input/icecast/common.js", + "./components/input/icecast/constants.js": "./src/components/input/icecast/constants.js", + "./components/input/icecast/element.js": "./src/components/input/icecast/element.js", + "./components/input/icecast/worker.js": "./src/components/input/icecast/worker.js", "./components/input/s3/common.js": "./src/components/input/s3/common.js", "./components/input/s3/constants.js": "./src/components/input/s3/constants.js", "./components/input/s3/element.js": "./src/components/input/s3/element.js", diff --git a/src/components/input/icecast/common.js b/src/components/input/icecast/common.js new file mode 100644 index 00000000..4e9db644 --- /dev/null +++ b/src/components/input/icecast/common.js @@ -0,0 +1,189 @@ +import { IcyParser } from "@cloudradio/icy-parser"; +import { cachedConsult } from "~/components/input/common.js"; + +import { SCHEME } from "./constants.js"; + +/** + * @import {Track} from "~/definitions/types.d.ts" + */ + +/** + * Build an icecast:// URI from an HTTPS URL. + * + * @param {string} httpsUrl + * @returns {string} + * + * @example Build URI from HTTPS URL + * ```ts + * import { expect } from "@std/expect"; + * import { buildURI } from "./common.js"; + * + * const uri = buildURI("https://radio.example.com/stream.mp3"); + * expect(uri).toBe("icecast://radio.example.com/stream.mp3"); + * ``` + * + * @example Build URI with port + * ```ts + * import { expect } from "@std/expect"; + * import { buildURI } from "./common.js"; + * + * const uri = buildURI("https://radio.example.com:8000/live"); + * expect(uri).toBe("icecast://radio.example.com:8000/live"); + * ``` + */ +export function buildURI(httpsUrl) { + const url = new URL(httpsUrl); + return `${SCHEME}://${url.host}${url.pathname}${url.search}`; +} + +/** + * Parse an icecast:// URI. + * + * @param {string} uriString + * @returns {{ host: string; path: string; httpsUrl: string } | undefined} + * + * @example Parse a valid icecast URI + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("icecast://radio.example.com/stream.mp3"); + * expect(result?.host).toBe("radio.example.com"); + * expect(result?.path).toBe("/stream.mp3"); + * expect(result?.httpsUrl).toBe("https://radio.example.com/stream.mp3"); + * ``` + * + * @example Parse icecast URI with port + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("icecast://radio.example.com:8000/live"); + * expect(result?.host).toBe("radio.example.com:8000"); + * expect(result?.httpsUrl).toBe("https://radio.example.com:8000/live"); + * ``` + * + * @example Reject non-icecast URI + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("https://radio.example.com/stream.mp3"); + * expect(result).toBeUndefined(); + * ``` + */ +export function parseURI(uriString) { + try { + const url = new URL(uriString); + if (url.protocol !== `${SCHEME}:`) return undefined; + + return { + host: url.host, + path: url.pathname, + httpsUrl: `https://${url.host}${url.pathname}${url.search}`, + }; + } catch { + return undefined; + } +} + +/** + * Group tracks by host. + * + * @param {Track[]} tracks + * @returns {Record} + */ +export function groupTracksByHost(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const { host } = parsed; + if (acc[host]) { + acc[host].tracks.push(track); + } else { + acc[host] = { host, tracks: [track] }; + } + }); + + return acc; +} + +/** + * Group URIs by host. + * + * @param {string[]} uris + * @returns {Record} + */ +export function groupUrisByHost(uris) { + /** @type {Record} */ + const acc = {}; + + uris.forEach((uri) => { + const parsed = parseURI(uri); + if (!parsed) return; + + const { host } = parsed; + if (acc[host]) { + acc[host].uris.push(uri); + } else { + acc[host] = { host, uris: [uri] }; + } + }); + + return acc; +} + +/** + * Extract unique hosts from tracks. + * + * @param {Track[]} tracks + * @returns {Record} + */ +export function hostsFromTracks(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const { host } = parsed; + if (acc[host]) return; + acc[host] = host; + }); + + return acc; +} + +/** + * Fetch ICY metadata from an Icecast stream. + * Returns undefined if the stream is unreachable or does not support ICY metadata. + * + * @param {string} httpsUrl + * @returns {Promise} + */ +export async function fetchMetadata(httpsUrl) { + try { + const parser = new IcyParser(httpsUrl); + return await parser.parseOnce(); + } catch { + return undefined; + } +} + +/** @param {string} uri */ +async function consultStream(uri) { + const parsed = parseURI(uri); + if (!parsed) return false; + const metadata = await fetchMetadata(parsed.httpsUrl); + return metadata !== undefined; +} + +export const consultStreamCached = cachedConsult( + consultStream, + (uri) => new URL(uri.replace(/^icecast:/, "https:")).host, +); diff --git a/src/components/input/icecast/constants.js b/src/components/input/icecast/constants.js new file mode 100644 index 00000000..c33e64e9 --- /dev/null +++ b/src/components/input/icecast/constants.js @@ -0,0 +1 @@ +export const SCHEME = "icecast"; diff --git a/src/components/input/icecast/element.js b/src/components/input/icecast/element.js new file mode 100644 index 00000000..0fcbf97f --- /dev/null +++ b/src/components/input/icecast/element.js @@ -0,0 +1,60 @@ +import { DiffuseElement } from "~/common/element.js"; +import { hostsFromTracks } from "./common.js"; +import { SCHEME } from "./constants.js"; + +/** + * @import {InputActions, InputSchemeProvider} from "~/components/input/types.d.ts" + * @import {ProxiedActions} from "~/common/worker.d.ts" + * @import {Track} from "~/definitions/types.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + * @implements {InputSchemeProvider} + */ +class IcecastInput extends DiffuseElement { + static NAME = "diffuse/input/icecast"; + static WORKER_URL = "components/input/icecast/worker.js"; + + SCHEME = SCHEME; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + this.proxy = this.workerProxy(); + + this.consult = this.proxy.consult; + this.detach = this.proxy.detach; + this.groupConsult = this.proxy.groupConsult; + this.list = this.proxy.list; + this.resolve = this.proxy.resolve; + } + + // 🛠️ + + /** @param {Track[]} tracks */ + sources(tracks) { + const hosts = Object.values(hostsFromTracks(tracks)); + + return hosts.map((host) => ({ + label: host, + uri: `${SCHEME}://${host}`, + })); + } +} + +export default IcecastInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = IcecastInput; +export const NAME = "di-icecast"; + +customElements.define(NAME, CLASS); diff --git a/src/components/input/icecast/worker.js b/src/components/input/icecast/worker.js new file mode 100644 index 00000000..68d48cce --- /dev/null +++ b/src/components/input/icecast/worker.js @@ -0,0 +1,151 @@ +import { ostiary, rpc } from "~/common/worker.js"; +import { detach as detachUtil, groupKey } from "~/components/input/common.js"; + +import { + consultStreamCached, + fetchMetadata, + groupTracksByHost, + groupUrisByHost, + parseURI, +} from "./common.js"; +import { SCHEME } from "./constants.js"; + +/** + * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {Actions['consult']} + */ +export async function consult(fileUriOrScheme) { + if (!fileUriOrScheme.includes(":")) { + return { supported: true, consult: "undetermined" }; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) { + return { supported: false, reason: "Invalid Icecast URI" }; + } + + const available = await consultStreamCached(fileUriOrScheme); + return { supported: true, consult: available }; +} + +/** + * @type {Actions['detach']} + */ +export async function detach(args) { + return detachUtil({ + ...args, + + inputScheme: SCHEME, + handleFileUri: ({ fileURI, tracks }) => { + const result = parseURI(fileURI); + if (!result) return tracks; + + const groups = groupTracksByHost(tracks); + delete groups[result.host]; + + return Object.values(groups).map((g) => g.tracks).flat(1); + }, + }); +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(uris) { + const groups = groupUrisByHost(uris); + + const promises = Object.entries(groups).map( + async ([_hostId, { host, uris }]) => { + const testUri = uris[0]; + const available = testUri ? await consultStreamCached(testUri) : false; + + /** @type {ConsultGrouping} */ + const grouping = available + ? { available, scheme: SCHEME, uris } + : { available, reason: "Stream unreachable", scheme: SCHEME, uris }; + + return { + key: groupKey(SCHEME, host), + grouping, + }; + }, + ); + + const entries = (await Promise.all(promises)).map((entry) => [ + entry.key, + entry.grouping, + ]); + + return Object.fromEntries(entries); +} + +/** + * @type {Actions['list']} + */ +export async function list(cachedTracks = []) { + const refreshed = await Promise.all( + cachedTracks.map(async (track) => { + const parsed = parseURI(track.uri); + if (!parsed) return track; + + const metadata = await fetchMetadata(parsed.httpsUrl); + if (!metadata) return track; + + return { + ...track, + kind: /** @type {"stream"} */ ("stream"), + tags: { + ...track.tags, + title: metadata.name ?? track.tags?.title, + genres: metadata.genre ? [metadata.genre] : track.tags?.genres, + }, + stats: { + ...track.stats, + // IcyMetadata.bitrate is in kbps; stats.bitrate is in bps + bitrate: metadata.bitrate + ? metadata.bitrate * 1000 + : track.stats?.bitrate, + }, + }; + }), + ); + + return refreshed; +} + +/** + * @type {Actions['resolve']} + */ +export async function resolve({ uri }) { + const parsed = parseURI(uri); + if (!parsed) return undefined; + + const expiresInSeconds = 60 * 60 * 24 * 365; // 1 year + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; + + return { + url: parsed.httpsUrl, + expiresAt: expiresAtSeconds, + }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/components/orchestrator/input/element.js b/src/components/orchestrator/input/element.js index 05d777e2..af2d7f02 100644 --- a/src/components/orchestrator/input/element.js +++ b/src/components/orchestrator/input/element.js @@ -2,6 +2,7 @@ import { DiffuseElement } from "~/common/element.js"; import "~/components/configurator/input/element.js"; import "~/components/input/https/element.js"; +import "~/components/input/icecast/element.js"; import "~/components/input/local/element.js"; import "~/components/input/opensubsonic/element.js"; import "~/components/input/s3/element.js"; @@ -63,6 +64,7 @@ class InputOrchestrator extends DiffuseElement { return html` + diff --git a/src/definitions/output/track.json b/src/definitions/output/track.json index 94d06890..8222c660 100644 --- a/src/definitions/output/track.json +++ b/src/definitions/output/track.json @@ -13,7 +13,7 @@ "ephemeral": { "type": "boolean" }, "kind": { "type": "string", - "enum": ["audiobook", "miscellaneous", "music", "placeholder", "podcast"] + "enum": ["audiobook", "miscellaneous", "music", "placeholder", "podcast", "stream"] }, "stats": { "type": "ref",