diff --git a/deno.jsonc b/deno.jsonc index a0a51fca..7d815d39 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -36,6 +36,7 @@ "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2", "@std/html": "jsr:@std/html@^1.0.5", "@std/semver": "jsr:@std/semver@^1.0.8", + "@std/xml": "jsr:@std/xml@^0.1.0", "@vicary/debounce-microtask": "jsr:@vicary/debounce-microtask@^0.1.8", "@tanstack/virtual-core": "npm:@tanstack/virtual-core@^3.13.0", "alien-signals": "npm:alien-signals@^3.1.2", diff --git a/src/_data/facets.json b/src/_data/facets.json index c3706a09..fd7db38a 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -97,6 +97,12 @@ "category": "Data", "desc": "Connect to an S3-compatible storage for audio input or user-data storage." }, + { + "url": "facets/connect/webdav/index.html", + "title": "Connect / WebDAV", + "category": "Data", + "desc": "Connect to a WebDAV server for audio input." + }, { "url": "facets/data/artwork-bundle/index.html", "title": "Default Artwork Bundle", @@ -111,7 +117,7 @@ "kind": "prelude", "category": "Data", "tags": ["base"], - "desc": "The default setup for audio input sources. Adds support for: Dropbox, HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." + "desc": "The default setup for audio input sources. Adds support for: Dropbox, HTTPS, Icecast, the local filesystem, OpenSubsonic, S3-compatible storage, and WebDAV." }, { "url": "facets/data/metadata-bundle/index.html", diff --git a/src/components/input/webdav/common.js b/src/components/input/webdav/common.js new file mode 100644 index 00000000..73b3367c --- /dev/null +++ b/src/components/input/webdav/common.js @@ -0,0 +1,308 @@ +import { parse as parseXml } from "@std/xml"; +import * 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"; + * @import { Server } from "./types.d.ts"; + */ + +//////////////////////////////////////////// +// 🛠️ +//////////////////////////////////////////// + +/** + * Build an HTTP(S) URL with credentials in a query param for the service worker to intercept. + * Credentials go in `?_auth=` rather than `user:pass@host` because browsers + * block `new Request()` with credentials in the URL authority (which music-metadata uses). + * + * @param {Server} server + * @param {string} [filePath] + */ +export function buildTrackUrl(server, filePath = "") { + const url = new URL(toHttpUrl(server, filePath)); + url.searchParams.set("diffuse:basic-auth", btoa(unescape(encodeURIComponent(`${server.username}:${server.password}`)))); + return url.href; +} + +/** + * @param {Server} server + */ +export function serverId(server) { + return `${server.username}:${server.password}@${server.host}${server.dir}`; +} + +/** + * @param {Server} server + * @param {string} [filePath] + */ +export function buildURI(server, filePath = "") { + let host = server.host; + let protocol; + + if (host.includes("://")) { + [protocol, host] = host.split("://"); + } + + return URI.serialize({ + scheme: SCHEME, + userinfo: `${encodeURIComponent(server.username)}:${encodeURIComponent(server.password)}`, + host, + path: filePath, + query: QS.stringify({ dir: server.dir, protocol }), + }); +} + +/** + * @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 userinfo = uri.userinfo ?? ""; + const colonIdx = userinfo.indexOf(":"); + const username = decodeURIComponent(colonIdx >= 0 ? userinfo.slice(0, colonIdx) : userinfo); + const password = decodeURIComponent(colonIdx >= 0 ? userinfo.slice(colonIdx + 1) : ""); + + const qs = QS.parse(uri.query || ""); + const dir = typeof qs.dir === "string" ? qs.dir : "/"; + const protocol = typeof qs.protocol === "string" ? qs.protocol : undefined; + + const rawHost = uri.port ? `${uri.host}:${uri.port}` : uri.host; + const host = protocol ? `${protocol}://${rawHost}` : rawHost; + const server = { username, password, host, dir }; + const path = uri.path || ""; + + return { server, path }; +} + +/** + * @param {Server} server + * @param {string} [path] + */ +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}`; + + return base.replace(/\/$/, "") + (path ? "/" + path.replace(/^\//, "") : ""); +} + +/** + * @param {Server} server + */ +export function authHeader(server) { + return `Basic ${btoa(unescape(encodeURIComponent(`${server.username}:${server.password}`)))}`; +} + +/** + * @param {Track[]} tracks + * @returns {Record} + */ +export function serversFromTracks(tracks) { + /** @type {Record} */ + 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} + */ +export function groupTracksByServer(tracks) { + /** @type {Record} */ + 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} + */ +export function groupUrisByServer(uris) { + /** @type {Record} */ + 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 + */ +async function checkAccess(server) { + try { + const url = toHttpUrl(server, server.dir); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: "PROPFIND", + headers: { + "Authorization": authHeader(server), + "Depth": "0", + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response.status === 207 || response.ok; + } catch { + return false; + } +} + +export const checkAccessCached = cachedConsult(checkAccess, serverId); + +/** + * List all files on a WebDAV server under server.dir. + * Uses Depth:1 and recurses into subdirectories to avoid loading the + * entire tree in one response. + * + * @param {Server} server + * @returns {Promise} + */ +export async function listFiles(server) { + const paths = /** @type {string[]} */ ([]); + await propfindDir(server, server.dir, paths); + return paths; +} + +/** + * @param {Server} server + * @param {string} dir + * @param {string[]} paths + */ +async function propfindDir(server, dir, paths) { + const url = toHttpUrl(server, dir); + + const response = await fetch(url, { + method: "PROPFIND", + headers: { + "Authorization": authHeader(server), + "Depth": "1", + }, + }); + + if (response.status !== 207 && !response.ok) return; + + const xml = await response.text(); + const subdirs = /** @type {string[]} */ ([]); + + const doc = parseXml(xml); + const multistatus = doc.root; + if (!multistatus) return; + + for (const node of multistatus.children ?? []) { + if (node.type !== "element" || node.name.local !== "response") continue; + + let href = ""; + let isCollection = false; + + for (const child of node.children ?? []) { + if (child.type !== "element") continue; + + if (child.name.local === "href") { + href = (child.children?.find((n) => n.type === "text")?.text ?? "").trim(); + } else if (child.name.local === "propstat") { + if (propstatHasCollection(child)) isCollection = true; + } + } + + if (!href) continue; + + // Trailing slash is the most reliable collection indicator in WebDAV + isCollection = isCollection || href.endsWith("/"); + + // Keep the raw (percent-encoded) pathname for recursion so that + // toHttpUrl produces a valid URL; decode only for the final paths list. + let rawPath; + try { + rawPath = new URL(href).pathname; + } catch { + rawPath = href; + } + const path = decodeURIComponent(rawPath); + + // Skip the directory entry itself. + // Normalise both sides to have a leading slash — server hrefs always do, + // but `dir` may not when the user omitted the leading slash in the form. + const normPath = path.replace(/\/$/, ""); + const normDir = ("/" + decodeURIComponent(dir).replace(/^\//, "")).replace(/\/$/, ""); + if (normPath === normDir) continue; + + // Skip Synology extended-attribute metadata folders + if (path.split("/").includes("@eaDir")) continue; + + if (isCollection) { + subdirs.push(rawPath); + } else { + paths.push(path); + } + } + + for (const subdir of subdirs) { + await propfindDir(server, subdir, paths); + } +} + +/** + * Check propstat > prop > resourcetype > collection (DAV spec path). + * Using `||=` in the caller means multiple propstat elements don't overwrite a true result. + * + * @param {{ children?: ReadonlyArray<{ type: string; name?: { local: string }; children?: ReadonlyArray }> }} propstat + * @returns {boolean} + */ +function propstatHasCollection(propstat) { + for (const prop of propstat.children ?? []) { + if (prop.type !== "element" || prop.name?.local !== "prop") continue; + for (const child of prop.children ?? []) { + if (child.type !== "element" || child.name?.local !== "resourcetype") continue; + for (const rt of child.children ?? []) { + if (rt.type === "element" && rt.name?.local === "collection") return true; + } + } + } + return false; +} diff --git a/src/components/input/webdav/constants.js b/src/components/input/webdav/constants.js new file mode 100644 index 00000000..d76a5f8f --- /dev/null +++ b/src/components/input/webdav/constants.js @@ -0,0 +1 @@ +export const SCHEME = "webdav"; diff --git a/src/components/input/webdav/element.js b/src/components/input/webdav/element.js new file mode 100644 index 00000000..7b6fa674 --- /dev/null +++ b/src/components/input/webdav/element.js @@ -0,0 +1,59 @@ +import { defineElement, DiffuseElement } from "~/common/element.js"; +import { SCHEME } from "./constants.js"; +import { buildURI, serversFromTracks } 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 WebdavInput extends DiffuseElement { + static NAME = "diffuse/input/webdav"; + static WORKER_URL = "components/input/webdav/worker.js"; + + SCHEME = SCHEME; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + this.proxy = this.workerProxy(); + + this.artwork = this.proxy.artwork; + 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) { + return Object.values(serversFromTracks(tracks)).map((server) => ({ + label: `WebDAV (${server.host}${server.dir})`, + uri: buildURI(server), + })); + } +} + +export default WebdavInput; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = WebdavInput; +export const NAME = "di-webdav"; + +defineElement(NAME, CLASS); diff --git a/src/components/input/webdav/types.d.ts b/src/components/input/webdav/types.d.ts new file mode 100644 index 00000000..0f1f8758 --- /dev/null +++ b/src/components/input/webdav/types.d.ts @@ -0,0 +1,6 @@ +export type Server = { + username: string; + password: string; + host: string; + dir: string; +}; diff --git a/src/components/input/webdav/worker.js b/src/components/input/webdav/worker.js new file mode 100644 index 00000000..70e6f57b --- /dev/null +++ b/src/components/input/webdav/worker.js @@ -0,0 +1,188 @@ +import * as TID from "@atcute/tid"; +import { ostiary, rpc } from "~/common/worker.js"; +import { + detach as detachUtil, + groupKey, + isAudioFile, +} from "~/components/input/common.js"; + +import { + buildTrackUrl, + buildURI, + checkAccessCached, + groupTracksByServer, + groupUrisByServer, + listFiles, + parseURI, + serverId, +} from "./common.js"; +import { SCHEME } from "./constants.js"; + +/** + * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; + * @import { Track } from "~/definitions/types.d.ts"; + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + +/** + * @type {Actions['consult']} + */ +export async function consult(fileUriOrScheme) { + if (!fileUriOrScheme.includes(":")) { + return { supported: true, consult: "undetermined" }; + } + + const parsed = parseURI(fileUriOrScheme); + if (!parsed) return { supported: true, consult: "undetermined" }; + + const accessible = await checkAccessCached(parsed.server); + return { supported: true, consult: accessible }; +} + +/** + * @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 id = serverId(result.server); + const groups = groupTracksByServer(tracks); + + delete groups[id]; + + return Object.values(groups).map((g) => g.tracks).flat(1); + }, + }); +} + +/** + * @type {Actions['groupConsult']} + */ +export async function groupConsult(uris) { + const groups = groupUrisByServer(uris); + + const promises = Object.entries(groups).map( + async ([id, { server, uris }]) => { + const available = await checkAccessCached(server); + + /** @type {ConsultGrouping} */ + const grouping = available + ? { available, scheme: SCHEME, uris } + : { available, reason: "WebDAV server unreachable", scheme: SCHEME, uris }; + + return { key: groupKey(SCHEME, id), grouping }; + }, + ); + + const entries = (await Promise.all(promises)).map((e) => [e.key, e.grouping]); + return Object.fromEntries(entries); +} + +/** + * @type {Actions['list']} + */ +export async function list(cachedTracks = []) { + /** @type {Record>} */ + const cache = {}; + + const groups = groupTracksByServer(cachedTracks); + + Object.entries(groups).forEach(([id, { tracks }]) => { + tracks.forEach((track) => { + const parsed = parseURI(track.uri); + if (!parsed) return; + + if (!cache[id]) cache[id] = {}; + cache[id][parsed.path] = track; + }); + }); + + const promises = Object.entries(groups).map(async ([id, { server }]) => { + const files = await listFiles(server); + + let tracks = files + .filter((path) => isAudioFile(path)) + .map((path) => { + const cachedTrack = cache[id]?.[path]; + + const trackId = cachedTrack?.id || TID.now(); + const stats = cachedTrack?.stats; + const tags = cachedTrack?.tags; + const now = new Date().toISOString(); + + /** @type {Track} */ + const track = { + $type: "sh.diffuse.output.track", + id: trackId, + createdAt: cachedTrack?.createdAt ?? now, + updatedAt: cachedTrack?.updatedAt ?? now, + stats, + tags, + uri: buildURI(server, path), + }; + + return track; + }); + + if (!tracks.length) { + const now = new Date().toISOString(); + + tracks = [{ + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(server), + }]; + } + + return tracks; + }); + + return (await Promise.all(promises)).flat(1); +} + +/** + * @type {Actions['resolve']} + */ +export async function resolve({ uri }) { + const parsed = parseURI(uri); + if (!parsed || !parsed.path) return undefined; + + const url = buildTrackUrl(parsed.server, parsed.path); + const expiresAt = Math.round(Date.now() / 1000) + 60 * 60 * 24 * 365; + + return { url, expiresAt }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { + artwork, + consult, + detach, + groupConsult, + list, + resolve, + }); +}); diff --git a/src/components/metadata/common.js b/src/components/metadata/common.js index 0f1b0760..9321d2ad 100644 --- a/src/components/metadata/common.js +++ b/src/components/metadata/common.js @@ -39,7 +39,7 @@ export async function musicMetadataTags({ const getHeadInfo = httpClient.getHeadInfo; // FUCKAROUND: Not sure of the downsides of this - httpClient.getHeadInfo = async () => { + /** @type {any} */ (httpClient).getHeadInfo = async () => { try { const info = await getHeadInfo.call(httpClient); return { ...info, acceptPartialRequests: true }; diff --git a/src/components/orchestrator/offline/element.js b/src/components/orchestrator/offline/element.js index 97ae6628..7406a1c3 100644 --- a/src/components/orchestrator/offline/element.js +++ b/src/components/orchestrator/offline/element.js @@ -35,7 +35,7 @@ class OfflineOrchestrator extends DiffuseElement { const src = this.getAttribute("src"); const swUrl = new URL( - src ?? import.meta.resolve("../../../service-worker-offline.js"), + src ?? import.meta.resolve("../../../service-worker.js"), ); swUrl.searchParams.set("cache-name", cacheName); diff --git a/src/components/orchestrator/process-tracks/element.js b/src/components/orchestrator/process-tracks/element.js index 95a2ec94..a89cef27 100644 --- a/src/components/orchestrator/process-tracks/element.js +++ b/src/components/orchestrator/process-tracks/element.js @@ -119,6 +119,9 @@ class ProcessTracksOrchestrator extends BroadcastableDiffuseElement { listen("progress", this.#progress.set, link); this.#proxy.progress().then(this.#progress.set); + // Save intermediate batches as they arrive so progress isn't lost + listen("batch", (tracks) => this.output?.tracks.save(tracks), link); + // Process whenever tracks are initially loaded; // unless already done so (possibly through another instance of this element) if (this.hasAttribute("process-when-ready")) { diff --git a/src/components/orchestrator/process-tracks/worker.js b/src/components/orchestrator/process-tracks/worker.js index 7b28bb65..e88f4812 100644 --- a/src/components/orchestrator/process-tracks/worker.js +++ b/src/components/orchestrator/process-tracks/worker.js @@ -26,9 +26,10 @@ const $progress = signal({ processed: 0, total: 0 }, { //////////////////////////////////////////// /** - * @type {ActionsWithTunnel["process"]} + * @param {any} context + * @returns {ActionsWithTunnel["process"]} */ -export async function process({ data, ports }) { +const process = (context) => /** @type {ActionsWithTunnel["process"]} */ (async ({ data, ports }) => { const cachedTracks = data; // Reset progress @@ -46,11 +47,17 @@ export async function process({ data, ports }) { // List const tracks = await input.list(cachedTracks); + // Persist the full track list immediately so that an interrupted metadata + // processing run doesn't lose discovered tracks. On next run they'll come + // back as cachedTracks and only the ones without metadata need reprocessing. + announce("batch", tracks, context); + // Reset progress $progress.value = { processed: 0, total: tracks.length }; // Fetch metadata if needed let processed = 0; + const BATCH_SIZE = 100; const tracksWithMetadata = await tracks.reduce( /** @@ -63,7 +70,9 @@ export async function process({ data, ports }) { if ((track.tags && track.stats) || track.kind === "placeholder") { processed++; $progress.value = { processed, total: tracks.length }; - return [...acc, track]; + const result = [...acc, track]; + if (processed % BATCH_SIZE === 0) announce("batch", result, context); + return result; } const patched = await metadata.patch(track); @@ -71,7 +80,9 @@ export async function process({ data, ports }) { processed++; $progress.value = { processed, total: tracks.length }; - return [...acc, patched]; + const result = [...acc, patched]; + if (processed % BATCH_SIZE === 0) announce("batch", result, context); + return result; }, Promise.resolve([]), ); @@ -83,14 +94,14 @@ export async function process({ data, ports }) { // Save if changed if (changed) return tracksWithMetadata; return null; -} +}); //////////////////////////////////////////// // ⚡️ //////////////////////////////////////////// ostiary((context) => { - rpc(context, { process, progress: $progress.get }); + rpc(context, { process: process(context), progress: $progress.get }); // Communicate state effect(() => announce("progress", $progress.value, context)); diff --git a/src/components/orchestrator/scoped-tracks/element.js b/src/components/orchestrator/scoped-tracks/element.js index ecd7f0a4..287dfffa 100644 --- a/src/components/orchestrator/scoped-tracks/element.js +++ b/src/components/orchestrator/scoped-tracks/element.js @@ -200,7 +200,7 @@ class ScopedTracksOrchestrator extends BroadcastableDiffuseElement { }); const availableTracks = tracks.filter((t) => { - return availableUris.has(t.uri); + return availableUris.has(t.uri) && !!t.tags; }); // Set pool @@ -281,7 +281,13 @@ class ScopedTracksOrchestrator extends BroadcastableDiffuseElement { return a.groupKey.localeCompare(b.groupKey) * groupDir; } for (let i = 0; i < a.fieldVals.length; i++) { - const cmp = compareValues(a.fieldVals[i], b.fieldVals[i]); + const av = a.fieldVals[i]; + const bv = b.fieldVals[i]; + // Null/undefined always sorts last regardless of direction + if (av == null && bv == null) continue; + if (av == null) return 1; + if (bv == null) return -1; + const cmp = compareValues(av, bv); if (cmp !== 0) return cmp * userDir; } return 0; diff --git a/src/components/orchestrator/scoped-tracks/worker.js b/src/components/orchestrator/scoped-tracks/worker.js index 381fdd73..cc335e83 100644 --- a/src/components/orchestrator/scoped-tracks/worker.js +++ b/src/components/orchestrator/scoped-tracks/worker.js @@ -66,7 +66,7 @@ export async function supply({ tracks }) { const tracksMap = new Map(); for (const track of tracks) { - if (!track.tags) return; + if (!track.tags) continue; tracksMap.set(track.id, track); } diff --git a/src/facets/connect/index.inline.js b/src/facets/connect/index.inline.js index c1b2d09e..b428747d 100644 --- a/src/facets/connect/index.inline.js +++ b/src/facets/connect/index.inline.js @@ -17,6 +17,7 @@ const icons = { "facets/connect/local/index.html": "folder-open", "facets/connect/opensubsonic/index.html": "broadcast", "facets/connect/s3/index.html": "hard-drives", + "facets/connect/webdav/index.html": "hard-drive", }; const facets = facetsData diff --git a/src/facets/connect/webdav/index.html b/src/facets/connect/webdav/index.html new file mode 100644 index 00000000..c1eb4e51 --- /dev/null +++ b/src/facets/connect/webdav/index.html @@ -0,0 +1,12 @@ + + +
+ + diff --git a/src/facets/connect/webdav/index.inline.js b/src/facets/connect/webdav/index.inline.js new file mode 100644 index 00000000..b643a6ab --- /dev/null +++ b/src/facets/connect/webdav/index.inline.js @@ -0,0 +1,156 @@ +import * as TID from "@atcute/tid"; +import { html } from "lit-html"; + +import * as Output from "~/common/output.js"; +import { SCHEME } from "~/components/input/webdav/constants.js"; +import { buildURI, parseURI, serverId } from "~/components/input/webdav/common.js"; +import { effect } from "~/common/signal.js"; +import foundation from "~/common/foundation.js"; + +import { setup } from "~/facets/connect/common.js"; + +/** + * @import { Server } from "~/components/input/webdav/types.d.ts" + */ + +foundation.setup({ title: "Connect WebDAV | Diffuse" }); + +//////////////////////////////////////////// +// SETUP +//////////////////////////////////////////// + +const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = + await Promise.all([ + foundation.configurator.input(), + foundation.orchestrator.output(), + foundation.orchestrator.sources(), + ]); + +await Promise.all([ + customElements.whenDefined(inputConfigurator.localName), + customElements.whenDefined(outputOrchestrator.localName), + customElements.whenDefined(sourcesOrchestrator.localName), +]); + +//////////////////////////////////////////// +// UI +//////////////////////////////////////////// + +const { setItems, setError } = setup({ + title: "WebDAV", + hasOutput: false, + + description: html` +

+ Connect to a WebDAV server to use it as an audio source. +

+ `, + + formFields: html` + + + + +

* Required

+ `, + + onSubmit: () => addServer(), +}); + +const hostInput = + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-host")); +const dirInput = + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-dir")); +const usernameInput = + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-username")); +const passwordInput = + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-password")); + +//////////////////////////////////////////// +// REACTIVE LIST +//////////////////////////////////////////// + +effect(() => { + const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? []; + + /** @type {Map} */ + const allServers = new Map(); + + for (const source of inputSources) { + const parsed = parseURI(source.uri); + if (!parsed) continue; + + const id = serverId(parsed.server); + if (!allServers.has(id)) { + allServers.set(id, { server: parsed.server, uri: source.uri }); + } + } + + setItems( + [...allServers.values()].map(({ server, uri }) => ({ + name: server.host, + detail: server.dir, + isInput: true, + isOutput: false, + isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(uri), + onRemove: () => removeServer(uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), + })), + ); +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** @param {string} uri */ +async function removeServer(uri) { + setError(null); + try { + const tracks = await Output.data(outputOrchestrator.tracks); + const detachedTracks = await inputConfigurator.detach({ + fileUriOrScheme: uri, + tracks, + }); + + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove server"); + } +} + +async function addServer() { + const host = hostInput.value?.trim(); + const dir = dirInput.value?.trim() || "/"; + const username = usernameInput.value?.trim() || ""; + const password = passwordInput.value?.trim() || ""; + + if (!host) return; + + /** @type {Server} */ + const server = { host, dir, username, password }; + const uri = buildURI(server); + + const now = new Date().toISOString(); + const tracksCol = outputOrchestrator.tracks.collection(); + const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; + + await outputOrchestrator.tracks.save([ + ...existingTracks, + { + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri, + }, + ]); +} + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/facets/data/input-bundle/index.inline.js b/src/facets/data/input-bundle/index.inline.js index 704bbd04..c9dab608 100644 --- a/src/facets/data/input-bundle/index.inline.js +++ b/src/facets/data/input-bundle/index.inline.js @@ -8,6 +8,7 @@ import { NAME as ICECAST_NAME } from "~/components/input/icecast/element.js"; import { NAME as LOCAL_NAME } from "~/components/input/local/element.js"; import { NAME as OPENSUBSONIC_NAME } from "~/components/input/opensubsonic/element.js"; import { NAME as S3_NAME } from "~/components/input/s3/element.js"; +import { NAME as WEBDAV_NAME } from "~/components/input/webdav/element.js"; /** @@ -28,6 +29,7 @@ effect(() => { local(input); opensubsonic(input); s3(input); + webdav(input); }); //////////////////////////////////////////// @@ -106,3 +108,14 @@ export function opensubsonic(input) { export function s3(input) { input.append(document.createElement(S3_NAME)); } + +//////////////////////////////////////////// +// WEBDAV +//////////////////////////////////////////// + +/** + * @param {InputConfigurator} input + */ +export function webdav(input) { + input.append(document.createElement(WEBDAV_NAME)); +} diff --git a/src/service-worker-offline.js b/src/service-worker.js similarity index 79% rename from src/service-worker-offline.js rename to src/service-worker.js index 1739f6b0..5cd698b0 100644 --- a/src/service-worker-offline.js +++ b/src/service-worker.js @@ -51,13 +51,56 @@ self.addEventListener("fetch", (_event) => { const event = /** @type {FetchEvent} */ (_event); const { request } = event; - // Only intercept GET requests over http(s). - if (request.method !== "GET") return; if (!request.url.startsWith("http")) return; + // Intercept credentialed URLs before the offline cache (any method). + const intercepted = interceptCredentials(request); + if (intercepted) { + event.respondWith(intercepted); + return; + } + + // Only cache GET requests. + if (request.method !== "GET") return; + event.respondWith(handleFetch(request)); }); +//////////////////////////////////////////// +// CREDENTIAL INTERCEPT +//////////////////////////////////////////// + +/** + * If the request URL contains a `_auth` query parameter (base64 Basic credentials), + * strip it and re-issue the request with a proper Authorization header instead. + * Also handles the legacy `user:pass@host` form for any callers that still use it. + * + * Returns a Promise when credentials are detected, or null to fall through. + * + * @param {Request} request + * @returns {Promise | null} + */ +function interceptCredentials(request) { + const url = new URL(request.url); + + if (url.username) { + const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; + url.username = ""; + url.password = ""; + const headers = new Headers(request.headers); + headers.set("Authorization", `Basic ${btoa(unescape(encodeURIComponent(credentials)))}`); + return fetch(url.href, { method: request.method, headers, signal: request.signal }); + } + + const auth = url.searchParams.get("diffuse:basic-auth"); + if (!auth) return null; + + url.searchParams.delete("diffuse:basic-auth"); + const headers = new Headers(request.headers); + headers.set("Authorization", `Basic ${auth}`); + return fetch(url.href, { method: request.method, headers, signal: request.signal }); +} + //////////////////////////////////////////// // CONTENT-ADDRESSED CACHE //////////////////////////////////////////// diff --git a/src/themes/blur/browser/element.css b/src/themes/blur/browser/element.css index 2ec9e7d5..60f6666d 100644 --- a/src/themes/blur/browser/element.css +++ b/src/themes/blur/browser/element.css @@ -208,6 +208,7 @@ .scroll-panel { flex: 1; + overflow-anchor: none; overflow-y: auto; min-height: 0; user-select: none; diff --git a/src/themes/blur/browser/element.js b/src/themes/blur/browser/element.js index d47efb7a..063e2210 100644 --- a/src/themes/blur/browser/element.js +++ b/src/themes/blur/browser/element.js @@ -462,14 +462,13 @@ class Browser extends DiffuseElement { } #setupVirtualizer() { - const panel = this.root().querySelector(".scroll-panel"); - if (!panel) return; + if (!this.root().querySelector(".scroll-panel")) return; this.#virtualizerCleanup?.(); this.#virtualizer = new Virtualizer({ count: 0, - getScrollElement: () => panel, + getScrollElement: () => this.root().querySelector(".scroll-panel"), estimateSize: (i) => this.#flatItems[i]?.type === "group" ? GROUP_HEADER_HEIGHT @@ -486,6 +485,14 @@ class Browser extends DiffuseElement { this.#virtualizerCleanup = this.#virtualizer._didMount(); this.#virtualizer._willUpdate(); this.forceRender(); + + // Reset scroll after browser scroll restoration, which fires asynchronously + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const panel = this.root().querySelector(".scroll-panel"); + if (panel && panel.scrollTop !== 0) panel.scrollTop = 0; + }); + }); } #disconnectCoverObserver() {