forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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}