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 v4 328 lines 8.8 kB view raw
1import { parse as parseXml } from "@std/xml"; 2import * as URI from "fast-uri"; 3import QS from "query-string"; 4 5import { safeDecodeURIComponent } from "~/common/utils.js"; 6 7import { cachedConsult } from "~/components/input/common.js"; 8import { SCHEME } from "./constants.js"; 9 10/** 11 * @import { Track } from "~/definitions/types.d.ts"; 12 * @import { Server } from "./types.d.ts"; 13 */ 14 15//////////////////////////////////////////// 16// 🛠️ 17//////////////////////////////////////////// 18 19/** 20 * Build an HTTP(S) URL with credentials in a query param for the service worker to intercept. 21 * Credentials go in `?diffuse:basic-auth=<base64>` rather than `user:pass@host` because browsers 22 * block `new Request()` with credentials in the URL authority (which music-metadata uses). 23 * 24 * @param {Server} server 25 * @param {string} [filePath] 26 */ 27export function buildTrackUrl(server, filePath = "") { 28 const url = new URL(toHttpUrl(server, filePath)); 29 url.searchParams.set( 30 "diffuse:basic-auth", 31 btoa(unescape(encodeURIComponent(`${server.username}:${server.password}`))), 32 ); 33 return url.href; 34} 35 36/** 37 * @param {Server} server 38 */ 39export function serverId(server) { 40 return `${server.username}:${server.password}@${server.host}${server.dir}`; 41} 42 43/** 44 * @param {Server} server 45 * @param {string} [filePath] 46 */ 47export function buildURI(server, filePath = "") { 48 let host = server.host; 49 let protocol; 50 51 if (host.includes("://")) { 52 [protocol, host] = host.split("://"); 53 } 54 55 const userinfo = `${encodeURIComponent(server.username)}:${encodeURIComponent(server.password)}`; 56 const query = QS.stringify({ dir: server.dir, protocol }); 57 return `${SCHEME}://${userinfo}@${host}${filePath}${query ? `?${query}` : ""}`; 58} 59 60/** 61 * @param {string} uriString 62 * @returns {{ server: Server; path: string } | undefined} 63 */ 64export function parseURI(uriString) { 65 const uri = URI.parse(uriString); 66 if (uri.scheme !== SCHEME) return undefined; 67 if (!uri.host) return undefined; 68 69 const userinfo = uri.userinfo ?? ""; 70 const colonIdx = userinfo.indexOf(":"); 71 const username = decodeURIComponent( 72 colonIdx >= 0 ? userinfo.slice(0, colonIdx) : userinfo, 73 ); 74 const password = decodeURIComponent( 75 colonIdx >= 0 ? userinfo.slice(colonIdx + 1) : "", 76 ); 77 78 const qs = QS.parse(uri.query || ""); 79 const dir = typeof qs.dir === "string" ? qs.dir : "/"; 80 const protocol = typeof qs.protocol === "string" ? qs.protocol : undefined; 81 82 const rawHost = uri.port ? `${uri.host}:${uri.port}` : uri.host; 83 const host = protocol ? `${protocol}://${rawHost}` : rawHost; 84 const server = { username, password, host, dir }; 85 const path = uri.path || ""; 86 87 return { server, path }; 88} 89 90/** 91 * @param {Server} server 92 * @param {string} [path] 93 */ 94export function toHttpUrl(server, path = "") { 95 const base = server.host.includes("://") 96 ? server.host 97 : `${ 98 server.host.split(":")[0] === "localhost" || 99 server.host.split(":")[0] === "127.0.0.1" 100 ? "http" 101 : "https" 102 }://${server.host}`; 103 104 return base.replace(/\/$/, "") + (path ? "/" + path.replace(/^\//, "") : ""); 105} 106 107/** 108 * @param {Server} server 109 */ 110export function authHeader(server) { 111 return `Basic ${ 112 btoa(unescape(encodeURIComponent(`${server.username}:${server.password}`))) 113 }`; 114} 115 116/** 117 * @param {Track[]} tracks 118 * @returns {Record<string, Server>} 119 */ 120export function serversFromTracks(tracks) { 121 /** @type {Record<string, Server>} */ 122 const acc = {}; 123 124 tracks.forEach((track) => { 125 const parsed = parseURI(track.uri); 126 if (!parsed) return; 127 128 const id = serverId(parsed.server); 129 if (!acc[id]) acc[id] = parsed.server; 130 }); 131 132 return acc; 133} 134 135/** 136 * @param {Track[]} tracks 137 * @returns {Record<string, { server: Server; tracks: Track[] }>} 138 */ 139export function groupTracksByServer(tracks) { 140 /** @type {Record<string, { server: Server; tracks: Track[] }>} */ 141 const acc = {}; 142 143 tracks.forEach((track) => { 144 const parsed = parseURI(track.uri); 145 if (!parsed) return; 146 147 const id = serverId(parsed.server); 148 149 if (acc[id]) { 150 acc[id].tracks.push(track); 151 } else { 152 acc[id] = { server: parsed.server, tracks: [track] }; 153 } 154 }); 155 156 return acc; 157} 158 159/** 160 * @param {string[]} uris 161 * @returns {Record<string, { server: Server; uris: string[] }>} 162 */ 163export function groupUrisByServer(uris) { 164 /** @type {Record<string, { server: Server; uris: string[] }>} */ 165 const acc = {}; 166 167 uris.forEach((uri) => { 168 const parsed = parseURI(uri); 169 if (!parsed) return; 170 171 const id = serverId(parsed.server); 172 173 if (acc[id]) { 174 acc[id].uris.push(uri); 175 } else { 176 acc[id] = { server: parsed.server, uris: [uri] }; 177 } 178 }); 179 180 return acc; 181} 182 183/** 184 * @param {Server} server 185 */ 186async function checkAccess(server) { 187 try { 188 const url = toHttpUrl(server, server.dir); 189 const controller = new AbortController(); 190 const timeoutId = setTimeout(() => controller.abort(), 5000); 191 192 const response = await fetch(url, { 193 method: "PROPFIND", 194 headers: { 195 "Authorization": authHeader(server), 196 "Depth": "0", 197 }, 198 signal: controller.signal, 199 }); 200 201 clearTimeout(timeoutId); 202 return response.status === 207 || response.ok; 203 } catch { 204 return false; 205 } 206} 207 208export const checkAccessCached = cachedConsult(checkAccess, serverId); 209 210/** 211 * List all files on a WebDAV server under server.dir. 212 * Uses Depth:1 and recurses into subdirectories to avoid loading the 213 * entire tree in one response. 214 * 215 * @param {Server} server 216 * @returns {Promise<string[]>} 217 */ 218export async function listFiles(server) { 219 const paths = /** @type {string[]} */ ([]); 220 await propfindDir(server, server.dir, paths); 221 return paths; 222} 223 224/** 225 * @param {Server} server 226 * @param {string} dir 227 * @param {string[]} paths 228 */ 229async function propfindDir(server, dir, paths) { 230 const url = toHttpUrl(server, dir); 231 232 const response = await fetch(url, { 233 method: "PROPFIND", 234 headers: { 235 "Authorization": authHeader(server), 236 "Depth": "1", 237 }, 238 }); 239 240 if (response.status !== 207 && !response.ok) return; 241 242 const xml = await response.text(); 243 const subdirs = /** @type {string[]} */ ([]); 244 245 const doc = parseXml(xml); 246 const multistatus = doc.root; 247 if (!multistatus) return; 248 249 for (const node of multistatus.children ?? []) { 250 if (node.type !== "element" || node.name.local !== "response") continue; 251 252 let href = ""; 253 let isCollection = false; 254 255 for (const child of node.children ?? []) { 256 if (child.type !== "element") continue; 257 258 if (child.name.local === "href") { 259 href = (child.children?.find((n) => n.type === "text")?.text ?? "") 260 .trim(); 261 } else if (child.name.local === "propstat") { 262 if (propstatHasCollection(child)) isCollection = true; 263 } 264 } 265 266 if (!href) continue; 267 268 // Trailing slash is the most reliable collection indicator in WebDAV 269 isCollection = isCollection || href.endsWith("/"); 270 271 // Keep the raw (percent-encoded) pathname for recursion so that 272 // toHttpUrl produces a valid URL; decode only for the final paths list. 273 let rawPath; 274 try { 275 rawPath = new URL(href).pathname; 276 } catch { 277 rawPath = href; 278 } 279 const path = safeDecodeURIComponent(rawPath); 280 281 // Skip the directory entry itself. 282 // Normalise both sides to have a leading slash — server hrefs always do, 283 // but `dir` may not when the user omitted the leading slash in the form. 284 const normPath = path.replace(/\/$/, ""); 285 const normDir = ("/" + safeDecodeURIComponent(dir).replace(/^\//, "")).replace( 286 /\/$/, 287 "", 288 ); 289 if (normPath === normDir) continue; 290 291 // Skip Synology extended-attribute metadata folders 292 if (path.split("/").includes("@eaDir")) continue; 293 294 if (isCollection) { 295 subdirs.push(rawPath); 296 } else { 297 paths.push(rawPath); 298 } 299 } 300 301 for (const subdir of subdirs) { 302 await propfindDir(server, subdir, paths); 303 } 304} 305 306/** 307 * Check propstat > prop > resourcetype > collection (DAV spec path). 308 * Using `||=` in the caller means multiple propstat elements don't overwrite a true result. 309 * 310 * @param {{ children?: ReadonlyArray<{ type: string; name?: { local: string }; children?: ReadonlyArray<any> }> }} propstat 311 * @returns {boolean} 312 */ 313function propstatHasCollection(propstat) { 314 for (const prop of propstat.children ?? []) { 315 if (prop.type !== "element" || prop.name?.local !== "prop") continue; 316 for (const child of prop.children ?? []) { 317 if (child.type !== "element" || child.name?.local !== "resourcetype") { 318 continue; 319 } 320 for (const rt of child.children ?? []) { 321 if (rt.type === "element" && rt.name?.local === "collection") { 322 return true; 323 } 324 } 325 } 326 } 327 return false; 328}