A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
9.4 kB · 328 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329import * as URI from "fast-uri";import QS from "query-string";
import { cachedConsult } from "~/components/input/common.js";import { SCHEME } from "./constants.js";
/** * @import {Track} from "~/definitions/types.d.ts" */
////////////////////////////////////////////// 🛠️////////////////////////////////////////////
/** * @typedef {{ host: string; dir: string; exclude?: string[] }} Server */
/** * @param {Server} server * @returns {string} */export function serverId(server) { return `${server.host}${server.dir}`;}
/** * Normalise a directory path to its canonical form: a single leading slash, * no trailing slash (the root stays "/") and no repeated slashes, so stray * `//` inputs can never produce double-slash URLs. * * @param {string} dir * @returns {string} */export function normalizeDir(dir) { const cleaned = dir.replace(/\/{2,}/g, "/").replace(/^\/|\/$/g, ""); return cleaned === "" ? "/" : "/" + cleaned;}
/** * Build a https-json:// URI. * Protocol can be embedded in host (eg. http://localhost:8080) and is stored * as a query param so the URI authority stays valid. * * @param {Server} server * @param {string} [path] * @returns {string} */export function buildURI(server, path = "") { let host = server.host; let protocol;
if (host.includes("://")) { [protocol, host] = host.split("://"); }
const exclude = server.exclude?.length ? server.exclude.join(",") : undefined; const query = QS.stringify({ dir: server.dir, exclude, protocol }); return `${SCHEME}://${host}${path}${query ? `?${query}` : ""}`;}
/** * @param {string} uriString * @returns {{ server: Server; path: string } | undefined} */export function parseURI(uriString) { const uri = URI.parse(uriString); if (uri.scheme !== SCHEME) return undefined; if (!uri.host) return undefined;
const qs = QS.parse(uri.query || ""); const dir = typeof qs.dir === "string" ? normalizeDir(qs.dir) : "/"; const protocol = typeof qs.protocol === "string" ? qs.protocol : undefined; const exclude = typeof qs.exclude === "string" ? qs.exclude.split(",").filter(Boolean) : undefined;
const rawHost = uri.port ? `${uri.host}:${uri.port}` : uri.host; const host = protocol ? `${protocol}://${rawHost}` : rawHost; const server = { host, dir, exclude }; const path = uri.path || "";
return { server, path };}
/** * @param {Server} server * @param {string} [path] * @returns {string} */export function toHttpUrl(server, path = "") { const base = server.host.includes("://") ? server.host : `${ server.host.split(":")[0] === "localhost" || server.host.split(":")[0] === "127.0.0.1" ? "http" : "https" }://${server.host}`;
// Strip ALL leading/trailing slashes on both sides of the join, so a stray // `//` in host or path can never produce a double-slash URL. return base.replace(/\/+$/, "") + (path ? "/" + path.replace(/^\/+/, "") : "");}
/** * @param {Track[]} tracks * @returns {Record<string, Server>} */export function serversFromTracks(tracks) { /** @type {Record<string, Server>} */ const acc = {};
tracks.forEach((track) => { const parsed = parseURI(track.uri); if (!parsed) return;
const id = serverId(parsed.server); if (!acc[id]) acc[id] = parsed.server; });
return acc;}
/** * @param {Track[]} tracks * @returns {Record<string, { server: Server; tracks: Track[] }>} */export function groupTracksByServer(tracks) { /** @type {Record<string, { server: Server; tracks: Track[] }>} */ const acc = {};
tracks.forEach((track) => { const parsed = parseURI(track.uri); if (!parsed) return;
const id = serverId(parsed.server);
if (acc[id]) { acc[id].tracks.push(track); } else { acc[id] = { server: parsed.server, tracks: [track] }; } });
return acc;}
/** * @param {string[]} uris * @returns {Record<string, { server: Server; uris: string[] }>} */export function groupUrisByServer(uris) { /** @type {Record<string, { server: Server; uris: string[] }>} */ const acc = {};
uris.forEach((uri) => { const parsed = parseURI(uri); if (!parsed) return;
const id = serverId(parsed.server);
if (acc[id]) { acc[id].uris.push(uri); } else { acc[id] = { server: parsed.server, uris: [uri] }; } });
return acc;}
/** * @param {Server} server * @returns {Promise<import("@specs/components/input/types.d.ts").ConsultResult>} */async function checkAccess(server) { const url = toHttpUrl(server, server.dir); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000);
try { const response = await fetch(url, { headers: { "Accept": "application/json" }, signal: controller.signal, });
clearTimeout(timeoutId); return response.ok ? "yes" : "no"; } catch { // Network error or timeout: inconclusive — let `cachedConsult` fall // back to the last known availability instead of caching a sticky // "no" for the full TTL (which would hide every track from this // server for 5 minutes after a laptop wake). clearTimeout(timeoutId); return "unsure"; }}
export const checkAccessCached = cachedConsult(checkAccess, serverId);
/** * List all files on the server under server.dir using JSON directory listing. * Fetches each directory with `Accept: application/json` and recurses into subdirs. * * Returns `null` if the root directory listing could not be fetched at all * (network error, non-2xx, or non-JSON response) — this signals a transient * outage rather than "directory is genuinely empty" so the caller can * preserve previously-cached tracks instead of wiping them with a placeholder. * * @param {Server} server * @returns {Promise<string[] | null>} */export async function listFiles(server) { const paths = /** @type {string[]} */ ([]); const exclude = new Set(server.exclude ?? []);
let rootEntries; try { rootEntries = await fetchDirEntries(server, server.dir); } catch { // Root fetch failed — server unreachable or not a JSON listing. // The caller can use this to preserve cached tracks rather than // replacing them with a placeholder. return null; }
await walkEntries(server, server.dir, rootEntries, paths, exclude); return paths;}
/** * @param {Server} server * @param {string} dir * @returns {Promise<unknown[]>} */async function fetchDirEntries(server, dir) { const url = toHttpUrl(server, dir); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000);
let response; try { response = await fetch(url, { headers: { "Accept": "application/json" }, signal: controller.signal, }); } finally { clearTimeout(timeoutId); }
if (!response.ok) throw new Error(`dir fetch failed: ${response.status}`);
/** @type {unknown} */ const data = await response.json(); if (!Array.isArray(data)) throw new Error("dir listing is not an array"); return /** @type {unknown[]} */ (data);}
/** * @param {Server} server * @param {string} dir * @param {unknown[]} entries * @param {string[]} paths * @param {Set<string>} exclude */async function walkEntries(server, dir, entries, paths, exclude) { const basePath = dir.endsWith("/") ? dir : dir + "/";
for (const entry of entries) { if (!entry || typeof /** @type {any} */ (entry).name !== "string" || !/** @type {any} */ (entry).type) { continue; }
// Encode each path segment so URIs stay valid for non-ASCII filenames. const encodedName = encodeURIComponent(/** @type {any} */ (entry).name); const entryPath = basePath + encodedName;
if (/** @type {any} */ (entry).type === "directory") { if (exclude.has(/** @type {any} */ (entry).name)) continue; // A failing subdir shouldn't fail the whole walk — skip it and // keep going, matching the previous tolerant behaviour. try { const subEntries = await fetchDirEntries(server, entryPath); await walkEntries(server, entryPath, subEntries, paths, exclude); } catch { // Subdir unavailable: skip. } } else if (/** @type {any} */ (entry).type === "file") { paths.push(entryPath); } }}
/** * List the names of the file entries in a single directory's JSON listing. * Only the immediate directory is fetched (no recursion); subdirectories are * ignored. Intended for locating sibling artwork next to an audio file. * * Returns the plain (raw) filenames of all `type === "file"` entries, or `null` * if the directory listing could not be fetched or is not an array. * * @param {Server} server * @param {string} dir * @returns {Promise<string[] | null>} */export async function listImageFilesInDir(server, dir) { /** @type {string[]} */ const fileNames = [];
try { const entries = await fetchDirEntries(server, dir); for (const entry of entries) { if (!entry || typeof /** @type {any} */ (entry).name !== "string" || /** @type {any} */ (entry).type !== "file") { continue; } fileNames.push(/** @type {any} */ (entry).name); } } catch { // Directory listing unavailable (network error, timeout, non-2xx, or // non-JSON response) — treat as "no sibling artwork found". return null; }
return fileNames;}