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

Configure Feed

Select the types of activity you want to include in your feed.

feat: webdav input + batch processing

+839 -17
+1
deno.jsonc
··· 36 36 "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2", 37 37 "@std/html": "jsr:@std/html@^1.0.5", 38 38 "@std/semver": "jsr:@std/semver@^1.0.8", 39 + "@std/xml": "jsr:@std/xml@^0.1.0", 39 40 "@vicary/debounce-microtask": "jsr:@vicary/debounce-microtask@^0.1.8", 40 41 "@tanstack/virtual-core": "npm:@tanstack/virtual-core@^3.13.0", 41 42 "alien-signals": "npm:alien-signals@^3.1.2",
+7 -1
src/_data/facets.json
··· 98 98 "desc": "Connect to an S3-compatible storage for audio input or user-data storage." 99 99 }, 100 100 { 101 + "url": "facets/connect/webdav/index.html", 102 + "title": "Connect / WebDAV", 103 + "category": "Data", 104 + "desc": "Connect to a WebDAV server for audio input." 105 + }, 106 + { 101 107 "url": "facets/data/artwork-bundle/index.html", 102 108 "title": "Default Artwork Bundle", 103 109 "kind": "prelude", ··· 111 117 "kind": "prelude", 112 118 "category": "Data", 113 119 "tags": ["base"], 114 - "desc": "The default setup for audio input sources. Adds support for: Dropbox, HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." 120 + "desc": "The default setup for audio input sources. Adds support for: Dropbox, HTTPS, Icecast, the local filesystem, OpenSubsonic, S3-compatible storage, and WebDAV." 115 121 }, 116 122 { 117 123 "url": "facets/data/metadata-bundle/index.html",
+308
src/components/input/webdav/common.js
··· 1 + import { parse as parseXml } from "@std/xml"; 2 + import * as URI from "fast-uri"; 3 + import QS from "query-string"; 4 + 5 + import { cachedConsult } from "~/components/input/common.js"; 6 + import { SCHEME } from "./constants.js"; 7 + 8 + /** 9 + * @import { Track } from "~/definitions/types.d.ts"; 10 + * @import { Server } from "./types.d.ts"; 11 + */ 12 + 13 + //////////////////////////////////////////// 14 + // 🛠️ 15 + //////////////////////////////////////////// 16 + 17 + /** 18 + * Build an HTTP(S) URL with credentials in a query param for the service worker to intercept. 19 + * Credentials go in `?_auth=<base64>` rather than `user:pass@host` because browsers 20 + * block `new Request()` with credentials in the URL authority (which music-metadata uses). 21 + * 22 + * @param {Server} server 23 + * @param {string} [filePath] 24 + */ 25 + export function buildTrackUrl(server, filePath = "") { 26 + const url = new URL(toHttpUrl(server, filePath)); 27 + url.searchParams.set("diffuse:basic-auth", btoa(unescape(encodeURIComponent(`${server.username}:${server.password}`)))); 28 + return url.href; 29 + } 30 + 31 + /** 32 + * @param {Server} server 33 + */ 34 + export function serverId(server) { 35 + return `${server.username}:${server.password}@${server.host}${server.dir}`; 36 + } 37 + 38 + /** 39 + * @param {Server} server 40 + * @param {string} [filePath] 41 + */ 42 + export function buildURI(server, filePath = "") { 43 + let host = server.host; 44 + let protocol; 45 + 46 + if (host.includes("://")) { 47 + [protocol, host] = host.split("://"); 48 + } 49 + 50 + return URI.serialize({ 51 + scheme: SCHEME, 52 + userinfo: `${encodeURIComponent(server.username)}:${encodeURIComponent(server.password)}`, 53 + host, 54 + path: filePath, 55 + query: QS.stringify({ dir: server.dir, protocol }), 56 + }); 57 + } 58 + 59 + /** 60 + * @param {string} uriString 61 + * @returns {{ server: Server; path: string } | undefined} 62 + */ 63 + export function parseURI(uriString) { 64 + const uri = URI.parse(uriString); 65 + if (uri.scheme !== SCHEME) return undefined; 66 + if (!uri.host) return undefined; 67 + 68 + const userinfo = uri.userinfo ?? ""; 69 + const colonIdx = userinfo.indexOf(":"); 70 + const username = decodeURIComponent(colonIdx >= 0 ? userinfo.slice(0, colonIdx) : userinfo); 71 + const password = decodeURIComponent(colonIdx >= 0 ? userinfo.slice(colonIdx + 1) : ""); 72 + 73 + const qs = QS.parse(uri.query || ""); 74 + const dir = typeof qs.dir === "string" ? qs.dir : "/"; 75 + const protocol = typeof qs.protocol === "string" ? qs.protocol : undefined; 76 + 77 + const rawHost = uri.port ? `${uri.host}:${uri.port}` : uri.host; 78 + const host = protocol ? `${protocol}://${rawHost}` : rawHost; 79 + const server = { username, password, host, dir }; 80 + const path = uri.path || ""; 81 + 82 + return { server, path }; 83 + } 84 + 85 + /** 86 + * @param {Server} server 87 + * @param {string} [path] 88 + */ 89 + export function toHttpUrl(server, path = "") { 90 + const base = server.host.includes("://") 91 + ? server.host 92 + : `${server.host.split(":")[0] === "localhost" || server.host.split(":")[0] === "127.0.0.1" ? "http" : "https"}://${server.host}`; 93 + 94 + return base.replace(/\/$/, "") + (path ? "/" + path.replace(/^\//, "") : ""); 95 + } 96 + 97 + /** 98 + * @param {Server} server 99 + */ 100 + export function authHeader(server) { 101 + return `Basic ${btoa(unescape(encodeURIComponent(`${server.username}:${server.password}`)))}`; 102 + } 103 + 104 + /** 105 + * @param {Track[]} tracks 106 + * @returns {Record<string, Server>} 107 + */ 108 + export function serversFromTracks(tracks) { 109 + /** @type {Record<string, Server>} */ 110 + const acc = {}; 111 + 112 + tracks.forEach((track) => { 113 + const parsed = parseURI(track.uri); 114 + if (!parsed) return; 115 + 116 + const id = serverId(parsed.server); 117 + if (!acc[id]) acc[id] = parsed.server; 118 + }); 119 + 120 + return acc; 121 + } 122 + 123 + /** 124 + * @param {Track[]} tracks 125 + * @returns {Record<string, { server: Server; tracks: Track[] }>} 126 + */ 127 + export function groupTracksByServer(tracks) { 128 + /** @type {Record<string, { server: Server; tracks: Track[] }>} */ 129 + const acc = {}; 130 + 131 + tracks.forEach((track) => { 132 + const parsed = parseURI(track.uri); 133 + if (!parsed) return; 134 + 135 + const id = serverId(parsed.server); 136 + 137 + if (acc[id]) { 138 + acc[id].tracks.push(track); 139 + } else { 140 + acc[id] = { server: parsed.server, tracks: [track] }; 141 + } 142 + }); 143 + 144 + return acc; 145 + } 146 + 147 + /** 148 + * @param {string[]} uris 149 + * @returns {Record<string, { server: Server; uris: string[] }>} 150 + */ 151 + export function groupUrisByServer(uris) { 152 + /** @type {Record<string, { server: Server; uris: string[] }>} */ 153 + const acc = {}; 154 + 155 + uris.forEach((uri) => { 156 + const parsed = parseURI(uri); 157 + if (!parsed) return; 158 + 159 + const id = serverId(parsed.server); 160 + 161 + if (acc[id]) { 162 + acc[id].uris.push(uri); 163 + } else { 164 + acc[id] = { server: parsed.server, uris: [uri] }; 165 + } 166 + }); 167 + 168 + return acc; 169 + } 170 + 171 + /** 172 + * @param {Server} server 173 + */ 174 + async function checkAccess(server) { 175 + try { 176 + const url = toHttpUrl(server, server.dir); 177 + const controller = new AbortController(); 178 + const timeoutId = setTimeout(() => controller.abort(), 5000); 179 + 180 + const response = await fetch(url, { 181 + method: "PROPFIND", 182 + headers: { 183 + "Authorization": authHeader(server), 184 + "Depth": "0", 185 + }, 186 + signal: controller.signal, 187 + }); 188 + 189 + clearTimeout(timeoutId); 190 + return response.status === 207 || response.ok; 191 + } catch { 192 + return false; 193 + } 194 + } 195 + 196 + export const checkAccessCached = cachedConsult(checkAccess, serverId); 197 + 198 + /** 199 + * List all files on a WebDAV server under server.dir. 200 + * Uses Depth:1 and recurses into subdirectories to avoid loading the 201 + * entire tree in one response. 202 + * 203 + * @param {Server} server 204 + * @returns {Promise<string[]>} 205 + */ 206 + export async function listFiles(server) { 207 + const paths = /** @type {string[]} */ ([]); 208 + await propfindDir(server, server.dir, paths); 209 + return paths; 210 + } 211 + 212 + /** 213 + * @param {Server} server 214 + * @param {string} dir 215 + * @param {string[]} paths 216 + */ 217 + async function propfindDir(server, dir, paths) { 218 + const url = toHttpUrl(server, dir); 219 + 220 + const response = await fetch(url, { 221 + method: "PROPFIND", 222 + headers: { 223 + "Authorization": authHeader(server), 224 + "Depth": "1", 225 + }, 226 + }); 227 + 228 + if (response.status !== 207 && !response.ok) return; 229 + 230 + const xml = await response.text(); 231 + const subdirs = /** @type {string[]} */ ([]); 232 + 233 + const doc = parseXml(xml); 234 + const multistatus = doc.root; 235 + if (!multistatus) return; 236 + 237 + for (const node of multistatus.children ?? []) { 238 + if (node.type !== "element" || node.name.local !== "response") continue; 239 + 240 + let href = ""; 241 + let isCollection = false; 242 + 243 + for (const child of node.children ?? []) { 244 + if (child.type !== "element") continue; 245 + 246 + if (child.name.local === "href") { 247 + href = (child.children?.find((n) => n.type === "text")?.text ?? "").trim(); 248 + } else if (child.name.local === "propstat") { 249 + if (propstatHasCollection(child)) isCollection = true; 250 + } 251 + } 252 + 253 + if (!href) continue; 254 + 255 + // Trailing slash is the most reliable collection indicator in WebDAV 256 + isCollection = isCollection || href.endsWith("/"); 257 + 258 + // Keep the raw (percent-encoded) pathname for recursion so that 259 + // toHttpUrl produces a valid URL; decode only for the final paths list. 260 + let rawPath; 261 + try { 262 + rawPath = new URL(href).pathname; 263 + } catch { 264 + rawPath = href; 265 + } 266 + const path = decodeURIComponent(rawPath); 267 + 268 + // Skip the directory entry itself. 269 + // Normalise both sides to have a leading slash — server hrefs always do, 270 + // but `dir` may not when the user omitted the leading slash in the form. 271 + const normPath = path.replace(/\/$/, ""); 272 + const normDir = ("/" + decodeURIComponent(dir).replace(/^\//, "")).replace(/\/$/, ""); 273 + if (normPath === normDir) continue; 274 + 275 + // Skip Synology extended-attribute metadata folders 276 + if (path.split("/").includes("@eaDir")) continue; 277 + 278 + if (isCollection) { 279 + subdirs.push(rawPath); 280 + } else { 281 + paths.push(path); 282 + } 283 + } 284 + 285 + for (const subdir of subdirs) { 286 + await propfindDir(server, subdir, paths); 287 + } 288 + } 289 + 290 + /** 291 + * Check propstat > prop > resourcetype > collection (DAV spec path). 292 + * Using `||=` in the caller means multiple propstat elements don't overwrite a true result. 293 + * 294 + * @param {{ children?: ReadonlyArray<{ type: string; name?: { local: string }; children?: ReadonlyArray<any> }> }} propstat 295 + * @returns {boolean} 296 + */ 297 + function propstatHasCollection(propstat) { 298 + for (const prop of propstat.children ?? []) { 299 + if (prop.type !== "element" || prop.name?.local !== "prop") continue; 300 + for (const child of prop.children ?? []) { 301 + if (child.type !== "element" || child.name?.local !== "resourcetype") continue; 302 + for (const rt of child.children ?? []) { 303 + if (rt.type === "element" && rt.name?.local === "collection") return true; 304 + } 305 + } 306 + } 307 + return false; 308 + }
+1
src/components/input/webdav/constants.js
··· 1 + export const SCHEME = "webdav";
+59
src/components/input/webdav/element.js
··· 1 + import { defineElement, DiffuseElement } from "~/common/element.js"; 2 + import { SCHEME } from "./constants.js"; 3 + import { buildURI, serversFromTracks } from "./common.js"; 4 + 5 + /** 6 + * @import {InputActions, InputSchemeProvider} from "~/components/input/types.d.ts" 7 + * @import {ProxiedActions} from "~/common/worker.d.ts" 8 + * @import {Track} from "~/definitions/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // ELEMENT 13 + //////////////////////////////////////////// 14 + 15 + /** 16 + * @implements {ProxiedActions<InputActions>} 17 + * @implements {InputSchemeProvider} 18 + */ 19 + class WebdavInput extends DiffuseElement { 20 + static NAME = "diffuse/input/webdav"; 21 + static WORKER_URL = "components/input/webdav/worker.js"; 22 + 23 + SCHEME = SCHEME; 24 + 25 + constructor() { 26 + super(); 27 + 28 + /** @type {ProxiedActions<InputActions>} */ 29 + this.proxy = this.workerProxy(); 30 + 31 + this.artwork = this.proxy.artwork; 32 + this.consult = this.proxy.consult; 33 + this.detach = this.proxy.detach; 34 + this.groupConsult = this.proxy.groupConsult; 35 + this.list = this.proxy.list; 36 + this.resolve = this.proxy.resolve; 37 + } 38 + 39 + // 🛠️ 40 + 41 + /** @param {Track[]} tracks */ 42 + sources(tracks) { 43 + return Object.values(serversFromTracks(tracks)).map((server) => ({ 44 + label: `WebDAV (${server.host}${server.dir})`, 45 + uri: buildURI(server), 46 + })); 47 + } 48 + } 49 + 50 + export default WebdavInput; 51 + 52 + //////////////////////////////////////////// 53 + // REGISTER 54 + //////////////////////////////////////////// 55 + 56 + export const CLASS = WebdavInput; 57 + export const NAME = "di-webdav"; 58 + 59 + defineElement(NAME, CLASS);
+6
src/components/input/webdav/types.d.ts
··· 1 + export type Server = { 2 + username: string; 3 + password: string; 4 + host: string; 5 + dir: string; 6 + };
+188
src/components/input/webdav/worker.js
··· 1 + import * as TID from "@atcute/tid"; 2 + import { ostiary, rpc } from "~/common/worker.js"; 3 + import { 4 + detach as detachUtil, 5 + groupKey, 6 + isAudioFile, 7 + } from "~/components/input/common.js"; 8 + 9 + import { 10 + buildTrackUrl, 11 + buildURI, 12 + checkAccessCached, 13 + groupTracksByServer, 14 + groupUrisByServer, 15 + listFiles, 16 + parseURI, 17 + serverId, 18 + } from "./common.js"; 19 + import { SCHEME } from "./constants.js"; 20 + 21 + /** 22 + * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; 23 + * @import { Track } from "~/definitions/types.d.ts"; 24 + */ 25 + 26 + //////////////////////////////////////////// 27 + // ACTIONS 28 + //////////////////////////////////////////// 29 + 30 + /** 31 + * @type {Actions['artwork']} 32 + */ 33 + export async function artwork(_uri) { 34 + return null; 35 + } 36 + 37 + /** 38 + * @type {Actions['consult']} 39 + */ 40 + export async function consult(fileUriOrScheme) { 41 + if (!fileUriOrScheme.includes(":")) { 42 + return { supported: true, consult: "undetermined" }; 43 + } 44 + 45 + const parsed = parseURI(fileUriOrScheme); 46 + if (!parsed) return { supported: true, consult: "undetermined" }; 47 + 48 + const accessible = await checkAccessCached(parsed.server); 49 + return { supported: true, consult: accessible }; 50 + } 51 + 52 + /** 53 + * @type {Actions['detach']} 54 + */ 55 + export async function detach(args) { 56 + return detachUtil({ 57 + ...args, 58 + 59 + inputScheme: SCHEME, 60 + handleFileUri: ({ fileURI, tracks }) => { 61 + const result = parseURI(fileURI); 62 + if (!result) return tracks; 63 + 64 + const id = serverId(result.server); 65 + const groups = groupTracksByServer(tracks); 66 + 67 + delete groups[id]; 68 + 69 + return Object.values(groups).map((g) => g.tracks).flat(1); 70 + }, 71 + }); 72 + } 73 + 74 + /** 75 + * @type {Actions['groupConsult']} 76 + */ 77 + export async function groupConsult(uris) { 78 + const groups = groupUrisByServer(uris); 79 + 80 + const promises = Object.entries(groups).map( 81 + async ([id, { server, uris }]) => { 82 + const available = await checkAccessCached(server); 83 + 84 + /** @type {ConsultGrouping} */ 85 + const grouping = available 86 + ? { available, scheme: SCHEME, uris } 87 + : { available, reason: "WebDAV server unreachable", scheme: SCHEME, uris }; 88 + 89 + return { key: groupKey(SCHEME, id), grouping }; 90 + }, 91 + ); 92 + 93 + const entries = (await Promise.all(promises)).map((e) => [e.key, e.grouping]); 94 + return Object.fromEntries(entries); 95 + } 96 + 97 + /** 98 + * @type {Actions['list']} 99 + */ 100 + export async function list(cachedTracks = []) { 101 + /** @type {Record<string, Record<string, Track>>} */ 102 + const cache = {}; 103 + 104 + const groups = groupTracksByServer(cachedTracks); 105 + 106 + Object.entries(groups).forEach(([id, { tracks }]) => { 107 + tracks.forEach((track) => { 108 + const parsed = parseURI(track.uri); 109 + if (!parsed) return; 110 + 111 + if (!cache[id]) cache[id] = {}; 112 + cache[id][parsed.path] = track; 113 + }); 114 + }); 115 + 116 + const promises = Object.entries(groups).map(async ([id, { server }]) => { 117 + const files = await listFiles(server); 118 + 119 + let tracks = files 120 + .filter((path) => isAudioFile(path)) 121 + .map((path) => { 122 + const cachedTrack = cache[id]?.[path]; 123 + 124 + const trackId = cachedTrack?.id || TID.now(); 125 + const stats = cachedTrack?.stats; 126 + const tags = cachedTrack?.tags; 127 + const now = new Date().toISOString(); 128 + 129 + /** @type {Track} */ 130 + const track = { 131 + $type: "sh.diffuse.output.track", 132 + id: trackId, 133 + createdAt: cachedTrack?.createdAt ?? now, 134 + updatedAt: cachedTrack?.updatedAt ?? now, 135 + stats, 136 + tags, 137 + uri: buildURI(server, path), 138 + }; 139 + 140 + return track; 141 + }); 142 + 143 + if (!tracks.length) { 144 + const now = new Date().toISOString(); 145 + 146 + tracks = [{ 147 + $type: "sh.diffuse.output.track", 148 + id: TID.now(), 149 + createdAt: now, 150 + updatedAt: now, 151 + kind: "placeholder", 152 + uri: buildURI(server), 153 + }]; 154 + } 155 + 156 + return tracks; 157 + }); 158 + 159 + return (await Promise.all(promises)).flat(1); 160 + } 161 + 162 + /** 163 + * @type {Actions['resolve']} 164 + */ 165 + export async function resolve({ uri }) { 166 + const parsed = parseURI(uri); 167 + if (!parsed || !parsed.path) return undefined; 168 + 169 + const url = buildTrackUrl(parsed.server, parsed.path); 170 + const expiresAt = Math.round(Date.now() / 1000) + 60 * 60 * 24 * 365; 171 + 172 + return { url, expiresAt }; 173 + } 174 + 175 + //////////////////////////////////////////// 176 + // ⚡️ 177 + //////////////////////////////////////////// 178 + 179 + ostiary((context) => { 180 + rpc(context, { 181 + artwork, 182 + consult, 183 + detach, 184 + groupConsult, 185 + list, 186 + resolve, 187 + }); 188 + });
+1 -1
src/components/metadata/common.js
··· 39 39 const getHeadInfo = httpClient.getHeadInfo; 40 40 41 41 // FUCKAROUND: Not sure of the downsides of this 42 - httpClient.getHeadInfo = async () => { 42 + /** @type {any} */ (httpClient).getHeadInfo = async () => { 43 43 try { 44 44 const info = await getHeadInfo.call(httpClient); 45 45 return { ...info, acceptPartialRequests: true };
+1 -1
src/components/orchestrator/offline/element.js
··· 35 35 const src = this.getAttribute("src"); 36 36 37 37 const swUrl = new URL( 38 - src ?? import.meta.resolve("../../../service-worker-offline.js"), 38 + src ?? import.meta.resolve("../../../service-worker.js"), 39 39 ); 40 40 41 41 swUrl.searchParams.set("cache-name", cacheName);
+3
src/components/orchestrator/process-tracks/element.js
··· 119 119 listen("progress", this.#progress.set, link); 120 120 this.#proxy.progress().then(this.#progress.set); 121 121 122 + // Save intermediate batches as they arrive so progress isn't lost 123 + listen("batch", (tracks) => this.output?.tracks.save(tracks), link); 124 + 122 125 // Process whenever tracks are initially loaded; 123 126 // unless already done so (possibly through another instance of this element) 124 127 if (this.hasAttribute("process-when-ready")) {
+17 -6
src/components/orchestrator/process-tracks/worker.js
··· 26 26 //////////////////////////////////////////// 27 27 28 28 /** 29 - * @type {ActionsWithTunnel<Actions>["process"]} 29 + * @param {any} context 30 + * @returns {ActionsWithTunnel<Actions>["process"]} 30 31 */ 31 - export async function process({ data, ports }) { 32 + const process = (context) => /** @type {ActionsWithTunnel<Actions>["process"]} */ (async ({ data, ports }) => { 32 33 const cachedTracks = data; 33 34 34 35 // Reset progress ··· 46 47 // List 47 48 const tracks = await input.list(cachedTracks); 48 49 50 + // Persist the full track list immediately so that an interrupted metadata 51 + // processing run doesn't lose discovered tracks. On next run they'll come 52 + // back as cachedTracks and only the ones without metadata need reprocessing. 53 + announce("batch", tracks, context); 54 + 49 55 // Reset progress 50 56 $progress.value = { processed: 0, total: tracks.length }; 51 57 52 58 // Fetch metadata if needed 53 59 let processed = 0; 60 + const BATCH_SIZE = 100; 54 61 55 62 const tracksWithMetadata = await tracks.reduce( 56 63 /** ··· 63 70 if ((track.tags && track.stats) || track.kind === "placeholder") { 64 71 processed++; 65 72 $progress.value = { processed, total: tracks.length }; 66 - return [...acc, track]; 73 + const result = [...acc, track]; 74 + if (processed % BATCH_SIZE === 0) announce("batch", result, context); 75 + return result; 67 76 } 68 77 69 78 const patched = await metadata.patch(track); ··· 71 80 processed++; 72 81 $progress.value = { processed, total: tracks.length }; 73 82 74 - return [...acc, patched]; 83 + const result = [...acc, patched]; 84 + if (processed % BATCH_SIZE === 0) announce("batch", result, context); 85 + return result; 75 86 }, 76 87 Promise.resolve([]), 77 88 ); ··· 83 94 // Save if changed 84 95 if (changed) return tracksWithMetadata; 85 96 return null; 86 - } 97 + }); 87 98 88 99 //////////////////////////////////////////// 89 100 // ⚡️ 90 101 //////////////////////////////////////////// 91 102 92 103 ostiary((context) => { 93 - rpc(context, { process, progress: $progress.get }); 104 + rpc(context, { process: process(context), progress: $progress.get }); 94 105 95 106 // Communicate state 96 107 effect(() => announce("progress", $progress.value, context));
+8 -2
src/components/orchestrator/scoped-tracks/element.js
··· 200 200 }); 201 201 202 202 const availableTracks = tracks.filter((t) => { 203 - return availableUris.has(t.uri); 203 + return availableUris.has(t.uri) && !!t.tags; 204 204 }); 205 205 206 206 // Set pool ··· 281 281 return a.groupKey.localeCompare(b.groupKey) * groupDir; 282 282 } 283 283 for (let i = 0; i < a.fieldVals.length; i++) { 284 - const cmp = compareValues(a.fieldVals[i], b.fieldVals[i]); 284 + const av = a.fieldVals[i]; 285 + const bv = b.fieldVals[i]; 286 + // Null/undefined always sorts last regardless of direction 287 + if (av == null && bv == null) continue; 288 + if (av == null) return 1; 289 + if (bv == null) return -1; 290 + const cmp = compareValues(av, bv); 285 291 if (cmp !== 0) return cmp * userDir; 286 292 } 287 293 return 0;
+1 -1
src/components/orchestrator/scoped-tracks/worker.js
··· 66 66 const tracksMap = new Map(); 67 67 68 68 for (const track of tracks) { 69 - if (!track.tags) return; 69 + if (!track.tags) continue; 70 70 tracksMap.set(track.id, track); 71 71 } 72 72
+1
src/facets/connect/index.inline.js
··· 17 17 "facets/connect/local/index.html": "folder-open", 18 18 "facets/connect/opensubsonic/index.html": "broadcast", 19 19 "facets/connect/s3/index.html": "hard-drives", 20 + "facets/connect/webdav/index.html": "hard-drive", 20 21 }; 21 22 22 23 const facets = facetsData
+12
src/facets/connect/webdav/index.html
··· 1 + <style> 2 + @import "./styles/base.css"; 3 + @import "./vendor/@phosphor-icons/web/bold/style.css"; 4 + @import "./vendor/@phosphor-icons/web/fill/style.css"; 5 + @import "./facets/connect/common.css"; 6 + 7 + @layer base, diffuse; 8 + </style> 9 + 10 + <main></main> 11 + 12 + <script type="module" src="facets/connect/webdav/index.inline.js"></script>
+156
src/facets/connect/webdav/index.inline.js
··· 1 + import * as TID from "@atcute/tid"; 2 + import { html } from "lit-html"; 3 + 4 + import * as Output from "~/common/output.js"; 5 + import { SCHEME } from "~/components/input/webdav/constants.js"; 6 + import { buildURI, parseURI, serverId } from "~/components/input/webdav/common.js"; 7 + import { effect } from "~/common/signal.js"; 8 + import foundation from "~/common/foundation.js"; 9 + 10 + import { setup } from "~/facets/connect/common.js"; 11 + 12 + /** 13 + * @import { Server } from "~/components/input/webdav/types.d.ts" 14 + */ 15 + 16 + foundation.setup({ title: "Connect WebDAV | Diffuse" }); 17 + 18 + //////////////////////////////////////////// 19 + // SETUP 20 + //////////////////////////////////////////// 21 + 22 + const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = 23 + await Promise.all([ 24 + foundation.configurator.input(), 25 + foundation.orchestrator.output(), 26 + foundation.orchestrator.sources(), 27 + ]); 28 + 29 + await Promise.all([ 30 + customElements.whenDefined(inputConfigurator.localName), 31 + customElements.whenDefined(outputOrchestrator.localName), 32 + customElements.whenDefined(sourcesOrchestrator.localName), 33 + ]); 34 + 35 + //////////////////////////////////////////// 36 + // UI 37 + //////////////////////////////////////////// 38 + 39 + const { setItems, setError } = setup({ 40 + title: "WebDAV", 41 + hasOutput: false, 42 + 43 + description: html` 44 + <p> 45 + Connect to a WebDAV server to use it as an audio source. 46 + </p> 47 + `, 48 + 49 + formFields: html` 50 + <label>Host* <input id="webdav-host" placeholder="music.example.com" required></label> 51 + <label>Directory <input id="webdav-dir" placeholder="/"></label> 52 + <label>Username <input id="webdav-username"></label> 53 + <label>Password <input id="webdav-password" type="password"></label> 54 + <p class="caption">* Required</p> 55 + `, 56 + 57 + onSubmit: () => addServer(), 58 + }); 59 + 60 + const hostInput = 61 + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-host")); 62 + const dirInput = 63 + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-dir")); 64 + const usernameInput = 65 + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-username")); 66 + const passwordInput = 67 + /** @type {HTMLInputElement} */ (document.querySelector("#webdav-password")); 68 + 69 + //////////////////////////////////////////// 70 + // REACTIVE LIST 71 + //////////////////////////////////////////// 72 + 73 + effect(() => { 74 + const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? []; 75 + 76 + /** @type {Map<string, { server: Server; uri: string }>} */ 77 + const allServers = new Map(); 78 + 79 + for (const source of inputSources) { 80 + const parsed = parseURI(source.uri); 81 + if (!parsed) continue; 82 + 83 + const id = serverId(parsed.server); 84 + if (!allServers.has(id)) { 85 + allServers.set(id, { server: parsed.server, uri: source.uri }); 86 + } 87 + } 88 + 89 + setItems( 90 + [...allServers.values()].map(({ server, uri }) => ({ 91 + name: server.host, 92 + detail: server.dir, 93 + isInput: true, 94 + isOutput: false, 95 + isSelectedOutput: false, 96 + isDisabled: sourcesOrchestrator.isDisabled(uri), 97 + onRemove: () => removeServer(uri), 98 + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), 99 + })), 100 + ); 101 + }); 102 + 103 + //////////////////////////////////////////// 104 + // ACTIONS 105 + //////////////////////////////////////////// 106 + 107 + /** @param {string} uri */ 108 + async function removeServer(uri) { 109 + setError(null); 110 + try { 111 + const tracks = await Output.data(outputOrchestrator.tracks); 112 + const detachedTracks = await inputConfigurator.detach({ 113 + fileUriOrScheme: uri, 114 + tracks, 115 + }); 116 + 117 + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 118 + } catch (err) { 119 + setError(err instanceof Error ? err.message : "Failed to remove server"); 120 + } 121 + } 122 + 123 + async function addServer() { 124 + const host = hostInput.value?.trim(); 125 + const dir = dirInput.value?.trim() || "/"; 126 + const username = usernameInput.value?.trim() || ""; 127 + const password = passwordInput.value?.trim() || ""; 128 + 129 + if (!host) return; 130 + 131 + /** @type {Server} */ 132 + const server = { host, dir, username, password }; 133 + const uri = buildURI(server); 134 + 135 + const now = new Date().toISOString(); 136 + const tracksCol = outputOrchestrator.tracks.collection(); 137 + const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; 138 + 139 + await outputOrchestrator.tracks.save([ 140 + ...existingTracks, 141 + { 142 + $type: "sh.diffuse.output.track", 143 + id: TID.now(), 144 + createdAt: now, 145 + updatedAt: now, 146 + kind: "placeholder", 147 + uri, 148 + }, 149 + ]); 150 + } 151 + 152 + //////////////////////////////////////////// 153 + // 🚀 154 + //////////////////////////////////////////// 155 + 156 + foundation.ready();
+13
src/facets/data/input-bundle/index.inline.js
··· 8 8 import { NAME as LOCAL_NAME } from "~/components/input/local/element.js"; 9 9 import { NAME as OPENSUBSONIC_NAME } from "~/components/input/opensubsonic/element.js"; 10 10 import { NAME as S3_NAME } from "~/components/input/s3/element.js"; 11 + import { NAME as WEBDAV_NAME } from "~/components/input/webdav/element.js"; 11 12 12 13 13 14 /** ··· 28 29 local(input); 29 30 opensubsonic(input); 30 31 s3(input); 32 + webdav(input); 31 33 }); 32 34 33 35 //////////////////////////////////////////// ··· 106 108 export function s3(input) { 107 109 input.append(document.createElement(S3_NAME)); 108 110 } 111 + 112 + //////////////////////////////////////////// 113 + // WEBDAV 114 + //////////////////////////////////////////// 115 + 116 + /** 117 + * @param {InputConfigurator} input 118 + */ 119 + export function webdav(input) { 120 + input.append(document.createElement(WEBDAV_NAME)); 121 + }
+45 -2
src/service-worker-offline.js src/service-worker.js
··· 51 51 const event = /** @type {FetchEvent} */ (_event); 52 52 const { request } = event; 53 53 54 - // Only intercept GET requests over http(s). 55 - if (request.method !== "GET") return; 56 54 if (!request.url.startsWith("http")) return; 55 + 56 + // Intercept credentialed URLs before the offline cache (any method). 57 + const intercepted = interceptCredentials(request); 58 + if (intercepted) { 59 + event.respondWith(intercepted); 60 + return; 61 + } 62 + 63 + // Only cache GET requests. 64 + if (request.method !== "GET") return; 57 65 58 66 event.respondWith(handleFetch(request)); 59 67 }); 68 + 69 + //////////////////////////////////////////// 70 + // CREDENTIAL INTERCEPT 71 + //////////////////////////////////////////// 72 + 73 + /** 74 + * If the request URL contains a `_auth` query parameter (base64 Basic credentials), 75 + * strip it and re-issue the request with a proper Authorization header instead. 76 + * Also handles the legacy `user:pass@host` form for any callers that still use it. 77 + * 78 + * Returns a Promise<Response> when credentials are detected, or null to fall through. 79 + * 80 + * @param {Request} request 81 + * @returns {Promise<Response> | null} 82 + */ 83 + function interceptCredentials(request) { 84 + const url = new URL(request.url); 85 + 86 + if (url.username) { 87 + const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; 88 + url.username = ""; 89 + url.password = ""; 90 + const headers = new Headers(request.headers); 91 + headers.set("Authorization", `Basic ${btoa(unescape(encodeURIComponent(credentials)))}`); 92 + return fetch(url.href, { method: request.method, headers, signal: request.signal }); 93 + } 94 + 95 + const auth = url.searchParams.get("diffuse:basic-auth"); 96 + if (!auth) return null; 97 + 98 + url.searchParams.delete("diffuse:basic-auth"); 99 + const headers = new Headers(request.headers); 100 + headers.set("Authorization", `Basic ${auth}`); 101 + return fetch(url.href, { method: request.method, headers, signal: request.signal }); 102 + } 60 103 61 104 //////////////////////////////////////////// 62 105 // CONTENT-ADDRESSED CACHE
+1
src/themes/blur/browser/element.css
··· 208 208 209 209 .scroll-panel { 210 210 flex: 1; 211 + overflow-anchor: none; 211 212 overflow-y: auto; 212 213 min-height: 0; 213 214 user-select: none;
+10 -3
src/themes/blur/browser/element.js
··· 462 462 } 463 463 464 464 #setupVirtualizer() { 465 - const panel = this.root().querySelector(".scroll-panel"); 466 - if (!panel) return; 465 + if (!this.root().querySelector(".scroll-panel")) return; 467 466 468 467 this.#virtualizerCleanup?.(); 469 468 470 469 this.#virtualizer = new Virtualizer({ 471 470 count: 0, 472 - getScrollElement: () => panel, 471 + getScrollElement: () => this.root().querySelector(".scroll-panel"), 473 472 estimateSize: (i) => 474 473 this.#flatItems[i]?.type === "group" 475 474 ? GROUP_HEADER_HEIGHT ··· 486 485 this.#virtualizerCleanup = this.#virtualizer._didMount(); 487 486 this.#virtualizer._willUpdate(); 488 487 this.forceRender(); 488 + 489 + // Reset scroll after browser scroll restoration, which fires asynchronously 490 + requestAnimationFrame(() => { 491 + requestAnimationFrame(() => { 492 + const panel = this.root().querySelector(".scroll-panel"); 493 + if (panel && panel.scrollTop !== 0) panel.scrollTop = 0; 494 + }); 495 + }); 489 496 } 490 497 491 498 #disconnectCoverObserver() {