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

feat: local file upload for ephemeral tracks

+963 -44
+1
src/components/configurator/input/element.js
··· 35 35 this.resolve = proxy.resolve; 36 36 37 37 this.cache = proxy.cache; 38 + this.cacheBlob = proxy.cacheBlob; 38 39 this.listCached = proxy.listCached; 39 40 this.removeFromCache = proxy.removeFromCache; 40 41 }
+1
src/components/configurator/input/types.d.ts
··· 2 2 3 3 export type Actions = InputActions & { 4 4 cache(uris: string[]): Promise<void> 5 + cacheBlob(blob: Blob): Promise<string> 5 6 listCached(): Promise<string[]> 6 7 removeFromCache(uris: string[]): Promise<void> 7 8 };
+20 -26
src/components/configurator/input/worker.js
··· 1 1 import * as IDB from "idb-keyval"; 2 2 import * as URI from "fast-uri"; 3 + import * as Cid from "~/common/cid.js"; 3 4 4 5 import { groupTracksPerScheme, groupUrisPerScheme } from "~/common/utils.js"; 5 6 import { ostiary, rpc, workerProxy } from "~/common/worker.js"; ··· 16 17 //////////////////////////////////////////// 17 18 18 19 const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/"; 19 - 20 - /** @type {Map<string, string>} */ 21 - const blobUrls = new Map(); 22 20 23 21 //////////////////////////////////////////// 24 22 // INPUT ACTIONS ··· 58 56 * @type {ActionsWithTunnel<Actions>['detach']} 59 57 */ 60 58 export async function detach({ data, ports }) { 61 - const cachedTracks = data.tracks; 62 - const groups = groupTracks(cachedTracks, ports); 59 + const currentTracks = data.tracks; 60 + const groups = groupTracks(currentTracks, ports); 63 61 64 62 const promises = Object.entries(groups).map( 65 63 async ([scheme, tracksGroup]) => { ··· 154 152 export async function resolve({ data, ports }) { 155 153 const uri = data.uri; 156 154 157 - const cachedBlob = 158 - /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri)); 159 - if (cachedBlob) { 160 - let blobUrl = blobUrls.get(uri); 161 - 162 - if (!blobUrl) { 163 - blobUrl = URL.createObjectURL(cachedBlob); 164 - blobUrls.set(uri, blobUrl); 165 - } 166 - 167 - return { expiresAt: Infinity, url: blobUrl }; 168 - } 169 - 170 155 const scheme = uri.split(":", 1)[0]; 171 156 const input = grabInput(scheme, ports); 172 157 if (!input) return undefined; ··· 213 198 export async function removeFromCache({ data }) { 214 199 const uris = data; 215 200 216 - await Promise.all(uris.map(async (uri) => { 217 - const blobUrl = blobUrls.get(uri); 218 - if (blobUrl) { 219 - URL.revokeObjectURL(blobUrl); 220 - blobUrls.delete(uri); 221 - } 222 - await IDB.del(CACHE_KEY_PREFIX + uri); 223 - })); 201 + await Promise.all(uris.map((uri) => IDB.del(CACHE_KEY_PREFIX + uri))); 202 + } 203 + 204 + /** 205 + * @type {ActionsWithTunnel<Actions>['cacheBlob']} 206 + */ 207 + export async function cacheBlob({ data }) { 208 + const blob = data; 209 + const buffer = await blob.arrayBuffer(); 210 + const bytes = new Uint8Array(buffer); 211 + const cid = await Cid.create(0x55, bytes); 212 + const uri = `ephemeral+cache://${cid}`; 213 + if (await IDB.get(CACHE_KEY_PREFIX + uri) === undefined) { 214 + await IDB.set(CACHE_KEY_PREFIX + uri, blob); 215 + } 216 + return uri; 224 217 } 225 218 226 219 //////////////////////////////////////////// ··· 237 230 resolve, 238 231 239 232 cache, 233 + cacheBlob, 240 234 listCached, 241 235 removeFromCache, 242 236 });
+2
src/components/input/ephemeral-cache/constants.js
··· 1 + export const SCHEME = "ephemeral+cache"; 2 + export const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/";
+58
src/components/input/ephemeral-cache/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + import { SCHEME } from "./constants.js"; 3 + 4 + /** 5 + * @import { InputActions, InputSchemeProvider } from "~/components/input/types.d.ts" 6 + * @import { ProxiedActions } from "~/common/worker.d.ts" 7 + * @import { Track } from "~/definitions/types.d.ts" 8 + */ 9 + 10 + //////////////////////////////////////////// 11 + // ELEMENT 12 + //////////////////////////////////////////// 13 + 14 + /** 15 + * @implements {ProxiedActions<InputActions>} 16 + * @implements {InputSchemeProvider} 17 + */ 18 + class EphemeralCacheInput extends DiffuseElement { 19 + static NAME = "diffuse/input/ephemeral-cache"; 20 + static WORKER_URL = "components/input/ephemeral-cache/worker.js"; 21 + 22 + SCHEME = SCHEME; 23 + 24 + constructor() { 25 + super(); 26 + 27 + /** @type {ProxiedActions<InputActions>} */ 28 + const proxy = this.workerProxy(); 29 + 30 + this.artwork = proxy.artwork; 31 + this.consult = proxy.consult; 32 + this.detach = proxy.detach; 33 + this.groupConsult = proxy.groupConsult; 34 + this.list = proxy.list; 35 + this.resolve = proxy.resolve; 36 + } 37 + 38 + // 🛠️ 39 + 40 + /** @param {Track[]} tracks */ 41 + sources(tracks) { 42 + return tracks.map((t) => ({ 43 + label: t.uri, 44 + uri: t.uri, 45 + })); 46 + } 47 + } 48 + 49 + export default EphemeralCacheInput; 50 + 51 + //////////////////////////////////////////// 52 + // REGISTER 53 + //////////////////////////////////////////// 54 + 55 + export const CLASS = EphemeralCacheInput; 56 + export const NAME = "di-ephemeral-cache"; 57 + 58 + customElements.define(NAME, CLASS);
+130
src/components/input/ephemeral-cache/worker.js
··· 1 + import * as IDB from "idb-keyval"; 2 + 3 + import { ostiary, rpc } from "~/common/worker.js"; 4 + import { CACHE_KEY_PREFIX, SCHEME } from "./constants.js"; 5 + 6 + /** 7 + * @import { InputActions as Actions } from "~/components/input/types.d.ts" 8 + * @import { Track } from "~/definitions/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // STATE 13 + //////////////////////////////////////////// 14 + 15 + /** @type {Map<string, string>} */ 16 + const blobUrls = new Map(); 17 + 18 + //////////////////////////////////////////// 19 + // ACTIONS 20 + //////////////////////////////////////////// 21 + 22 + /** 23 + * @type {Actions['artwork']} 24 + */ 25 + export async function artwork(_uri) { 26 + return null; 27 + } 28 + 29 + /** 30 + * @type {Actions['consult']} 31 + */ 32 + export async function consult(uriOrScheme) { 33 + if (!uriOrScheme.includes("://")) { 34 + return { supported: true, consult: "undetermined" }; 35 + } 36 + 37 + const cached = await IDB.get(CACHE_KEY_PREFIX + uriOrScheme); 38 + return { supported: true, consult: cached !== undefined }; 39 + } 40 + 41 + /** 42 + * @type {Actions['detach']} 43 + */ 44 + export async function detach({ fileUriOrScheme, tracks }) { 45 + if (!fileUriOrScheme.includes("://")) { 46 + if (fileUriOrScheme === SCHEME) { 47 + await removeBlobs(tracks.map((t) => t.uri)); 48 + return []; 49 + } 50 + return tracks; 51 + } 52 + 53 + const remaining = tracks.filter((t) => t.uri !== fileUriOrScheme); 54 + await removeBlobs([fileUriOrScheme]); 55 + return remaining; 56 + } 57 + 58 + /** 59 + * @type {Actions['groupConsult']} 60 + */ 61 + export async function groupConsult(uris) { 62 + const cached = await Promise.all( 63 + uris.map((uri) => IDB.get(CACHE_KEY_PREFIX + uri)), 64 + ); 65 + 66 + return { 67 + [SCHEME]: { 68 + available: true, 69 + scheme: SCHEME, 70 + uris: uris.filter((_, i) => cached[i] !== undefined), 71 + }, 72 + }; 73 + } 74 + 75 + /** 76 + * @type {Actions['list']} 77 + */ 78 + export async function list(tracks) { 79 + return tracks; 80 + } 81 + 82 + /** 83 + * @type {Actions['resolve']} 84 + */ 85 + export async function resolve({ uri }) { 86 + const blob = /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri)); 87 + if (!blob) return undefined; 88 + 89 + let blobUrl = blobUrls.get(uri); 90 + 91 + if (!blobUrl) { 92 + blobUrl = URL.createObjectURL(blob); 93 + blobUrls.set(uri, blobUrl); 94 + } 95 + 96 + return { expiresAt: Infinity, url: blobUrl }; 97 + } 98 + 99 + //////////////////////////////////////////// 100 + // 🛠️ 101 + //////////////////////////////////////////// 102 + 103 + /** 104 + * @param {string[]} uris 105 + */ 106 + async function removeBlobs(uris) { 107 + await Promise.all(uris.map(async (uri) => { 108 + const blobUrl = blobUrls.get(uri); 109 + if (blobUrl) { 110 + URL.revokeObjectURL(blobUrl); 111 + blobUrls.delete(uri); 112 + } 113 + await IDB.del(CACHE_KEY_PREFIX + uri); 114 + })); 115 + } 116 + 117 + //////////////////////////////////////////// 118 + // ⚡️ 119 + //////////////////////////////////////////// 120 + 121 + ostiary((context) => { 122 + rpc(context, { 123 + artwork, 124 + consult, 125 + detach, 126 + groupConsult, 127 + list, 128 + resolve, 129 + }); 130 + });
+1
src/components/orchestrator/output/element.js
··· 144 144 <dtor-default 145 145 id="do-output__output" 146 146 output-selector="#do-output__dtor-initial-contents" 147 + group="${ifDefined(group)}" 147 148 ></dtor-default> 148 149 `; 149 150 }
+1
src/components/orchestrator/process-tracks/element.js
··· 148 148 untracked(() => this.process()); 149 149 }); 150 150 } 151 + 151 152 } 152 153 153 154 // WORKERS
+1 -1
src/components/orchestrator/sources/element.js
··· 69 69 } 70 70 } else { 71 71 const dep = deps[scheme]; 72 - if (!dep) sources = []; 72 + if (!dep) sources = tracks.map((t) => ({ label: t.uri, uri: t.uri })); 73 73 else sources = dep.sources(tracks); 74 74 } 75 75
+52 -10
src/components/transformer/output/refiner/default/element.js
··· 22 22 23 23 const base = this.base(); 24 24 25 - // Ephemeral signals 26 - const ephemeralPlaylistItems = signal(/** @type {any[]} */ ([])); 27 - const ephemeralTracks = signal(/** @type {any[]} */ ([])); 28 - 29 25 // Restore stored ephemeral items 30 26 IDB.get(IDB_KEY_PLAYLISTS).then((items) => { 31 - if (items) ephemeralPlaylistItems.set(items); 27 + if (items) this.#ephemeralPlaylistItems.set(items); 32 28 }); 33 29 34 30 IDB.get(IDB_KEY_TRACKS).then((items) => { 35 - if (items) ephemeralTracks.set(items); 31 + if (items) this.#ephemeralTracks.set(items); 36 32 }); 37 33 38 34 /** @type {OutputManagerDeputy} */ ··· 52 48 if (col.state !== "loaded") return col; 53 49 return { 54 50 state: "loaded", 55 - data: [...col.data, ...ephemeralPlaylistItems.get()], 51 + data: [...col.data, ...this.#ephemeralPlaylistItems.get()], 56 52 }; 57 53 }), 58 54 save: async (newPlaylists) => { ··· 69 65 }); 70 66 71 67 await IDB.set(IDB_KEY_PLAYLISTS, ephemeral); 72 - ephemeralPlaylistItems.set(ephemeral); 68 + this.#ephemeralPlaylistItems.set(ephemeral); 73 69 74 70 await base.playlistItems.save(filtered); 75 71 }, ··· 81 77 if (col.state !== "loaded") return col; 82 78 return { 83 79 state: "loaded", 84 - data: [...col.data, ...ephemeralTracks.get()], 80 + data: [...col.data, ...this.#ephemeralTracks.get()], 85 81 }; 86 82 }), 87 83 save: async (newTracks) => { ··· 98 94 }); 99 95 100 96 await IDB.set(IDB_KEY_TRACKS, ephemeral); 101 - ephemeralTracks.set(ephemeral); 97 + this.#ephemeralTracks.set(ephemeral); 102 98 103 99 await base.tracks.save(filtered); 104 100 }, ··· 113 109 this.playlistItems = manager.playlistItems; 114 110 this.tracks = manager.tracks; 115 111 this.ready = manager.ready; 112 + } 113 + 114 + // SIGNALS 115 + 116 + #ephemeralPlaylistItems = signal(/** @type {PlaylistItem[]} */ ([])); 117 + #ephemeralTracks = signal(/** @type {Track[]} */ ([])); 118 + 119 + // LIFECYCLE 120 + 121 + /** @override */ 122 + connectedCallback() { 123 + if (this.hasAttribute("group")) { 124 + const actions = this.broadcast(IDB_KEY_TRACKS, { 125 + getEphemeralPlaylistItems: { 126 + strategy: "leaderOnly", 127 + fn: this.#ephemeralPlaylistItems.get, 128 + }, 129 + setEphemeralPlaylistItems: { 130 + strategy: "replicate", 131 + fn: this.#ephemeralPlaylistItems.set, 132 + }, 133 + getEphemeralTracks: { 134 + strategy: "leaderOnly", 135 + fn: this.#ephemeralTracks.get, 136 + }, 137 + setEphemeralTracks: { 138 + strategy: "replicate", 139 + fn: this.#ephemeralTracks.set, 140 + }, 141 + }); 142 + 143 + if (actions) { 144 + this.#ephemeralPlaylistItems.set = actions.setEphemeralPlaylistItems; 145 + this.#ephemeralTracks.set = actions.setEphemeralTracks; 146 + 147 + actions.getEphemeralPlaylistItems().then((items) => { 148 + this.#ephemeralPlaylistItems.value = items; 149 + }); 150 + 151 + actions.getEphemeralTracks().then((tracks) => { 152 + this.#ephemeralTracks.value = tracks; 153 + }); 154 + } 155 + } 156 + 157 + super.connectedCallback(); 116 158 } 117 159 } 118 160
+23
src/facets/connect/common.css
··· 92 92 flex-shrink: 0; 93 93 } 94 94 95 + .dropzone { 96 + align-items: center; 97 + border: 2px dashed var(--wa-color-neutral-border-quiet); 98 + border-radius: var(--wa-border-radius-m); 99 + color: var(--wa-color-text-quiet); 100 + display: flex; 101 + flex-direction: column; 102 + gap: var(--wa-space-xs); 103 + font-size: var(--wa-font-size-s); 104 + justify-content: center; 105 + padding: var(--wa-space-l); 106 + transition: 107 + background-color 150ms, 108 + border-color 150ms, 109 + color 150ms; 110 + 111 + &.dropzone--active { 112 + background-color: var(--wa-color-surface-sunken); 113 + border-color: var(--wa-color-brand-500); 114 + color: var(--wa-color-text-normal); 115 + } 116 + } 117 + 95 118 [hidden] { 96 119 display: none !important; 97 120 }
+202 -7
src/facets/connect/local/index.inline.js
··· 1 1 import * as TID from "@atcute/tid"; 2 - import { html } from "lit-html"; 2 + import { html, nothing } from "lit-html"; 3 3 4 4 import * as Output from "~/common/output.js"; 5 5 import { SCHEME } from "~/components/input/local/constants.js"; ··· 49 49 50 50 description: html` 51 51 <p>Add local directories or files as audio input.</p> 52 + 53 + <label class="dropzone" id="local-dropzone"> 54 + <input id="local-dropzone-input" type="file" multiple hidden /> 55 + <wa-icon library="phosphor/bold" name="upload-simple"></wa-icon> 56 + <span>Drop or click to select files</span> 57 + </label> 58 + 59 + <wa-divider id="local-ephemeral-divider" hidden></wa-divider> 60 + <div id="local-ephemeral-row" class="button-row" hidden> 61 + <wa-button 62 + id="local-clear-ephemeral-btn" 63 + variant="danger" 64 + appearance="outlined" 65 + size="small" 66 + style="width: 100%" 67 + > 68 + <wa-icon slot="start" library="phosphor/bold" name="trash"></wa-icon> 69 + Clear files 70 + </wa-button> 71 + </div> 72 + 52 73 ${supported 53 74 ? html` 75 + <wa-divider></wa-divider> 76 + 54 77 <div class="button-row"> 55 78 <wa-button id="local-add-dir-btn" variant="neutral" appearance="filled"> 56 79 <wa-icon slot="start" library="phosphor/fill" name="folder-open"></wa-icon> ··· 62 85 </wa-button> 63 86 </div> 64 87 ` 65 - : html` 66 - <wa-callout variant="warning"> 67 - Your browser does not support the File System Access API. Use a Chromium-based 68 - browser to add local files. 69 - </wa-callout> 70 - `} 88 + : nothing} 71 89 `, 72 90 73 91 formFields: html` ··· 84 102 .querySelector("#local-add-files-btn") 85 103 ?.addEventListener("click", () => addFiles()); 86 104 105 + document 106 + .querySelector("#local-clear-ephemeral-btn") 107 + ?.addEventListener("click", () => clearEphemeral()); 108 + 109 + const dropzone = document.querySelector("#local-dropzone"); 110 + const dropzoneInput = 111 + /** @type {HTMLInputElement | null} */ (document.querySelector( 112 + "#local-dropzone-input", 113 + )); 114 + 115 + dropzoneInput?.addEventListener("change", async () => { 116 + const files = Array.from(dropzoneInput.files ?? []); 117 + dropzoneInput.value = ""; 118 + if (files.length === 0) return; 119 + await cacheFiles(files); 120 + }); 121 + 122 + dropzone?.addEventListener("dragover", (e) => { 123 + e.preventDefault(); 124 + dropzone.classList.add("dropzone--active"); 125 + }); 126 + 127 + dropzone?.addEventListener("dragleave", () => { 128 + dropzone.classList.remove("dropzone--active"); 129 + }); 130 + 131 + dropzone?.addEventListener("drop", async (e) => { 132 + e.preventDefault(); 133 + dropzone.classList.remove("dropzone--active"); 134 + 135 + const dragEvent = /** @type {DragEvent} */ (e); 136 + const items = Array.from(dragEvent.dataTransfer?.items ?? []); 137 + const files = await collectFiles(items); 138 + if (files.length === 0) return; 139 + 140 + await cacheFiles(files); 141 + }); 142 + 87 143 //////////////////////////////////////////// 88 144 // REACTIVE LIST 89 145 //////////////////////////////////////////// 146 + 147 + const ephemeralDivider = 148 + /** @type {HTMLElement | null} */ (document.querySelector( 149 + "#local-ephemeral-divider", 150 + )); 151 + const ephemeralRow = 152 + /** @type {HTMLElement | null} */ (document.querySelector( 153 + "#local-ephemeral-row", 154 + )); 90 155 91 156 effect(() => { 92 157 const tracksCol = outputOrchestrator.tracks.collection(); 93 158 const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; 159 + const hasEphemeral = tracks.some((t) => 160 + t.uri.startsWith("ephemeral+cache://") 161 + ); 162 + 163 + if (ephemeralDivider) ephemeralDivider.hidden = !hasEphemeral; 164 + if (ephemeralRow) ephemeralRow.hidden = !hasEphemeral; 165 + 94 166 const entries = localInput?.sources(tracks) ?? []; 95 167 96 168 setItems( ··· 122 194 if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 123 195 } catch (err) { 124 196 setError(err instanceof Error ? err.message : "Failed to remove entry"); 197 + } 198 + } 199 + 200 + async function clearEphemeral() { 201 + setError(null); 202 + try { 203 + const tracks = await Output.data(outputOrchestrator.tracks); 204 + const ephemeralUris = tracks 205 + .filter((t) => t.uri.startsWith("ephemeral+cache://")) 206 + .map((t) => t.uri); 207 + 208 + await inputConfigurator.removeFromCache(ephemeralUris); 209 + await outputOrchestrator.tracks.save( 210 + tracks.filter((t) => !t.uri.startsWith("ephemeral+cache://")), 211 + ); 212 + } catch (err) { 213 + setError( 214 + err instanceof Error ? err.message : "Failed to clear cached files", 215 + ); 125 216 } 126 217 } 127 218 ··· 180 271 } 181 272 } 182 273 } 274 + 275 + /** 276 + * @param {File[]} files 277 + */ 278 + async function cacheFiles(files) { 279 + setError(null); 280 + try { 281 + const uris = await Promise.all( 282 + files.map((file) => inputConfigurator.cacheBlob(file)), 283 + ); 284 + const now = new Date().toISOString(); 285 + const existingTracks = await Output.data(outputOrchestrator.tracks); 286 + const existingUris = new Set(existingTracks.map((t) => t.uri)); 287 + const newUris = uris.filter((uri) => !existingUris.has(uri)); 288 + await outputOrchestrator.tracks.save([ 289 + ...existingTracks, 290 + ...newUris.map((uri) => { 291 + /** @type {Track} */ 292 + const track = { 293 + $type: "sh.diffuse.output.track", 294 + id: TID.now(), 295 + createdAt: now, 296 + updatedAt: now, 297 + ephemeral: true, 298 + uri, 299 + }; 300 + return track; 301 + }), 302 + ]); 303 + } catch (err) { 304 + setError(err instanceof Error ? err.message : "Failed to cache files"); 305 + } 306 + } 307 + 308 + /** 309 + * @param {DataTransferItem[]} items 310 + * @returns {Promise<File[]>} 311 + */ 312 + async function collectFiles(items) { 313 + const files = /** @type {File[]} */ ([]); 314 + 315 + await Promise.all( 316 + items.map(async (item) => { 317 + if (item.kind !== "file") return; 318 + 319 + const entry = item.webkitGetAsEntry?.(); 320 + if (entry?.isDirectory) { 321 + const dirFiles = await readDirectoryEntry( 322 + /** @type {FileSystemDirectoryEntry} */ (entry), 323 + ); 324 + files.push(...dirFiles); 325 + } else { 326 + const file = item.getAsFile(); 327 + if (file) files.push(file); 328 + } 329 + }), 330 + ); 331 + 332 + return files; 333 + } 334 + 335 + /** 336 + * @param {FileSystemDirectoryEntry} dir 337 + * @returns {Promise<File[]>} 338 + */ 339 + async function readDirectoryEntry(dir) { 340 + const reader = dir.createReader(); 341 + 342 + return new Promise((resolve, reject) => { 343 + /** @type {File[]} */ 344 + const files = []; 345 + 346 + const readBatch = () => { 347 + reader.readEntries(async (entries) => { 348 + if (entries.length === 0) { 349 + resolve(files); 350 + return; 351 + } 352 + 353 + await Promise.all( 354 + entries.map(async (entry) => { 355 + if (entry.isDirectory) { 356 + const nested = await readDirectoryEntry( 357 + /** @type {FileSystemDirectoryEntry} */ (entry), 358 + ); 359 + files.push(...nested); 360 + } else { 361 + const file = await new Promise( 362 + /** @param {(f: File) => void} res */ 363 + (res, rej) => 364 + /** @type {FileSystemFileEntry} */ (entry).file(res, rej), 365 + ); 366 + files.push(file); 367 + } 368 + }), 369 + ); 370 + 371 + readBatch(); 372 + }, reject); 373 + }; 374 + 375 + readBatch(); 376 + }); 377 + }
+13
src/facets/data/input-bundle/index.inline.js
··· 1 1 import foundation from "~/common/foundation.js"; 2 2 import { effect } from "~/common/signal.js"; 3 3 4 + import { NAME as EPHEMERAL_CACHE_NAME } from "~/components/input/ephemeral-cache/element.js"; 4 5 import { NAME as HTTPS_NAME } from "~/components/input/https/element.js"; 5 6 import { NAME as ICECAST_NAME } from "~/components/input/icecast/element.js"; 6 7 import { NAME as LOCAL_NAME } from "~/components/input/local/element.js"; ··· 19 20 const input = foundation.signals.configurator.input(); 20 21 if (!input) return; 21 22 23 + ephemeralCache(input); 22 24 https(input); 23 25 icecast(input); 24 26 local(input); 25 27 opensubsonic(input); 26 28 s3(input); 27 29 }); 30 + 31 + //////////////////////////////////////////// 32 + // EPHEMERAL CACHE 33 + //////////////////////////////////////////// 34 + 35 + /** 36 + * @param {InputConfigurator} input 37 + */ 38 + export function ephemeralCache(input) { 39 + input.append(document.createElement(EPHEMERAL_CACHE_NAME)); 40 + } 28 41 29 42 //////////////////////////////////////////// 30 43 // HTTPS
+79
tests/components/configurator/input/test.ts
··· 155 155 expect(result.supported).toBe(true); 156 156 }); 157 157 158 + it("cacheBlob stores a blob and returns an ephemeral+cache:// URI", async () => { 159 + const result = await testWeb(async () => { 160 + const mod = await import( 161 + "~/components/configurator/input/element.js" 162 + ); 163 + const configurator = new mod.CLASS(); 164 + document.body.append(configurator); 165 + 166 + const blob = new Blob(["audio data"], { type: "audio/mpeg" }); 167 + return configurator.cacheBlob(blob); 168 + }); 169 + 170 + expect(result).toMatch(/^ephemeral\+cache:\/\//); 171 + }); 172 + 173 + it("cacheBlob returns the same URI for identical blobs", async () => { 174 + const [uri1, uri2] = await testWeb(async () => { 175 + const mod = await import( 176 + "~/components/configurator/input/element.js" 177 + ); 178 + const configurator = new mod.CLASS(); 179 + document.body.append(configurator); 180 + 181 + const uri1 = await configurator.cacheBlob( 182 + new Blob(["same content"], { type: "audio/mpeg" }), 183 + ); 184 + const uri2 = await configurator.cacheBlob( 185 + new Blob(["same content"], { type: "audio/mpeg" }), 186 + ); 187 + return [uri1, uri2]; 188 + }); 189 + 190 + expect(uri1).toBe(uri2); 191 + }); 192 + 193 + it("cacheBlob returns different URIs for different blobs", async () => { 194 + const [uri1, uri2] = await testWeb(async () => { 195 + const mod = await import( 196 + "~/components/configurator/input/element.js" 197 + ); 198 + const configurator = new mod.CLASS(); 199 + document.body.append(configurator); 200 + 201 + const uri1 = await configurator.cacheBlob( 202 + new Blob(["content A"], { type: "audio/mpeg" }), 203 + ); 204 + const uri2 = await configurator.cacheBlob( 205 + new Blob(["content B"], { type: "audio/mpeg" }), 206 + ); 207 + return [uri1, uri2]; 208 + }); 209 + 210 + expect(uri1).not.toBe(uri2); 211 + }); 212 + 213 + it("removeFromCache deletes a previously cached blob", async () => { 214 + const result = await testWeb(async () => { 215 + const IDB = await import("idb-keyval"); 216 + const { CACHE_KEY_PREFIX } = await import( 217 + "~/components/input/ephemeral-cache/constants.js" 218 + ); 219 + const mod = await import( 220 + "~/components/configurator/input/element.js" 221 + ); 222 + const configurator = new mod.CLASS(); 223 + document.body.append(configurator); 224 + 225 + const uri = await configurator.cacheBlob( 226 + new Blob(["to be removed"], { type: "audio/mpeg" }), 227 + ); 228 + await configurator.removeFromCache([uri]); 229 + 230 + const stored = await IDB.get(CACHE_KEY_PREFIX + uri); 231 + return stored ?? null; 232 + }); 233 + 234 + expect(result).toBe(null); 235 + }); 236 + 158 237 it("resolve with an HTTPS URI returns a url via the HTTPS input", async () => { 159 238 const result = await testWeb(async () => { 160 239 const mod = await import(
+379
tests/components/input/ephemeral-cache/test.ts
··· 1 + import { describe, it } from "@std/testing/bdd"; 2 + import { expect } from "@std/expect"; 3 + 4 + import { testWeb } from "@tests/common/index.ts"; 5 + import type { Track } from "~/definitions/types.d.ts"; 6 + 7 + describe("components/input/ephemeral-cache", () => { 8 + it("has correct SCHEME property", async () => { 9 + const scheme = await testWeb(async () => { 10 + const mod = await import( 11 + "~/components/input/ephemeral-cache/element.js" 12 + ); 13 + const input = new mod.CLASS(); 14 + document.body.append(input); 15 + return input.SCHEME; 16 + }); 17 + 18 + expect(scheme).toBe("ephemeral+cache"); 19 + }); 20 + 21 + it("artwork returns null", async () => { 22 + const result = await testWeb(async () => { 23 + const mod = await import( 24 + "~/components/input/ephemeral-cache/element.js" 25 + ); 26 + const input = new mod.CLASS(); 27 + document.body.append(input); 28 + return await input.artwork("ephemeral+cache://bafktest"); 29 + }); 30 + 31 + expect(result).toBe(null); 32 + }); 33 + 34 + it("consult returns undetermined for scheme only", async () => { 35 + const result = await testWeb(async () => { 36 + const mod = await import( 37 + "~/components/input/ephemeral-cache/element.js" 38 + ); 39 + const input = new mod.CLASS(); 40 + document.body.append(input); 41 + return await input.consult("ephemeral+cache"); 42 + }); 43 + 44 + expect(result.supported).toBe(true); 45 + if (result.supported) { 46 + expect(result.consult).toBe("undetermined"); 47 + } 48 + }); 49 + 50 + it("consult returns false for an uncached URI", async () => { 51 + const result = await testWeb(async () => { 52 + const mod = await import( 53 + "~/components/input/ephemeral-cache/element.js" 54 + ); 55 + const input = new mod.CLASS(); 56 + document.body.append(input); 57 + return await input.consult("ephemeral+cache://bafknotcached"); 58 + }); 59 + 60 + expect(result.supported).toBe(true); 61 + if (result.supported) { 62 + expect(result.consult).toBe(false); 63 + } 64 + }); 65 + 66 + it("consult returns true for a cached URI", async () => { 67 + const result = await testWeb(async () => { 68 + const IDB = await import("idb-keyval"); 69 + const { CACHE_KEY_PREFIX } = await import( 70 + "~/components/input/ephemeral-cache/constants.js" 71 + ); 72 + const mod = await import( 73 + "~/components/input/ephemeral-cache/element.js" 74 + ); 75 + const input = new mod.CLASS(); 76 + document.body.append(input); 77 + 78 + const uri = "ephemeral+cache://bafkcacheduri"; 79 + await IDB.set( 80 + CACHE_KEY_PREFIX + uri, 81 + new Blob(["audio"], { type: "audio/mpeg" }), 82 + ); 83 + 84 + const result = await input.consult(uri); 85 + await IDB.del(CACHE_KEY_PREFIX + uri); 86 + return result; 87 + }); 88 + 89 + expect(result.supported).toBe(true); 90 + if (result.supported) { 91 + expect(result.consult).toBe(true); 92 + } 93 + }); 94 + 95 + it("groupConsult returns only cached URIs as available", async () => { 96 + const result = await testWeb(async () => { 97 + const IDB = await import("idb-keyval"); 98 + const { CACHE_KEY_PREFIX } = await import( 99 + "~/components/input/ephemeral-cache/constants.js" 100 + ); 101 + const mod = await import( 102 + "~/components/input/ephemeral-cache/element.js" 103 + ); 104 + const input = new mod.CLASS(); 105 + document.body.append(input); 106 + 107 + const cachedUri = "ephemeral+cache://bafkgroupcached"; 108 + const uncachedUri = "ephemeral+cache://bafkgroupuncached"; 109 + await IDB.set( 110 + CACHE_KEY_PREFIX + cachedUri, 111 + new Blob(["audio"], { type: "audio/mpeg" }), 112 + ); 113 + 114 + const result = await input.groupConsult([cachedUri, uncachedUri]); 115 + await IDB.del(CACHE_KEY_PREFIX + cachedUri); 116 + return result; 117 + }); 118 + 119 + expect(result["ephemeral+cache"]?.available).toBe(true); 120 + expect(result["ephemeral+cache"]?.uris).toEqual([ 121 + "ephemeral+cache://bafkgroupcached", 122 + ]); 123 + }); 124 + 125 + it("groupConsult with no cached URIs returns empty uris list", async () => { 126 + const result = await testWeb(async () => { 127 + const mod = await import( 128 + "~/components/input/ephemeral-cache/element.js" 129 + ); 130 + const input = new mod.CLASS(); 131 + document.body.append(input); 132 + 133 + return await input.groupConsult([ 134 + "ephemeral+cache://bafkuncached1", 135 + "ephemeral+cache://bafkuncached2", 136 + ]); 137 + }); 138 + 139 + expect(result["ephemeral+cache"]?.available).toBe(true); 140 + expect(result["ephemeral+cache"]?.uris).toEqual([]); 141 + }); 142 + 143 + it("resolve returns undefined for an uncached URI", async () => { 144 + const result = await testWeb(async () => { 145 + const mod = await import( 146 + "~/components/input/ephemeral-cache/element.js" 147 + ); 148 + const input = new mod.CLASS(); 149 + document.body.append(input); 150 + const r = await input.resolve({ uri: "ephemeral+cache://bafknotcached" }); 151 + return r ?? null; 152 + }); 153 + 154 + expect(result).toBe(null); 155 + }); 156 + 157 + it("resolve returns a blob URL with Infinity expiry for a cached URI", async () => { 158 + const result = await testWeb(async () => { 159 + const IDB = await import("idb-keyval"); 160 + const { CACHE_KEY_PREFIX } = await import( 161 + "~/components/input/ephemeral-cache/constants.js" 162 + ); 163 + const mod = await import( 164 + "~/components/input/ephemeral-cache/element.js" 165 + ); 166 + const input = new mod.CLASS(); 167 + document.body.append(input); 168 + 169 + const uri = "ephemeral+cache://bafkresolvetest"; 170 + await IDB.set( 171 + CACHE_KEY_PREFIX + uri, 172 + new Blob(["audio"], { type: "audio/mpeg" }), 173 + ); 174 + 175 + const resolved = await input.resolve({ uri }); 176 + await IDB.del(CACHE_KEY_PREFIX + uri); 177 + if (!resolved || !("url" in resolved)) return null; 178 + return { url: resolved.url, neverExpires: resolved.expiresAt === Infinity }; 179 + }); 180 + 181 + expect(result).not.toBe(null); 182 + if (result) { 183 + expect(result.url).toMatch(/^blob:/); 184 + expect(result.neverExpires).toBe(true); 185 + } 186 + }); 187 + 188 + it("resolve returns the same blob URL on repeated calls", async () => { 189 + const [url1, url2] = await testWeb(async () => { 190 + const IDB = await import("idb-keyval"); 191 + const { CACHE_KEY_PREFIX } = await import( 192 + "~/components/input/ephemeral-cache/constants.js" 193 + ); 194 + const mod = await import( 195 + "~/components/input/ephemeral-cache/element.js" 196 + ); 197 + const input = new mod.CLASS(); 198 + document.body.append(input); 199 + 200 + const uri = "ephemeral+cache://bafkresolvetwice"; 201 + await IDB.set( 202 + CACHE_KEY_PREFIX + uri, 203 + new Blob(["audio"], { type: "audio/mpeg" }), 204 + ); 205 + 206 + const r1 = await input.resolve({ uri }); 207 + const r2 = await input.resolve({ uri }); 208 + await IDB.del(CACHE_KEY_PREFIX + uri); 209 + return [ 210 + r1 && "url" in r1 ? r1.url : null, 211 + r2 && "url" in r2 ? r2.url : null, 212 + ]; 213 + }); 214 + 215 + expect(url1).not.toBe(null); 216 + expect(url1).toBe(url2); 217 + }); 218 + 219 + it("list returns tracks unchanged", async () => { 220 + const result = await testWeb(async () => { 221 + const mod = await import( 222 + "~/components/input/ephemeral-cache/element.js" 223 + ); 224 + const input = new mod.CLASS(); 225 + document.body.append(input); 226 + 227 + const tracks: Track[] = [ 228 + { 229 + $type: "sh.diffuse.output.track", 230 + id: "t1", 231 + uri: "ephemeral+cache://bafktrack1", 232 + }, 233 + { 234 + $type: "sh.diffuse.output.track", 235 + id: "t2", 236 + uri: "ephemeral+cache://bafktrack2", 237 + }, 238 + ]; 239 + 240 + return await input.list(tracks); 241 + }); 242 + 243 + expect(result.map((t) => t.id)).toEqual(["t1", "t2"]); 244 + }); 245 + 246 + it("detaches all tracks when given the scheme", async () => { 247 + const remaining = await testWeb(async () => { 248 + const IDB = await import("idb-keyval"); 249 + const { CACHE_KEY_PREFIX } = await import( 250 + "~/components/input/ephemeral-cache/constants.js" 251 + ); 252 + const mod = await import( 253 + "~/components/input/ephemeral-cache/element.js" 254 + ); 255 + const input = new mod.CLASS(); 256 + document.body.append(input); 257 + 258 + const tracks: Track[] = [ 259 + { 260 + $type: "sh.diffuse.output.track", 261 + id: "t1", 262 + uri: "ephemeral+cache://bafkdetach1", 263 + }, 264 + { 265 + $type: "sh.diffuse.output.track", 266 + id: "t2", 267 + uri: "ephemeral+cache://bafkdetach2", 268 + }, 269 + ]; 270 + 271 + for (const t of tracks) { 272 + await IDB.set(CACHE_KEY_PREFIX + t.uri, new Blob(["audio"])); 273 + } 274 + 275 + return await input.detach({ fileUriOrScheme: "ephemeral+cache", tracks }); 276 + }); 277 + 278 + expect(remaining.length).toBe(0); 279 + }); 280 + 281 + it("detaches a specific track when given a URI", async () => { 282 + const remaining = await testWeb(async () => { 283 + const IDB = await import("idb-keyval"); 284 + const { CACHE_KEY_PREFIX } = await import( 285 + "~/components/input/ephemeral-cache/constants.js" 286 + ); 287 + const mod = await import( 288 + "~/components/input/ephemeral-cache/element.js" 289 + ); 290 + const input = new mod.CLASS(); 291 + document.body.append(input); 292 + 293 + const tracks: Track[] = [ 294 + { 295 + $type: "sh.diffuse.output.track", 296 + id: "t1", 297 + uri: "ephemeral+cache://bafkremove", 298 + }, 299 + { 300 + $type: "sh.diffuse.output.track", 301 + id: "t2", 302 + uri: "ephemeral+cache://bafkkeep", 303 + }, 304 + ]; 305 + 306 + for (const t of tracks) { 307 + await IDB.set(CACHE_KEY_PREFIX + t.uri, new Blob(["audio"])); 308 + } 309 + 310 + return await input.detach({ 311 + fileUriOrScheme: "ephemeral+cache://bafkremove", 312 + tracks, 313 + }); 314 + }); 315 + 316 + expect(remaining.length).toBe(1); 317 + expect(remaining[0].id).toBe("t2"); 318 + }); 319 + 320 + it("detach with non-matching scheme returns tracks unchanged", async () => { 321 + const remaining = await testWeb(async () => { 322 + const mod = await import( 323 + "~/components/input/ephemeral-cache/element.js" 324 + ); 325 + const input = new mod.CLASS(); 326 + document.body.append(input); 327 + 328 + const tracks: Track[] = [ 329 + { 330 + $type: "sh.diffuse.output.track", 331 + id: "t1", 332 + uri: "ephemeral+cache://bafk1", 333 + }, 334 + { 335 + $type: "sh.diffuse.output.track", 336 + id: "t2", 337 + uri: "ephemeral+cache://bafk2", 338 + }, 339 + ]; 340 + 341 + return await input.detach({ fileUriOrScheme: "https", tracks }); 342 + }); 343 + 344 + expect(remaining.map((t) => t.id)).toEqual(["t1", "t2"]); 345 + }); 346 + 347 + it("sources returns tracks mapped by URI as label", async () => { 348 + const sources = await testWeb(async () => { 349 + const mod = await import( 350 + "~/components/input/ephemeral-cache/element.js" 351 + ); 352 + const input = new mod.CLASS(); 353 + document.body.append(input); 354 + 355 + const tracks: Track[] = [ 356 + { 357 + $type: "sh.diffuse.output.track", 358 + id: "t1", 359 + uri: "ephemeral+cache://bafksrc1", 360 + }, 361 + { 362 + $type: "sh.diffuse.output.track", 363 + id: "t2", 364 + uri: "ephemeral+cache://bafksrc2", 365 + tags: { title: "My Song" }, 366 + }, 367 + ]; 368 + 369 + return input.sources(tracks); 370 + }); 371 + 372 + expect(sources.length).toBe(2); 373 + expect(sources[0].uri).toBe("ephemeral+cache://bafksrc1"); 374 + expect(sources[0].label).toBe("ephemeral+cache://bafksrc1"); 375 + // label is always the URI regardless of tags, to keep sources stable across processing 376 + expect(sources[1].uri).toBe("ephemeral+cache://bafksrc2"); 377 + expect(sources[1].label).toBe("ephemeral+cache://bafksrc2"); 378 + }); 379 + });