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 { 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}