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

Configure Feed

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

at e68f727ec6d8bde0a9a06f73af2ebae7f10ffc5f 308 lines 8.6 kB view raw
1import { parse as parseXml } from "@std/xml"; 2import * as URI from "fast-uri"; 3import QS from "query-string"; 4 5import { cachedConsult } from "~/components/input/common.js"; 6import { 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 */ 25export 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 */ 34export 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 */ 42export 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 */ 63export 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 */ 89export 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 */ 100export 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 */ 108export 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 */ 127export 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 */ 151export 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 */ 174async 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 196export 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 */ 206export 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 */ 217async 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 */ 297function 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}