diff --git a/src/components/configurator/input/element.js b/src/components/configurator/input/element.js index 4b8fc68a..f9a889dd 100644 --- a/src/components/configurator/input/element.js +++ b/src/components/configurator/input/element.js @@ -35,6 +35,7 @@ class InputConfigurator extends DiffuseElement { this.resolve = proxy.resolve; this.cache = proxy.cache; + this.cacheBlob = proxy.cacheBlob; this.listCached = proxy.listCached; this.removeFromCache = proxy.removeFromCache; } diff --git a/src/components/configurator/input/types.d.ts b/src/components/configurator/input/types.d.ts index 12064c57..3b08e6a4 100644 --- a/src/components/configurator/input/types.d.ts +++ b/src/components/configurator/input/types.d.ts @@ -2,6 +2,7 @@ import type {InputActions} from "~/components/input/types.d.ts" export type Actions = InputActions & { cache(uris: string[]): Promise + cacheBlob(blob: Blob): Promise listCached(): Promise removeFromCache(uris: string[]): Promise }; diff --git a/src/components/configurator/input/worker.js b/src/components/configurator/input/worker.js index dd3a51ec..70861bd2 100644 --- a/src/components/configurator/input/worker.js +++ b/src/components/configurator/input/worker.js @@ -1,5 +1,6 @@ import * as IDB from "idb-keyval"; import * as URI from "fast-uri"; +import * as Cid from "~/common/cid.js"; import { groupTracksPerScheme, groupUrisPerScheme } from "~/common/utils.js"; import { ostiary, rpc, workerProxy } from "~/common/worker.js"; @@ -17,9 +18,6 @@ import { ostiary, rpc, workerProxy } from "~/common/worker.js"; const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/"; -/** @type {Map} */ -const blobUrls = new Map(); - //////////////////////////////////////////// // INPUT ACTIONS //////////////////////////////////////////// @@ -58,8 +56,8 @@ export async function consult({ data, ports }) { * @type {ActionsWithTunnel['detach']} */ export async function detach({ data, ports }) { - const cachedTracks = data.tracks; - const groups = groupTracks(cachedTracks, ports); + const currentTracks = data.tracks; + const groups = groupTracks(currentTracks, ports); const promises = Object.entries(groups).map( async ([scheme, tracksGroup]) => { @@ -154,19 +152,6 @@ export async function list({ data, ports }) { export async function resolve({ data, ports }) { const uri = data.uri; - const cachedBlob = - /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri)); - if (cachedBlob) { - let blobUrl = blobUrls.get(uri); - - if (!blobUrl) { - blobUrl = URL.createObjectURL(cachedBlob); - blobUrls.set(uri, blobUrl); - } - - return { expiresAt: Infinity, url: blobUrl }; - } - const scheme = uri.split(":", 1)[0]; const input = grabInput(scheme, ports); if (!input) return undefined; @@ -213,14 +198,22 @@ export async function listCached() { export async function removeFromCache({ data }) { const uris = data; - await Promise.all(uris.map(async (uri) => { - const blobUrl = blobUrls.get(uri); - if (blobUrl) { - URL.revokeObjectURL(blobUrl); - blobUrls.delete(uri); - } - await IDB.del(CACHE_KEY_PREFIX + uri); - })); + await Promise.all(uris.map((uri) => IDB.del(CACHE_KEY_PREFIX + uri))); +} + +/** + * @type {ActionsWithTunnel['cacheBlob']} + */ +export async function cacheBlob({ data }) { + const blob = data; + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const cid = await Cid.create(0x55, bytes); + const uri = `ephemeral+cache://${cid}`; + if (await IDB.get(CACHE_KEY_PREFIX + uri) === undefined) { + await IDB.set(CACHE_KEY_PREFIX + uri, blob); + } + return uri; } //////////////////////////////////////////// @@ -237,6 +230,7 @@ ostiary((context) => { resolve, cache, + cacheBlob, listCached, removeFromCache, }); diff --git a/src/components/input/ephemeral-cache/constants.js b/src/components/input/ephemeral-cache/constants.js new file mode 100644 index 00000000..a5168560 --- /dev/null +++ b/src/components/input/ephemeral-cache/constants.js @@ -0,0 +1,2 @@ +export const SCHEME = "ephemeral+cache"; +export const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/"; diff --git a/src/components/input/ephemeral-cache/element.js b/src/components/input/ephemeral-cache/element.js new file mode 100644 index 00000000..9643fbc9 --- /dev/null +++ b/src/components/input/ephemeral-cache/element.js @@ -0,0 +1,58 @@ +import { DiffuseElement } from "~/common/element.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 EphemeralCacheInput extends DiffuseElement { + static NAME = "diffuse/input/ephemeral-cache"; + static WORKER_URL = "components/input/ephemeral-cache/worker.js"; + + SCHEME = SCHEME; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + const proxy = this.workerProxy(); + + this.artwork = proxy.artwork; + this.consult = proxy.consult; + this.detach = proxy.detach; + this.groupConsult = proxy.groupConsult; + this.list = proxy.list; + this.resolve = proxy.resolve; + } + + // 🛠️ + + /** @param {Track[]} tracks */ + sources(tracks) { + return tracks.map((t) => ({ + label: t.uri, + uri: t.uri, + })); + } +} + +export default EphemeralCacheInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = EphemeralCacheInput; +export const NAME = "di-ephemeral-cache"; + +customElements.define(NAME, CLASS); diff --git a/src/components/input/ephemeral-cache/worker.js b/src/components/input/ephemeral-cache/worker.js new file mode 100644 index 00000000..5198388b --- /dev/null +++ b/src/components/input/ephemeral-cache/worker.js @@ -0,0 +1,130 @@ +import * as IDB from "idb-keyval"; + +import { ostiary, rpc } from "~/common/worker.js"; +import { CACHE_KEY_PREFIX, SCHEME } from "./constants.js"; + +/** + * @import { InputActions as Actions } from "~/components/input/types.d.ts" + * @import { Track } from "~/definitions/types.d.ts" + */ + +//////////////////////////////////////////// +// STATE +//////////////////////////////////////////// + +/** @type {Map} */ +const blobUrls = new Map(); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + +/** + * @type {Actions['consult']} + */ +export async function consult(uriOrScheme) { + if (!uriOrScheme.includes("://")) { + return { supported: true, consult: "undetermined" }; + } + + const cached = await IDB.get(CACHE_KEY_PREFIX + uriOrScheme); + return { supported: true, consult: cached !== undefined }; +} + +/** + * @type {Actions['detach']} + */ +export async function detach({ fileUriOrScheme, tracks }) { + if (!fileUriOrScheme.includes("://")) { + if (fileUriOrScheme === SCHEME) { + await removeBlobs(tracks.map((t) => t.uri)); + return []; + } + return tracks; + } + + const remaining = tracks.filter((t) => t.uri !== fileUriOrScheme); + await removeBlobs([fileUriOrScheme]); + return remaining; +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(uris) { + const cached = await Promise.all( + uris.map((uri) => IDB.get(CACHE_KEY_PREFIX + uri)), + ); + + return { + [SCHEME]: { + available: true, + scheme: SCHEME, + uris: uris.filter((_, i) => cached[i] !== undefined), + }, + }; +} + +/** + * @type {Actions['list']} + */ +export async function list(tracks) { + return tracks; +} + +/** + * @type {Actions['resolve']} + */ +export async function resolve({ uri }) { + const blob = /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri)); + if (!blob) return undefined; + + let blobUrl = blobUrls.get(uri); + + if (!blobUrl) { + blobUrl = URL.createObjectURL(blob); + blobUrls.set(uri, blobUrl); + } + + return { expiresAt: Infinity, url: blobUrl }; +} + +//////////////////////////////////////////// +// 🛠️ +//////////////////////////////////////////// + +/** + * @param {string[]} uris + */ +async function removeBlobs(uris) { + await Promise.all(uris.map(async (uri) => { + const blobUrl = blobUrls.get(uri); + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + blobUrls.delete(uri); + } + await IDB.del(CACHE_KEY_PREFIX + uri); + })); +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + artwork, + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/components/orchestrator/output/element.js b/src/components/orchestrator/output/element.js index 8be921b2..c223d96d 100644 --- a/src/components/orchestrator/output/element.js +++ b/src/components/orchestrator/output/element.js @@ -144,6 +144,7 @@ class OutputOrchestrator extends DiffuseElement { `; } diff --git a/src/components/orchestrator/process-tracks/element.js b/src/components/orchestrator/process-tracks/element.js index 301eb245..4d450d48 100644 --- a/src/components/orchestrator/process-tracks/element.js +++ b/src/components/orchestrator/process-tracks/element.js @@ -148,6 +148,7 @@ class ProcessTracksOrchestrator extends BroadcastableDiffuseElement { untracked(() => this.process()); }); } + } // WORKERS diff --git a/src/components/orchestrator/sources/element.js b/src/components/orchestrator/sources/element.js index 4e0d2581..ad60481d 100644 --- a/src/components/orchestrator/sources/element.js +++ b/src/components/orchestrator/sources/element.js @@ -69,7 +69,7 @@ class Sources extends BroadcastableDiffuseElement { } } else { const dep = deps[scheme]; - if (!dep) sources = []; + if (!dep) sources = tracks.map((t) => ({ label: t.uri, uri: t.uri })); else sources = dep.sources(tracks); } diff --git a/src/components/transformer/output/refiner/default/element.js b/src/components/transformer/output/refiner/default/element.js index 308e1666..3ebf3e78 100644 --- a/src/components/transformer/output/refiner/default/element.js +++ b/src/components/transformer/output/refiner/default/element.js @@ -22,17 +22,13 @@ class DefaultOutputRefinerTransformer extends OutputTransformer { const base = this.base(); - // Ephemeral signals - const ephemeralPlaylistItems = signal(/** @type {any[]} */ ([])); - const ephemeralTracks = signal(/** @type {any[]} */ ([])); - // Restore stored ephemeral items IDB.get(IDB_KEY_PLAYLISTS).then((items) => { - if (items) ephemeralPlaylistItems.set(items); + if (items) this.#ephemeralPlaylistItems.set(items); }); IDB.get(IDB_KEY_TRACKS).then((items) => { - if (items) ephemeralTracks.set(items); + if (items) this.#ephemeralTracks.set(items); }); /** @type {OutputManagerDeputy} */ @@ -52,7 +48,7 @@ class DefaultOutputRefinerTransformer extends OutputTransformer { if (col.state !== "loaded") return col; return { state: "loaded", - data: [...col.data, ...ephemeralPlaylistItems.get()], + data: [...col.data, ...this.#ephemeralPlaylistItems.get()], }; }), save: async (newPlaylists) => { @@ -69,7 +65,7 @@ class DefaultOutputRefinerTransformer extends OutputTransformer { }); await IDB.set(IDB_KEY_PLAYLISTS, ephemeral); - ephemeralPlaylistItems.set(ephemeral); + this.#ephemeralPlaylistItems.set(ephemeral); await base.playlistItems.save(filtered); }, @@ -81,7 +77,7 @@ class DefaultOutputRefinerTransformer extends OutputTransformer { if (col.state !== "loaded") return col; return { state: "loaded", - data: [...col.data, ...ephemeralTracks.get()], + data: [...col.data, ...this.#ephemeralTracks.get()], }; }), save: async (newTracks) => { @@ -98,7 +94,7 @@ class DefaultOutputRefinerTransformer extends OutputTransformer { }); await IDB.set(IDB_KEY_TRACKS, ephemeral); - ephemeralTracks.set(ephemeral); + this.#ephemeralTracks.set(ephemeral); await base.tracks.save(filtered); }, @@ -114,6 +110,52 @@ class DefaultOutputRefinerTransformer extends OutputTransformer { this.tracks = manager.tracks; this.ready = manager.ready; } + + // SIGNALS + + #ephemeralPlaylistItems = signal(/** @type {PlaylistItem[]} */ ([])); + #ephemeralTracks = signal(/** @type {Track[]} */ ([])); + + // LIFECYCLE + + /** @override */ + connectedCallback() { + if (this.hasAttribute("group")) { + const actions = this.broadcast(IDB_KEY_TRACKS, { + getEphemeralPlaylistItems: { + strategy: "leaderOnly", + fn: this.#ephemeralPlaylistItems.get, + }, + setEphemeralPlaylistItems: { + strategy: "replicate", + fn: this.#ephemeralPlaylistItems.set, + }, + getEphemeralTracks: { + strategy: "leaderOnly", + fn: this.#ephemeralTracks.get, + }, + setEphemeralTracks: { + strategy: "replicate", + fn: this.#ephemeralTracks.set, + }, + }); + + if (actions) { + this.#ephemeralPlaylistItems.set = actions.setEphemeralPlaylistItems; + this.#ephemeralTracks.set = actions.setEphemeralTracks; + + actions.getEphemeralPlaylistItems().then((items) => { + this.#ephemeralPlaylistItems.value = items; + }); + + actions.getEphemeralTracks().then((tracks) => { + this.#ephemeralTracks.value = tracks; + }); + } + } + + super.connectedCallback(); + } } export default DefaultOutputRefinerTransformer; diff --git a/src/facets/connect/common.css b/src/facets/connect/common.css index 4cda4891..3cd7bf21 100644 --- a/src/facets/connect/common.css +++ b/src/facets/connect/common.css @@ -92,6 +92,29 @@ wa-card { flex-shrink: 0; } +.dropzone { + align-items: center; + border: 2px dashed var(--wa-color-neutral-border-quiet); + border-radius: var(--wa-border-radius-m); + color: var(--wa-color-text-quiet); + display: flex; + flex-direction: column; + gap: var(--wa-space-xs); + font-size: var(--wa-font-size-s); + justify-content: center; + padding: var(--wa-space-l); + transition: + background-color 150ms, + border-color 150ms, + color 150ms; + + &.dropzone--active { + background-color: var(--wa-color-surface-sunken); + border-color: var(--wa-color-brand-500); + color: var(--wa-color-text-normal); + } +} + [hidden] { display: none !important; } diff --git a/src/facets/connect/local/index.inline.js b/src/facets/connect/local/index.inline.js index a7dd5236..d376d0f2 100644 --- a/src/facets/connect/local/index.inline.js +++ b/src/facets/connect/local/index.inline.js @@ -1,5 +1,5 @@ import * as TID from "@atcute/tid"; -import { html } from "lit-html"; +import { html, nothing } from "lit-html"; import * as Output from "~/common/output.js"; import { SCHEME } from "~/components/input/local/constants.js"; @@ -49,8 +49,31 @@ const { setItems, setError } = setup({ description: html`

Add local directories or files as audio input.

+ + + + + + ${supported ? html` + +
@@ -62,12 +85,7 @@ const { setItems, setError } = setup({
` - : html` - - Your browser does not support the File System Access API. Use a Chromium-based - browser to add local files. - - `} + : nothing} `, formFields: html` @@ -84,13 +102,67 @@ document .querySelector("#local-add-files-btn") ?.addEventListener("click", () => addFiles()); +document + .querySelector("#local-clear-ephemeral-btn") + ?.addEventListener("click", () => clearEphemeral()); + +const dropzone = document.querySelector("#local-dropzone"); +const dropzoneInput = + /** @type {HTMLInputElement | null} */ (document.querySelector( + "#local-dropzone-input", + )); + +dropzoneInput?.addEventListener("change", async () => { + const files = Array.from(dropzoneInput.files ?? []); + dropzoneInput.value = ""; + if (files.length === 0) return; + await cacheFiles(files); +}); + +dropzone?.addEventListener("dragover", (e) => { + e.preventDefault(); + dropzone.classList.add("dropzone--active"); +}); + +dropzone?.addEventListener("dragleave", () => { + dropzone.classList.remove("dropzone--active"); +}); + +dropzone?.addEventListener("drop", async (e) => { + e.preventDefault(); + dropzone.classList.remove("dropzone--active"); + + const dragEvent = /** @type {DragEvent} */ (e); + const items = Array.from(dragEvent.dataTransfer?.items ?? []); + const files = await collectFiles(items); + if (files.length === 0) return; + + await cacheFiles(files); +}); + //////////////////////////////////////////// // REACTIVE LIST //////////////////////////////////////////// +const ephemeralDivider = + /** @type {HTMLElement | null} */ (document.querySelector( + "#local-ephemeral-divider", + )); +const ephemeralRow = + /** @type {HTMLElement | null} */ (document.querySelector( + "#local-ephemeral-row", + )); + effect(() => { const tracksCol = outputOrchestrator.tracks.collection(); const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; + const hasEphemeral = tracks.some((t) => + t.uri.startsWith("ephemeral+cache://") + ); + + if (ephemeralDivider) ephemeralDivider.hidden = !hasEphemeral; + if (ephemeralRow) ephemeralRow.hidden = !hasEphemeral; + const entries = localInput?.sources(tracks) ?? []; setItems( @@ -125,6 +197,25 @@ async function removeEntry(uri) { } } +async function clearEphemeral() { + setError(null); + try { + const tracks = await Output.data(outputOrchestrator.tracks); + const ephemeralUris = tracks + .filter((t) => t.uri.startsWith("ephemeral+cache://")) + .map((t) => t.uri); + + await inputConfigurator.removeFromCache(ephemeralUris); + await outputOrchestrator.tracks.save( + tracks.filter((t) => !t.uri.startsWith("ephemeral+cache://")), + ); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to clear cached files", + ); + } +} + async function addDirectory() { setError(null); try { @@ -180,3 +271,107 @@ async function addFiles() { } } } + +/** + * @param {File[]} files + */ +async function cacheFiles(files) { + setError(null); + try { + const uris = await Promise.all( + files.map((file) => inputConfigurator.cacheBlob(file)), + ); + const now = new Date().toISOString(); + const existingTracks = await Output.data(outputOrchestrator.tracks); + const existingUris = new Set(existingTracks.map((t) => t.uri)); + const newUris = uris.filter((uri) => !existingUris.has(uri)); + await outputOrchestrator.tracks.save([ + ...existingTracks, + ...newUris.map((uri) => { + /** @type {Track} */ + const track = { + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + ephemeral: true, + uri, + }; + return track; + }), + ]); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to cache files"); + } +} + +/** + * @param {DataTransferItem[]} items + * @returns {Promise} + */ +async function collectFiles(items) { + const files = /** @type {File[]} */ ([]); + + await Promise.all( + items.map(async (item) => { + if (item.kind !== "file") return; + + const entry = item.webkitGetAsEntry?.(); + if (entry?.isDirectory) { + const dirFiles = await readDirectoryEntry( + /** @type {FileSystemDirectoryEntry} */ (entry), + ); + files.push(...dirFiles); + } else { + const file = item.getAsFile(); + if (file) files.push(file); + } + }), + ); + + return files; +} + +/** + * @param {FileSystemDirectoryEntry} dir + * @returns {Promise} + */ +async function readDirectoryEntry(dir) { + const reader = dir.createReader(); + + return new Promise((resolve, reject) => { + /** @type {File[]} */ + const files = []; + + const readBatch = () => { + reader.readEntries(async (entries) => { + if (entries.length === 0) { + resolve(files); + return; + } + + await Promise.all( + entries.map(async (entry) => { + if (entry.isDirectory) { + const nested = await readDirectoryEntry( + /** @type {FileSystemDirectoryEntry} */ (entry), + ); + files.push(...nested); + } else { + const file = await new Promise( + /** @param {(f: File) => void} res */ + (res, rej) => + /** @type {FileSystemFileEntry} */ (entry).file(res, rej), + ); + files.push(file); + } + }), + ); + + readBatch(); + }, reject); + }; + + readBatch(); + }); +} diff --git a/src/facets/data/input-bundle/index.inline.js b/src/facets/data/input-bundle/index.inline.js index 5ea0915c..e14c16bd 100644 --- a/src/facets/data/input-bundle/index.inline.js +++ b/src/facets/data/input-bundle/index.inline.js @@ -1,6 +1,7 @@ import foundation from "~/common/foundation.js"; import { effect } from "~/common/signal.js"; +import { NAME as EPHEMERAL_CACHE_NAME } from "~/components/input/ephemeral-cache/element.js"; import { NAME as HTTPS_NAME } from "~/components/input/https/element.js"; import { NAME as ICECAST_NAME } from "~/components/input/icecast/element.js"; import { NAME as LOCAL_NAME } from "~/components/input/local/element.js"; @@ -19,6 +20,7 @@ effect(() => { const input = foundation.signals.configurator.input(); if (!input) return; + ephemeralCache(input); https(input); icecast(input); local(input); @@ -26,6 +28,17 @@ effect(() => { s3(input); }); +//////////////////////////////////////////// +// EPHEMERAL CACHE +//////////////////////////////////////////// + +/** + * @param {InputConfigurator} input + */ +export function ephemeralCache(input) { + input.append(document.createElement(EPHEMERAL_CACHE_NAME)); +} + //////////////////////////////////////////// // HTTPS //////////////////////////////////////////// diff --git a/tests/components/configurator/input/test.ts b/tests/components/configurator/input/test.ts index 4b92942a..3730599f 100644 --- a/tests/components/configurator/input/test.ts +++ b/tests/components/configurator/input/test.ts @@ -155,6 +155,85 @@ describe("components/configurator/input", () => { expect(result.supported).toBe(true); }); + it("cacheBlob stores a blob and returns an ephemeral+cache:// URI", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/configurator/input/element.js" + ); + const configurator = new mod.CLASS(); + document.body.append(configurator); + + const blob = new Blob(["audio data"], { type: "audio/mpeg" }); + return configurator.cacheBlob(blob); + }); + + expect(result).toMatch(/^ephemeral\+cache:\/\//); + }); + + it("cacheBlob returns the same URI for identical blobs", async () => { + const [uri1, uri2] = await testWeb(async () => { + const mod = await import( + "~/components/configurator/input/element.js" + ); + const configurator = new mod.CLASS(); + document.body.append(configurator); + + const uri1 = await configurator.cacheBlob( + new Blob(["same content"], { type: "audio/mpeg" }), + ); + const uri2 = await configurator.cacheBlob( + new Blob(["same content"], { type: "audio/mpeg" }), + ); + return [uri1, uri2]; + }); + + expect(uri1).toBe(uri2); + }); + + it("cacheBlob returns different URIs for different blobs", async () => { + const [uri1, uri2] = await testWeb(async () => { + const mod = await import( + "~/components/configurator/input/element.js" + ); + const configurator = new mod.CLASS(); + document.body.append(configurator); + + const uri1 = await configurator.cacheBlob( + new Blob(["content A"], { type: "audio/mpeg" }), + ); + const uri2 = await configurator.cacheBlob( + new Blob(["content B"], { type: "audio/mpeg" }), + ); + return [uri1, uri2]; + }); + + expect(uri1).not.toBe(uri2); + }); + + it("removeFromCache deletes a previously cached blob", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/configurator/input/element.js" + ); + const configurator = new mod.CLASS(); + document.body.append(configurator); + + const uri = await configurator.cacheBlob( + new Blob(["to be removed"], { type: "audio/mpeg" }), + ); + await configurator.removeFromCache([uri]); + + const stored = await IDB.get(CACHE_KEY_PREFIX + uri); + return stored ?? null; + }); + + expect(result).toBe(null); + }); + it("resolve with an HTTPS URI returns a url via the HTTPS input", async () => { const result = await testWeb(async () => { const mod = await import( diff --git a/tests/components/input/ephemeral-cache/test.ts b/tests/components/input/ephemeral-cache/test.ts new file mode 100644 index 00000000..9b008fc8 --- /dev/null +++ b/tests/components/input/ephemeral-cache/test.ts @@ -0,0 +1,379 @@ +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/ephemeral-cache", () => { + it("has correct SCHEME property", async () => { + const scheme = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + return input.SCHEME; + }); + + expect(scheme).toBe("ephemeral+cache"); + }); + + it("artwork returns null", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + return await input.artwork("ephemeral+cache://bafktest"); + }); + + expect(result).toBe(null); + }); + + it("consult returns undetermined for scheme only", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + return await input.consult("ephemeral+cache"); + }); + + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe("undetermined"); + } + }); + + it("consult returns false for an uncached URI", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + return await input.consult("ephemeral+cache://bafknotcached"); + }); + + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(false); + } + }); + + it("consult returns true for a cached URI", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const uri = "ephemeral+cache://bafkcacheduri"; + await IDB.set( + CACHE_KEY_PREFIX + uri, + new Blob(["audio"], { type: "audio/mpeg" }), + ); + + const result = await input.consult(uri); + await IDB.del(CACHE_KEY_PREFIX + uri); + return result; + }); + + expect(result.supported).toBe(true); + if (result.supported) { + expect(result.consult).toBe(true); + } + }); + + it("groupConsult returns only cached URIs as available", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const cachedUri = "ephemeral+cache://bafkgroupcached"; + const uncachedUri = "ephemeral+cache://bafkgroupuncached"; + await IDB.set( + CACHE_KEY_PREFIX + cachedUri, + new Blob(["audio"], { type: "audio/mpeg" }), + ); + + const result = await input.groupConsult([cachedUri, uncachedUri]); + await IDB.del(CACHE_KEY_PREFIX + cachedUri); + return result; + }); + + expect(result["ephemeral+cache"]?.available).toBe(true); + expect(result["ephemeral+cache"]?.uris).toEqual([ + "ephemeral+cache://bafkgroupcached", + ]); + }); + + it("groupConsult with no cached URIs returns empty uris list", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + return await input.groupConsult([ + "ephemeral+cache://bafkuncached1", + "ephemeral+cache://bafkuncached2", + ]); + }); + + expect(result["ephemeral+cache"]?.available).toBe(true); + expect(result["ephemeral+cache"]?.uris).toEqual([]); + }); + + it("resolve returns undefined for an uncached URI", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + const r = await input.resolve({ uri: "ephemeral+cache://bafknotcached" }); + return r ?? null; + }); + + expect(result).toBe(null); + }); + + it("resolve returns a blob URL with Infinity expiry for a cached URI", async () => { + const result = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const uri = "ephemeral+cache://bafkresolvetest"; + await IDB.set( + CACHE_KEY_PREFIX + uri, + new Blob(["audio"], { type: "audio/mpeg" }), + ); + + const resolved = await input.resolve({ uri }); + await IDB.del(CACHE_KEY_PREFIX + uri); + if (!resolved || !("url" in resolved)) return null; + return { url: resolved.url, neverExpires: resolved.expiresAt === Infinity }; + }); + + expect(result).not.toBe(null); + if (result) { + expect(result.url).toMatch(/^blob:/); + expect(result.neverExpires).toBe(true); + } + }); + + it("resolve returns the same blob URL on repeated calls", async () => { + const [url1, url2] = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const uri = "ephemeral+cache://bafkresolvetwice"; + await IDB.set( + CACHE_KEY_PREFIX + uri, + new Blob(["audio"], { type: "audio/mpeg" }), + ); + + const r1 = await input.resolve({ uri }); + const r2 = await input.resolve({ uri }); + await IDB.del(CACHE_KEY_PREFIX + uri); + return [ + r1 && "url" in r1 ? r1.url : null, + r2 && "url" in r2 ? r2.url : null, + ]; + }); + + expect(url1).not.toBe(null); + expect(url1).toBe(url2); + }); + + it("list returns tracks unchanged", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "ephemeral+cache://bafktrack1", + }, + { + $type: "sh.diffuse.output.track", + id: "t2", + uri: "ephemeral+cache://bafktrack2", + }, + ]; + + return await input.list(tracks); + }); + + expect(result.map((t) => t.id)).toEqual(["t1", "t2"]); + }); + + it("detaches all tracks when given the scheme", async () => { + const remaining = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "ephemeral+cache://bafkdetach1", + }, + { + $type: "sh.diffuse.output.track", + id: "t2", + uri: "ephemeral+cache://bafkdetach2", + }, + ]; + + for (const t of tracks) { + await IDB.set(CACHE_KEY_PREFIX + t.uri, new Blob(["audio"])); + } + + return await input.detach({ fileUriOrScheme: "ephemeral+cache", tracks }); + }); + + expect(remaining.length).toBe(0); + }); + + it("detaches a specific track when given a URI", async () => { + const remaining = await testWeb(async () => { + const IDB = await import("idb-keyval"); + const { CACHE_KEY_PREFIX } = await import( + "~/components/input/ephemeral-cache/constants.js" + ); + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "ephemeral+cache://bafkremove", + }, + { + $type: "sh.diffuse.output.track", + id: "t2", + uri: "ephemeral+cache://bafkkeep", + }, + ]; + + for (const t of tracks) { + await IDB.set(CACHE_KEY_PREFIX + t.uri, new Blob(["audio"])); + } + + return await input.detach({ + fileUriOrScheme: "ephemeral+cache://bafkremove", + tracks, + }); + }); + + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("t2"); + }); + + it("detach with non-matching scheme returns tracks unchanged", async () => { + const remaining = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "ephemeral+cache://bafk1", + }, + { + $type: "sh.diffuse.output.track", + id: "t2", + uri: "ephemeral+cache://bafk2", + }, + ]; + + return await input.detach({ fileUriOrScheme: "https", tracks }); + }); + + expect(remaining.map((t) => t.id)).toEqual(["t1", "t2"]); + }); + + it("sources returns tracks mapped by URI as label", async () => { + const sources = await testWeb(async () => { + const mod = await import( + "~/components/input/ephemeral-cache/element.js" + ); + const input = new mod.CLASS(); + document.body.append(input); + + const tracks: Track[] = [ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: "ephemeral+cache://bafksrc1", + }, + { + $type: "sh.diffuse.output.track", + id: "t2", + uri: "ephemeral+cache://bafksrc2", + tags: { title: "My Song" }, + }, + ]; + + return input.sources(tracks); + }); + + expect(sources.length).toBe(2); + expect(sources[0].uri).toBe("ephemeral+cache://bafksrc1"); + expect(sources[0].label).toBe("ephemeral+cache://bafksrc1"); + // label is always the URI regardless of tags, to keep sources stable across processing + expect(sources[1].uri).toBe("ephemeral+cache://bafksrc2"); + expect(sources[1].label).toBe("ephemeral+cache://bafksrc2"); + }); +});