A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
10.0 kB · 270 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271/** * @import {ConsultResult} from "@specs/components/input/types.d.ts" * @import {Track} from "~/definitions/types.d.ts" */
/** * Creates a time-cached version of an async consult function. * Results are cached per key for the given TTL. * * The underlying `fn` returns a {@link ConsultResult}: * - `"yes"` → server confirmed reachable (cached for the full TTL); * - `"no"` → server explicitly rejected, or a transient consult * failure occurred (cached for the full TTL only when the * underlying fn actually returned `"no"`; an `"unsure"` * result is **not** cached — the next consult retries * immediately); * - `"unsure"` → inconclusive (network blip, timeout, aborted fetch). * * The wrapper normalises `"unsure"` to `"no"` for the caller: we can't * confirm availability, so callers should hide the source's tracks * until a real consult succeeds. The key difference vs. a genuine * `"no"` is purely about caching: `"unsure"` is never written to the * cache, so the next consult call will retry the underlying fn rather * than wait out the TTL. * * @template T * @param {(arg: T) => Promise<ConsultResult>} fn * @param {(arg: T) => string} keyFn * @param {number} ttl - Cache TTL in milliseconds * @returns {(arg: T) => Promise<ConsultResult>} * * @example Caches results and avoids calling fn more than once per key * ```js * import { cachedConsult } from "~/components/input/common.js"; * * let callCount = 0; * const cached = cachedConsult(async () => { callCount++; return "yes"; }, (k) => k); * * const r1 = await cached("k"); * const r2 = await cached("k"); * * if (r1 !== "yes" || r2 !== "yes") throw new Error("should return cached value"); * if (callCount !== 1) throw new Error("fn should only be called once per key"); * ``` * * @example An `"unsure"` result is normalised to `"no"` and not cached * ```js * import { cachedConsult } from "~/components/input/common.js"; * * let n = 0; * const cached = cachedConsult( * async () => (n++ === 0 ? "unsure" : "yes"), * (k) => k, * ); * * // First call: fn returns "unsure". The wrapper returns "no" without * // caching, so the caller hides the source's tracks until a real * // consult succeeds. * const r1 = await cached("k"); * if (r1 !== "no") throw new Error("first call should normalise \"unsure\" to \"no\""); * * // Second call: cache is empty so fn runs again and returns "yes". * const r2 = await cached("k"); * if (r2 !== "yes") throw new Error("second call should return the fresh \"yes\""); * ``` */export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { /** @type {Map<string, { value: ConsultResult; expiry: number }>} */ const cache = new Map();
return async (arg) => { const key = keyFn(arg); const now = Date.now(); const cached = cache.get(key);
if (cached && cached.expiry > now) { return cached.value; }
const value = await fn(arg);
// `"unsure"` means we couldn't confirm availability — surface it // as `"no"` to callers (their tracks shouldn't show until a real // consult succeeds), but never cache it so the next consult call // retries the underlying fn immediately rather than waiting out // the TTL. This is what distinguishes a transient network blip // from a server-confirmed `"no"`: both look like "unavailable" to // callers, but only the latter sticks for the cache window. if (value === "unsure") { return "no"; }
cache.set(key, { value, expiry: now + ttl }); return value; };}
/** * @param {{ fileUriOrScheme: string; handleFileUri: (args: { fileURI: string; tracks: Track[] }) => Track[]; inputScheme: string; tracks: Track[] }} _ * * @example Removes all tracks when given a matching scheme, returns all when scheme doesn't match * ```js * import { detach } from "~/components/input/common.js"; * * const tracks = JSON.parse('[{"$type":"sh.diffuse.output.track","id":"1","uri":"https://a.com/1.mp3"},{"$type":"sh.diffuse.output.track","id":"2","uri":"https://b.com/2.mp3"}]'); * * // @ts-ignore * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks }); * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks"); * * // @ts-ignore * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks }); * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks"); * ``` * * @example Delegates to handleFileUri when a full URI is given * ```js * import { detach } from "~/components/input/common.js"; * * const tracks = JSON.parse('[{"$type":"sh.diffuse.output.track","id":"1","uri":"https://a.com/1.mp3"},{"$type":"sh.diffuse.output.track","id":"2","uri":"https://b.com/2.mp3"}]'); * * // @ts-ignore * const result = detach({ fileUriOrScheme: "https://a.com/1.mp3", inputScheme: "https", handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), tracks }); * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI"); * ``` */export function detach( { fileUriOrScheme, handleFileUri, inputScheme, tracks },) { if (!fileUriOrScheme.includes("://")) { // Delete everything if scheme matches if (fileUriOrScheme === inputScheme) return []; return tracks; }
return handleFileUri({ fileURI: fileUriOrScheme, tracks });}
/** * @param {string} scheme * @param {string} groupId * * @example Returns scheme://groupId * ```js * import { groupKey } from "~/components/input/common.js"; * * if (groupKey("https", "example.com") !== "https://example.com") throw new Error(`expected "https://example.com"`); * ``` */export function groupKey(scheme, groupId) { return `${scheme}://${groupId}`;}
/** * @param {string} filename * * @example Returns truthy for audio extensions and falsy for non-audio ones * ```js * import { isAudioFile } from "~/components/input/common.js"; * * const audioExts = ["track.mp3", "track.flac", "track.ogg", "track.opus", "track.wav", "track.m4a", "track.webm"]; * for (const f of audioExts) { * if (!isAudioFile(f)) throw new Error(`${f} should be recognised as audio`); * } * * const nonAudio = ["track.txt", "track.jpg", "track.pdf", "track"]; * for (const f of nonAudio) { * if (isAudioFile(f)) throw new Error(`${f} should not be recognised as audio`); * } * ``` */export function isAudioFile(filename) { return filename.match(/\.(flac|m4a|mp3|mp4|ogg|opus|wav|webm)$/);}
/** * @param {string} filename * @returns {boolean} * * @example Returns truthy for image extensions and falsy for non-image ones * ```js * import { isImageFile } from "~/components/input/common.js"; * * const images = ["cover.jpg", "folder.jpeg", "art.png", "scan.gif", "pic.webp", "Cover.JPG"]; * for (const f of images) { * if (!isImageFile(f)) throw new Error(`${f} should be recognised as an image`); * } * * const nonImages = ["cover.txt", "audio.mp3", "cover", "folder"]; * for (const f of nonImages) { * if (isImageFile(f)) throw new Error(`${f} should not be recognised as an image`); * } * ``` */export function isImageFile(filename) { return /\.(jpe?g|png|gif|webp)$/i.test(filename);}
/** * Time budget for fetching bytes (currently artwork-sized payloads). Without * this a request to a server that accepts the connection but never answers — * a half-open connection after a network change or laptop sleep — stays * pending forever, holding one of the browser's few connections to that * origin. Enough leaked requests eventually starve all traffic to that * server, including audio streaming. */const BYTES_TIMEOUT_MS = 30_000;
/** * Fetch a URL and return its body bytes, or `null` if the request failed. * * @param {string} url * @param {AbortSignal} [signal] * @returns {Promise<Uint8Array | null>} */export async function bytesFromUrl(url, signal) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), BYTES_TIMEOUT_MS); const onAbort = () => controller.abort(); signal?.addEventListener("abort", onAbort, { once: true });
try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) return null; return new Uint8Array(await response.arrayBuffer()); } finally { clearTimeout(timeoutId); signal?.removeEventListener("abort", onAbort); }}
/** * Pick the preferred artwork from a list of image candidates, in fallback order: * first one whose name contains "cover" (case-insensitive), else one containing * "front", else the first candidate. * * @template T * @param {T[]} items * @param {(item: T) => string} nameOf * @returns {T | undefined} * * @example Prefers the cover-named image over the first one * ```js * import { pickCoverArt } from "~/components/input/common.js"; * * const picked = pickCoverArt(["a.png", "cover.png", "b.png"], (n) => n); * if (picked !== "cover.png") throw new Error("expected cover.png to win"); * ``` * * @example Falls back to a front-named image when no cover is present * ```js * import { pickCoverArt } from "~/components/input/common.js"; * * const picked = pickCoverArt(["a.png", "front.jpg", "b.png"], (n) => n); * if (picked !== "front.jpg") throw new Error("expected front.jpg to beat non-cover images"); * ``` * * @example Falls back to the first image when neither cover nor front is present * ```js * import { pickCoverArt } from "~/components/input/common.js"; * * const picked = pickCoverArt(["a.png", "b.png"], (n) => n); * if (picked !== "a.png") throw new Error("expected first image when no cover or front is present"); * ``` */export function pickCoverArt(items, nameOf) { return items.find((item) => /cover/i.test(nameOf(item))) ?? items.find((item) => /front/i.test(nameOf(item))) ?? items[0];}