diff --git a/.gitignore b/.gitignore index 58a6415e..31d8d3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ node_modules /_site /_vendor /dist +/docs/plans/* /src/definitions/types/ /vendor diff --git a/deno.jsonc b/deno.jsonc index 88a1f8e3..4b01f7eb 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -43,7 +43,7 @@ "webamp": "npm:webamp@^2.2.0", // Paths - "@src/": "./src/", + "@testing/": "./src/testing/", "@tests/": "./tests/", "@common/": "./src/common/", @@ -70,6 +70,7 @@ "exports": { // .js "./common/facets/foundation.js": "./src/common/facets/foundation.js", + "./common/facets/utils.js": "./src/common/facets/utils.js", "./common/element.js": "./src/common/element.js", "./common/signal.js": "./src/common/signal.js", "./common/worker.js": "./src/common/worker.js", @@ -88,6 +89,10 @@ "./components/input/opensubsonic/constants.js": "./src/components/input/opensubsonic/constants.js", "./components/input/opensubsonic/element.js": "./src/components/input/opensubsonic/element.js", "./components/input/opensubsonic/worker.js": "./src/components/input/opensubsonic/worker.js", + "./components/input/https/common.js": "./src/components/input/https/common.js", + "./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/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", @@ -115,6 +120,10 @@ "./components/processor/search/element.js": "./src/components/processor/search/element.js", "./components/processor/search/worker.js": "./src/components/processor/search/worker.js", "./components/transformer/output/base.js": "./src/components/transformer/output/base.js", + "./components/transformer/output/bytes/automerge/constants.js": "./src/components/transformer/output/bytes/automerge/constants.js", + "./components/transformer/output/bytes/automerge/element.js": "./src/components/transformer/output/bytes/automerge/element.js", + "./components/transformer/output/bytes/automerge/utils.js": "./src/components/transformer/output/bytes/automerge/utils.js", + "./components/transformer/output/bytes/json/element.js": "./src/components/transformer/output/bytes/json/element.js", "./components/transformer/output/refiner/default/element.js": "./src/components/transformer/output/refiner/default/element.js", "./components/transformer/output/string/json/element.js": "./src/components/transformer/output/string/json/element.js", @@ -127,7 +136,6 @@ "./components/input/opensubsonic/types.d.ts": "./src/components/input/opensubsonic/types.d.ts", "./components/input/s3/types.d.ts": "./src/components/input/s3/types.d.ts", "./components/input/types.d.ts": "./src/components/input/types.d.ts", - "./components/orchestrator/auto-queue/types.d.ts": "./src/components/orchestrator/auto-queue/types.d.ts", "./components/orchestrator/process-tracks/types.d.ts": "./src/components/orchestrator/process-tracks/types.d.ts", "./components/orchestrator/scoped-tracks/types.d.ts": "./src/components/orchestrator/scoped-tracks/types.d.ts", "./components/output/polymorphic/indexed-db/types.d.ts": "./src/components/output/polymorphic/indexed-db/types.d.ts", @@ -135,6 +143,7 @@ "./components/processor/artwork/types.d.ts": "./src/components/processor/artwork/types.d.ts", "./components/processor/metadata/types.d.ts": "./src/components/processor/metadata/types.d.ts", "./components/processor/search/types.d.ts": "./src/components/processor/search/types.d.ts", + "./components/transformer/output/bytes/automerge/types.d.ts": "./src/components/transformer/output/bytes/automerge/types.d.ts", "./definitions/types.d.ts": "./src/definitions/types.d.ts", // .ts @@ -160,6 +169,10 @@ "description": "Run and serve the site for development", "command": "deno task lume -s", }, + "test": { + "description": "Run tests", + "command": "deno test -A --doc --ignore=README.md", + }, }, "compilerOptions": { "checkJs": true, diff --git a/src/components/input/https/common.js b/src/components/input/https/common.js new file mode 100644 index 00000000..dd20ee75 --- /dev/null +++ b/src/components/input/https/common.js @@ -0,0 +1,212 @@ +/** + * @import {Track} from "@definitions/types.d.ts" + */ + +/** + * Group tracks by host. + * + * @param {Track[]} tracks + * @returns {Record} + * + * @example Group tracks by domain + * ```ts + * import { expect } from "@std/expect"; + * import { groupTracksByHost } from "./common.js"; + * import type { Track } from "@definitions/types.d.ts"; + * + * const tracks: Track[] = [ + * { + * $type: "sh.diffuse.output.track", + * id: "1", + * uri: "https://example.com/a.mp3", + * }, + * { + * $type: "sh.diffuse.output.track", + * id: "2", + * uri: "https://cdn.example.com/b.mp3", + * }, + * { + * $type: "sh.diffuse.output.track", + * id: "3", + * uri: "https://example.com/c.mp3", + * }, + * ]; + * + * const groups = groupTracksByHost(tracks); + * expect(Object.keys(groups).length).toBe(2); + * expect(groups["example.com"].tracks.length).toBe(2); + * expect(groups["cdn.example.com"].tracks.length).toBe(1); + * ``` + * + * @example Group tracks by host including port + * ```ts + * import { expect } from "@std/expect"; + * import { groupTracksByHost } from "./common.js"; + * import type { Track } from "@definitions/types.d.ts"; + * + * const tracks: Track[] = [ + * { + * $type: "sh.diffuse.output.track", + * id: "1", + * uri: "https://example.com/a.mp3", + * }, + * { + * $type: "sh.diffuse.output.track", + * id: "2", + * uri: "https://example.com:8443/b.mp3", + * }, + * ]; + * + * const groups = groupTracksByHost(tracks); + * expect(Object.keys(groups).length).toBe(2); + * expect(groups["example.com"].tracks.length).toBe(1); + * expect(groups["example.com:8443"].tracks.length).toBe(1); + * ``` + */ +export function groupTracksByHost(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const host = parsed.host; + + if (acc[host]) { + acc[host].tracks.push(track); + } else { + acc[host] = { host, tracks: [track] }; + } + }); + + return acc; +} + +/** + * Extract unique hosts from tracks. + * + * @param {Track[]} tracks + * @returns {Record} + * + * @example Extract unique hosts + * ```ts + * import { expect } from "@std/expect"; + * import { hostsFromTracks } from "./common.js"; + * import type { Track } from "@definitions/types.d.ts"; + * + * const tracks: Track[] = [ + * { + * $type: "sh.diffuse.output.track", + * id: "1", + * uri: "https://example.com/a.mp3", + * }, + * { + * $type: "sh.diffuse.output.track", + * id: "2", + * uri: "https://example.com/b.mp3", + * }, + * { + * $type: "sh.diffuse.output.track", + * id: "3", + * uri: "https://cdn.example.com/c.mp3", + * }, + * ]; + * + * const hosts = hostsFromTracks(tracks); + * expect(Object.keys(hosts).length).toBe(2); + * expect(hosts["example.com"]).toBe("example.com"); + * expect(hosts["cdn.example.com"]).toBe("cdn.example.com"); + * ``` + */ +export function hostsFromTracks(tracks) { + /** @type {Record} */ + const acc = {}; + + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + const host = parsed.host; + if (acc[host]) return; + + acc[host] = host; + }); + + return acc; +} + +/** + * Parse an HTTPS URI. + * Validates and extracts components from a standard HTTPS URL. + * + * @param {string} uriString + * @returns {{ url: string; domain: string; path: string; host: string } | undefined} + * + * @example Parse a valid HTTPS URI + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("https://example.com/song.mp3"); + * expect(result?.domain).toBe("example.com"); + * expect(result?.host).toBe("example.com"); + * expect(result?.path).toBe("/song.mp3"); + * expect(result?.url).toBe("https://example.com/song.mp3"); + * ``` + * + * @example Parse HTTPS URI with port + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("https://example.com:8443/audio.mp3"); + * expect(result?.domain).toBe("example.com"); + * expect(result?.host).toBe("example.com:8443"); + * expect(result?.path).toBe("/audio.mp3"); + * ``` + * + * @example Parse HTTPS URI with query parameters + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("https://example.com/song.mp3?token=abc123"); + * expect(result?.domain).toBe("example.com"); + * expect(result?.path).toBe("/song.mp3"); + * expect(result?.url).toContain("token=abc123"); + * ``` + * + * @example Reject non-HTTPS URI + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("http://example.com/song.mp3"); + * expect(result).toBeUndefined(); + * ``` + * + * @example Reject invalid URI + * ```ts + * import { expect } from "@std/expect"; + * import { parseURI } from "./common.js"; + * + * const result = parseURI("not-a-url"); + * expect(result).toBeUndefined(); + * ``` + */ +export function parseURI(uriString) { + try { + const url = new URL(uriString); + if (url.protocol !== "https:") return undefined; + + return { + url: url.href, + domain: url.hostname, + host: url.host, // includes port if present + path: url.pathname, + }; + } catch { + return undefined; + } +} diff --git a/src/components/input/https/constants.js b/src/components/input/https/constants.js new file mode 100644 index 00000000..df0c7c72 --- /dev/null +++ b/src/components/input/https/constants.js @@ -0,0 +1 @@ +export const SCHEME = "https"; diff --git a/src/components/input/https/element.js b/src/components/input/https/element.js new file mode 100644 index 00000000..06a18eb6 --- /dev/null +++ b/src/components/input/https/element.js @@ -0,0 +1,60 @@ +import { DiffuseElement } from "@common/element.js"; +import { SCHEME } from "./constants.js"; +import { hostsFromTracks } from "./common.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 HttpsInput extends DiffuseElement { + static NAME = "diffuse/input/https"; + static WORKER_URL = "components/input/https/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: `https://${host}`, + })); + } +} + +export default HttpsInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = HttpsInput; +export const NAME = "di-https"; + +customElements.define(NAME, CLASS); diff --git a/src/components/input/https/worker.js b/src/components/input/https/worker.js new file mode 100644 index 00000000..ea6adb7d --- /dev/null +++ b/src/components/input/https/worker.js @@ -0,0 +1,163 @@ +import { ostiary, rpc } from "@common/worker.js"; +import { + detach as detachUtil, + groupKeyHash, +} from "@components/input/common.js"; + +import { groupTracksByHost, 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 HTTPS URL" }; + } + + // Ping the URL to check if it's reachable + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + const response = await fetch(parsed.url, { + method: "HEAD", + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return { supported: true, consult: response.ok }; + } catch (error) { + return { supported: true, consult: false }; + } +} + +/** + * @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 did = result.host; + const groups = groupTracksByHost(tracks); + + delete groups[did]; + + return Object.values(groups).map((a) => a.tracks).flat(1); + }, + }); +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(tracks) { + const groups = groupTracksByHost(tracks); + + const promises = Object.entries(groups).map( + async ([_domainId, { host, tracks }]) => { + // Pick one track to test reachability + const testTrack = tracks[0]; + let available = false; + + if (testTrack) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + const response = await fetch(testTrack.uri, { + method: "HEAD", + signal: controller.signal, + }); + + clearTimeout(timeoutId); + available = response.ok; + } catch { + available = false; + } + } + + /** @type {ConsultGrouping} */ + const grouping = available + ? { available, scheme: SCHEME, tracks } + : { available, reason: "Host unreachable", scheme: SCHEME, tracks }; + + return { + key: await groupKeyHash(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 = []) { + // HTTPS input doesn't discover tracks automatically. + // It only manages URLs that were added manually. + // Just return the cached tracks that match our scheme. + return cachedTracks.filter((track) => { + const parsed = parseURI(track.uri); + return !!parsed; + }); +} + +/** + * @type {Actions['resolve']} + */ +export async function resolve({ method, uri }) { + const parsed = parseURI(uri); + if (!parsed) return undefined; + + // HTTPS URLs don't need resolution - they're already accessible. + // Just return the URL as-is with a far-future expiration. + const expiresInSeconds = 60 * 60 * 24 * 365; // 1 year + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; + + return { + url: parsed.url, + expiresAt: expiresAtSeconds, + }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + // Setup RPC + + rpc(context, { + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/index.vto b/src/index.vto index 4f60978f..d4da981a 100644 --- a/src/index.vto +++ b/src/index.vto @@ -48,10 +48,10 @@ input: title: "Opensubsonic" desc: > Add any (open)subsonic server. - - title: "HTTPS (Raw)" + - url: "components/input/https/element.js" + title: "HTTPS" desc: > - Enables usage of tracks with a HTTPS URI. - todo: true + HTTPS URLs to audio files or streams. - title: "HTTPS (JSON)" desc: > Generate tracks based on HTTPS servers that provide JSON (directory) listings. diff --git a/src/testing/index.vto b/src/testing/index.vto index 684c2f35..d921c5bf 100644 --- a/src/testing/index.vto +++ b/src/testing/index.vto @@ -11,7 +11,7 @@ styles: { "imports": { "@components/": "./components/", - "@src/": "./" + "@testing/": "./testing/" } } diff --git a/tests/components/engine/queue/test.ts b/tests/components/engine/queue/test.ts index 6669f53a..f43a01d1 100644 --- a/tests/components/engine/queue/test.ts +++ b/tests/components/engine/queue/test.ts @@ -2,7 +2,7 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { testWeb } from "@tests/common/index.ts"; -import { tracks } from "@src/testing/sample/tracks.js"; +import { tracks } from "@testing/sample/tracks.js"; describe("components/engine/queue", () => { it("adds tracks", async () => { @@ -12,7 +12,7 @@ describe("components/engine/queue", () => { document.body.append(engine); - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await engine.add({ tracks }); return engine.future(); @@ -31,7 +31,7 @@ describe("components/engine/queue", () => { document.body.append(engine); - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await engine.supply({ tracks }); await engine.fill({ amount: 1, shuffled: false }); @@ -51,7 +51,7 @@ describe("components/engine/queue", () => { document.body.append(engine); - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await engine.add({ tracks }); await engine.shift(); @@ -72,7 +72,7 @@ describe("components/engine/queue", () => { document.body.append(engine); - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await engine.add({ tracks }); await engine.shift(); diff --git a/tests/components/input/https/test.ts b/tests/components/input/https/test.ts new file mode 100644 index 00000000..58897ce7 --- /dev/null +++ b/tests/components/input/https/test.ts @@ -0,0 +1,180 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; +import type { Track } from "@definitions/types.d.ts"; + +describe("components/input/https", () => { + it("resolves HTTPS URI to same URL", async () => { + const resolved = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + return await input.resolve({ + uri: "https://example.com/audio.mp3", + }); + }); + + if (resolved && "url" in resolved) { + expect(resolved.url).toBe("https://example.com/audio.mp3"); + expect(resolved.expiresAt).toBeGreaterThan(Date.now() / 1000); + } + }); + + it("lists only HTTPS tracks from mixed collection", async () => { + const tracks = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + const cachedTracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "1", + uri: "https://example.com/a.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "2", + uri: "s3://bucket/b.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "3", + uri: "https://example.com/c.mp3", + }, + ]; + + return await input.list(cachedTracks); + }); + + expect(tracks.length).toBe(2); + expect(tracks[0].id).toBe("1"); + expect(tracks[1].id).toBe("3"); + }); + + it("provides sources list from tracks", async () => { + const sources = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "1", + uri: "https://example.com/a.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "2", + uri: "https://cdn.example.com/b.mp3", + }, + ]; + + return input.sources(tracks); + }); + + expect(sources.length).toBe(2); + expect(sources[0].label).toBeDefined(); + expect(sources[0].uri).toContain("https://"); + expect(sources[1].label).toBeDefined(); + expect(sources[1].uri).toContain("https://"); + }); + + it("consult returns undetermined for scheme only", async () => { + const result = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + return await input.consult("https"); + }); + + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("undetermined"); + } + }); + + it("detaches all HTTPS tracks when given scheme", async () => { + const remaining = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "1", + uri: "https://example.com/a.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "2", + uri: "https://example.com/c.mp3", + }, + ]; + + return await input.detach({ + fileUriOrScheme: "https", + tracks, + }); + }); + + expect(remaining.length).toBe(0); + }); + + it("detaches tracks from specific domain", async () => { + const remaining = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "1", + uri: "https://example.com/a.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "2", + uri: "https://cdn.example.com/b.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "3", + uri: "https://example.com/c.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "4", + uri: "https://cdn.example.com/d.mp3", + }, + ]; + + return await input.detach({ + fileUriOrScheme: "https://example.com", + tracks, + }); + }); + + expect(remaining.length).toBe(2); + expect(remaining[0].id).toBe("2"); + expect(remaining[1].id).toBe("4"); + }); + + it("has correct SCHEME property", async () => { + const scheme = await testWeb(async () => { + const HttpsInput = await import("@components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + + return input.SCHEME; + }); + + expect(scheme).toBe("https"); + }); +}); diff --git a/tests/components/processor/search/test.ts b/tests/components/processor/search/test.ts index 1c23f68a..8d319aa5 100644 --- a/tests/components/processor/search/test.ts +++ b/tests/components/processor/search/test.ts @@ -2,7 +2,7 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { testWeb } from "@tests/common/index.ts"; -import { trackA, trackB } from "@src/testing/sample/tracks.js"; +import { trackA, trackB } from "@testing/sample/tracks.js"; describe("components/processor/search", () => { it("finds tracks by album", async () => { @@ -15,7 +15,7 @@ describe("components/processor/search", () => { document.body.append(processor); // Add sample tracks to the supply first - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await processor.supply({ tracks }); // Search for a specific term @@ -35,7 +35,7 @@ describe("components/processor/search", () => { document.body.append(processor); // Add sample tracks to the supply first - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await processor.supply({ tracks }); // Search for a specific term @@ -55,7 +55,7 @@ describe("components/processor/search", () => { document.body.append(processor); // Add sample tracks to the supply first - const { tracks } = await import("@src/testing/sample/tracks.js"); + const { tracks } = await import("@testing/sample/tracks.js"); await processor.supply({ tracks }); // Search for a specific term