offline.ts
⎇
Raw
1import Dexie, { liveQuery } from "dexie";
2import { AsyncSemaphore } from "music-server-shared/semaphore";
3import type {
4 Chapter,
5 Directory,
6 DirectoryWithStatus,
7 FileListing,
8 FileListingWithStatus,
9 MediaFile,
10 Metadata,
11 SubtitleTrack,
12 VideoExtras,
13} from "music-server-shared/types";
14import { audioCodecHasBitrate, encodePath } from "music-server-shared/utils";
15import { batch, from, type ResourceActions, untrack } from "solid-js";
16import { createStore, unwrap } from "solid-js/store";
17import type { FlatFileListing } from "./App";
18import { type AppOptions, type LoadedVideoExtras, type Playlist, StreamingMode } from "./types";
19import {
20 basename,
21 dirname,
22 handleForbiddenPath,
23 handleUnauthorized,
24 joinPath,
25 parentPaths,
26 statusLabel,
27 toast,
28} from "./utils";
29
30interface StoredSubtitle extends SubtitleTrack {
31 data: Blob;
32}
33
34interface StoredVideoExtras {
35 chapters: Chapter[];
36 subtitles: StoredSubtitle[];
37}
38
39interface StoredFile {
40 path: string;
41 data: Blob;
42 metadata: Metadata;
43 videoExtras?: StoredVideoExtras;
44}
45
46interface StoredCover {
47 path: string;
48 data: Blob | null;
49}
50
51interface Database extends Dexie {
52 files: Dexie.Table<StoredFile, string>;
53 covers: Dexie.Table<StoredCover, string>;
54 playlists: Dexie.Table<Playlist, string>;
55}
56
57const db = new Dexie("music-files") as Database;
58db.version(1).stores({
59 files: "path",
60 covers: "path",
61 playlists: "name",
62});
63
64export type Progress =
65 | { status: number; abort: () => void }
66 | { status: "Aborted"; retry: () => void }
67 | { status: "Failed"; retry: () => void; reason: string };
68
69interface LoadingChildren {
70 amount: number;
71}
72
73export type DownloadStatus = Progress | LoadingChildren | "Synced" | undefined;
74export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
75
76//creates entry if it doesn't exist, so reactivity works
77export function getDownloadStatus(path: string): DownloadStatus {
78 untrack(() => {
79 if (!Object.keys(downloadStatusMap).includes(path)) setDownloadStatusMap(path, undefined);
80 });
81 return downloadStatusMap[path];
82}
83
84function updateRecursiveStatus(path: string, loadChange: number, includeCurrent: boolean) {
85 function updateProgressAmount(path: string) {
86 const currentStatus = downloadStatusMap[path];
87 if (typeof currentStatus !== "object" || !("amount" in currentStatus)) {
88 setDownloadStatusMap(path, loadChange > 0 ? { amount: loadChange } : undefined);
89 return;
90 }
91
92 const newAmount = currentStatus.amount + loadChange;
93 setDownloadStatusMap(path, newAmount > 0 ? { amount: newAmount } : undefined);
94 }
95
96 if (includeCurrent) updateProgressAmount(path);
97 // Update all parent directories
98 for (const parent of parentPaths(path)) {
99 updateProgressAmount(parent);
100 }
101}
102
103function startProgress(path: string, status: DownloadStatus) {
104 updateRecursiveStatus(path, 1, false);
105 setDownloadStatusMap(path, status);
106}
107
108function stopProgress(path: string, status: DownloadStatus) {
109 updateRecursiveStatus(path, -1, false);
110 setDownloadStatusMap(path, status);
111}
112
113//initialize filetrees and download statuses from indexedDB
114export async function loadDataFromDB() {
115 try {
116 const files = await db.files.toArray();
117 for (const file of files) {
118 setDownloadStatusMap(file.path, "Synced");
119 updateFileTree(file.path, { metadata: file.metadata }, true);
120 }
121 const covers = await db.covers.toArray();
122 for (const cover of covers) {
123 coverCache.set(cover.path, { data: cover.data });
124 }
125 } catch (e) {
126 toast("Loading offline files failed", "error", e);
127 console.error(e);
128 }
129}
130
131//contains all locally synced files
132const offlineTreeRoot: Directory = {
133 files: {},
134};
135//used for caching online file listings
136const onlineTreeRoot: DirectoryWithStatus = {
137 files: {},
138 status: "Unknown",
139};
140
141//merges "Scanned" directories from "other" to "target"
142function mergeTrees(target: DirectoryWithStatus, other: DirectoryWithStatus) {
143 if (other.status === "Unknown") return;
144 target.status = other.status;
145 for (const [fileName, file] of Object.entries(other.files)) {
146 const targetFile = target.files[fileName];
147 if (targetFile && "files" in targetFile && "files" in file) {
148 mergeTrees(targetFile, file);
149 } else {
150 target.files[fileName] = file;
151 }
152 }
153}
154
155//TODO: improve typing to avoid "as"
156//overloads for typecheking
157//offline tree only stores files, directories are created ad-hoc
158function updateFileTree(path: string, file: MediaFile, offline: true): void;
159//online tree only stores cached directory listings
160function updateFileTree(path: string, file: DirectoryWithStatus, offline: false): void;
161// Updates file tree structure with new data (offline or online)
162function updateFileTree(path: string, file: DirectoryWithStatus | MediaFile, offline: boolean) {
163 if (offline && path === "") throw new Error("Trying to update root in offline mode");
164 const basePath = dirname(path);
165 const fileName = basename(path);
166 const node = getDirInTree(basePath, true, offline);
167 //widen so the writes below don't have to satisfy both tree flavors at once
168 const nodeFiles: Record<string, DirectoryWithStatus | Directory | MediaFile> = node.files;
169
170 if (offline) {
171 nodeFiles[fileName] = file;
172 } else {
173 if (path === "") mergeTrees(node as DirectoryWithStatus, file as DirectoryWithStatus);
174 else {
175 let target = nodeFiles[fileName] as DirectoryWithStatus;
176 if (!target) target = { files: {}, status: "Unknown" };
177 mergeTrees(target as DirectoryWithStatus, file as DirectoryWithStatus);
178 nodeFiles[fileName] = target;
179 }
180 }
181}
182
183//one overload per call shape: creating always yields a node, looking one up may not find it, and
184//which tree flavor comes back depends on the mode - the offline tree holds plain Directory nodes
185function getDirInTree(path: string, create: true, offline: boolean): DirectoryWithStatus | Directory;
186function getDirInTree(path: string, create: false, offline: true): Directory | null;
187function getDirInTree(path: string, create: false, offline: false): DirectoryWithStatus | null;
188// get a DirectoryNode from the directory tree matching the path, and optionally creating missing nodes it if they don't exist yet
189function getDirInTree(path: string, create: boolean, offline: boolean): DirectoryWithStatus | Directory | null {
190 let current = offline ? offlineTreeRoot : onlineTreeRoot;
191 if (path === "") return current;
192 const parts = path.split("/");
193
194 for (const part of parts) {
195 //widen so reads and writes don't have to satisfy both tree flavors at once
196 const files: Record<string, DirectoryWithStatus | Directory | MediaFile> = current.files;
197 let next: DirectoryWithStatus | Directory | MediaFile | undefined = files[part];
198 if (!next || "metadata" in next) {
199 if (!create) return null;
200 //if mediafile already exists, we overwrite it with a directory anyway
201 if (offline) next = { files: {} };
202 else next = { files: {}, status: "Unknown" };
203 files[part] = next;
204 }
205 current = next;
206 }
207 return current;
208}
209
210function deleteInOfflineTree(path: string): void {
211 if (path === "") return;
212 const basePath = dirname(path);
213 const fileName = basename(path);
214
215 const node = getDirInTree(basePath, false, true);
216 if (!node) return;
217
218 delete node.files[fileName];
219 if (Object.keys(node.files).length === 0) {
220 db.covers.delete(basePath); //remove stored cover for this dir if there are not files left
221 deleteInOfflineTree(basePath);
222 }
223}
224
225export const clearDirectoryCache = async (path: string) => {
226 const node = getDirInTree(path, false, false);
227 if (!node) return;
228
229 // Save existing children before clearing
230 const preservedChildren = { ...node.files };
231
232 node.files = {};
233 node.status = "Unknown";
234
235 // restore any preserved subtrees where the parent directory still exist
236 const newListing = await listFiles(path, false, false);
237 for (const [name, child] of Object.entries(preservedChildren)) {
238 if ("files" in child && newListing[name] !== undefined) {
239 node.files[name] = child;
240 }
241 }
242};
243async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileListingWithStatus | undefined> {
244 const response = await fetch(`/list/${encodePath(dir)}?recursive=${recursive}`, { credentials: "same-origin" }).catch(
245 () => {},
246 );
247 if (handleUnauthorized(response)) return;
248 //403 means the path names a root this user does not have - typically a bookmark or a remembered
249 //path from before roots existed, so it has to reset rather than just report a failure
250 if (handleForbiddenPath(response)) return;
251 if (response?.status === 404) {
252 toast(`Directory not found: ${dir}`, "error");
253 return;
254 }
255 if (!response?.ok) {
256 toast(
257 `Fetching files for ${dir === "" ? "root" : dir} failed${response ? `: ${statusLabel(response)}` : ""}`,
258 "error",
259 );
260 return;
261 }
262
263 return (await response.json()) as FileListingWithStatus;
264}
265
266function listOfflineFiles(dir: string): FileListing {
267 const node = getDirInTree(dir, false, true);
268 if (!node?.files) return {};
269 return node.files;
270}
271
272//checks if this Directory is completely scanned, i.e. suitable for a recursive listing result
273function fullyKnown(dir: DirectoryWithStatus) {
274 if (dir.status === "Unknown") {
275 return false;
276 }
277 if (Object.values(dir.files).some((value) => "files" in value && !fullyKnown(value))) {
278 return false;
279 }
280 return true;
281}
282
283//non-recursive listing should not include nested children
284function pruneChildren(listing: FileListing) {
285 const pruned: FileListing = {};
286 for (const [name, item] of Object.entries(listing)) {
287 if ("metadata" in item) pruned[name] = item;
288 else pruned[name] = { files: {} };
289 }
290 return pruned;
291}
292
293//used for combining the online and offline results, to be able to show files that only exist locally
294function combineFileListings(listing1: FileListing, listing2: FileListing): FileListing {
295 const result: FileListing = { ...listing1 };
296
297 for (const [key, value2] of Object.entries(listing2)) {
298 const value1 = result[key];
299
300 if (value1 && "files" in value1 && "files" in value2) {
301 result[key] = { files: combineFileListings(value1.files, value2.files) };
302 } else {
303 result[key] = value2;
304 }
305 }
306
307 return result;
308}
309
310// File listing logic with hybrid online/offline support
311// Combines:
312// 1. Direct IndexedDB lookups for offline mode
313// 2. Cached online listings
314// 3. Merged online+offline results
315export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
316 if (offline) {
317 const files = listOfflineFiles(dir);
318 return recursive ? files : pruneChildren(files);
319 }
320 const cached = getDirInTree(dir, false, false);
321 if (cached !== null && cached.status === "Scanned" && (!recursive || fullyKnown(cached))) {
322 const combined = combineFileListings(cached.files, listOfflineFiles(dir));
323 return recursive ? combined : pruneChildren(combined);
324 }
325 const files = await listOnlineFiles(dir, recursive);
326 if (!files) return {};
327 updateFileTree(dir, { files, status: "Scanned" }, false);
328 const combined = combineFileListings(files, listOfflineFiles(dir));
329 return recursive ? combined : pruneChildren(combined);
330}
331
332const coverCache = new Map<string, { data: Blob | null }>();
333
334//get cover art object-url for a file or directory with cache-first strategy
335export async function getCover(
336 dir: string,
337 options: AppOptions,
338 saveToDb: boolean,
339 offline: boolean,
340 forceRefresh = false,
341) {
342 //the top level lists the roots themselves, so there is no directory to take a cover from
343 if (dir === "") return undefined;
344 try {
345 const cached = coverCache.get(dir);
346 if (!forceRefresh && cached !== undefined) {
347 if (saveToDb) db.covers.add({ path: dir, data: cached.data }).catch(() => {});
348 return cached.data === null ? undefined : URL.createObjectURL(cached.data);
349 }
350
351 if (offline) return undefined;
352 const response = await fetch(`/cover/${encodePath(dir)}${options.transcodeCovers ? "?transcode=true" : ""}`, {
353 method: "GET",
354 credentials: "same-origin",
355 }).catch(() => {});
356 if (handleUnauthorized(response)) return undefined;
357 if (!response || response.status === 404) {
358 coverCache.set(dir, { data: null });
359 if (saveToDb) db.covers.add({ path: dir, data: null }).catch(() => {});
360 //only update a row that already exists, otherwise merely browsing a directory would
361 //persist its cover - note the await, without it the promise is always truthy
362 if (!saveToDb && forceRefresh && (await db.covers.get(dir)) !== undefined)
363 db.covers.put({ path: dir, data: null }).catch(() => {});
364 return undefined;
365 }
366 if (!response.ok) return undefined;
367 const blob = await response.blob();
368 coverCache.set(dir, { data: blob });
369 if (saveToDb) db.covers.add({ path: dir, data: blob }).catch(() => {});
370 //see above: the await is what makes this an "already stored" check
371 if (!saveToDb && forceRefresh && (await db.covers.get(dir)) !== undefined)
372 db.covers.put({ path: dir, data: blob }).catch(() => {});
373 return URL.createObjectURL(blob);
374 } catch {
375 return undefined;
376 }
377}
378
379const emptyVideoExtras = (): LoadedVideoExtras => ({ subtitleTracks: [], chapters: [], subtitleSources: {} });
380
381function subtitleUrl(path: string, streamIndex: number): string {
382 return `/subtitles/${encodePath(path)}?track=${streamIndex}`;
383}
384
385function videoInfoUrl(path: string): string {
386 return `/video-info/${encodePath(path)}`;
387}
388
389function checkVideoExtrasResponse(response: Response | undefined, path: string): asserts response is Response {
390 if (handleUnauthorized(response)) throw new Error("Unauthorized while fetching video extras");
391 if (!response?.ok)
392 throw new Error(
393 `Fetching video extras for ${path} failed: ${response ? statusLabel(response) : "server unavailable"}`,
394 );
395}
396
397export async function loadVideoExtras(path: string, offline: boolean): Promise<LoadedVideoExtras> {
398 const stored = await db.files.get(path);
399 if (stored?.videoExtras) {
400 const subtitleSources: Record<number, string> = {};
401 const subtitleTracks: SubtitleTrack[] = [];
402 for (const subtitle of stored.videoExtras.subtitles) {
403 const { data, ...track } = subtitle;
404 subtitleTracks.push(track);
405 subtitleSources[track.streamIndex] = URL.createObjectURL(data);
406 }
407 return { subtitleTracks, chapters: stored.videoExtras.chapters, subtitleSources };
408 }
409 if (offline) return emptyVideoExtras();
410
411 const response = await fetch(videoInfoUrl(path), { credentials: "same-origin" }).catch(() => undefined);
412 checkVideoExtrasResponse(response, path);
413 const extras = (await response.json()) as VideoExtras;
414 const subtitleSources: Record<number, string> = {};
415 for (const track of extras.subtitleTracks) subtitleSources[track.streamIndex] = subtitleUrl(path, track.streamIndex);
416 return { ...extras, subtitleSources };
417}
418
419export function revokeVideoExtras(extras: LoadedVideoExtras | undefined): void {
420 if (!extras) return;
421 for (const source of Object.values(extras.subtitleSources)) {
422 if (source.startsWith("blob:")) URL.revokeObjectURL(source);
423 }
424}
425
426//Unlike normal playback metadata, sync treats extras as part of the file request: a failed chapter probe
427//or a failed subtitle conversion rejects before the media blob is committed to IndexedDB.
428async function downloadVideoExtras(path: string, signal: AbortSignal): Promise<StoredVideoExtras> {
429 const response = await fetch(videoInfoUrl(path), { credentials: "same-origin", signal });
430 checkVideoExtrasResponse(response, path);
431 const extras = (await response.json()) as VideoExtras;
432 const subtitles = await Promise.all(
433 extras.subtitleTracks.map(async (track) => {
434 const subtitleResponse = await fetch(subtitleUrl(path, track.streamIndex), {
435 credentials: "same-origin",
436 signal,
437 });
438 if (handleUnauthorized(subtitleResponse) || !subtitleResponse.ok) {
439 throw new Error(
440 `Fetching subtitle track ${track.streamIndex} for ${path} failed: ${statusLabel(subtitleResponse)}`,
441 );
442 }
443 return { ...track, data: await subtitleResponse.blob() };
444 }),
445 );
446 return { chapters: extras.chapters, subtitles };
447}
448
449//returns an object url to the blob if synced, otherwise the server url
450//with forceUrl it always resolves, without it returns undefined for an uncached file in offline mode
451export async function getSrc(
452 path: string,
453 metadata: Metadata,
454 options: AppOptions,
455 offline: boolean,
456 seekTo: number,
457 forceUrl = false,
458): Promise<{ src: string; isVideo?: boolean; transcodeId?: string } | undefined> {
459 if (!forceUrl) {
460 const dbResult = await db.files.get(path);
461 if (dbResult) return { src: URL.createObjectURL(dbResult.data), isVideo: undefined };
462
463 if (offline) {
464 toast("This file is not cached, leave offline mode to play it", "error");
465 return undefined;
466 }
467 }
468 if (options.enableTranscoding) {
469 const isVideo = metadata.videoCodec !== undefined;
470 const settings = isVideo ? options.videoSettings : options.audioSettings;
471 const disableChunkedTranscoding = settings.streamingMode === StreamingMode.buffered;
472 const audioBitrate = audioCodecHasBitrate(settings.audioCodec) ? settings.audioBitrate : undefined;
473
474 const transcodeId = crypto.randomUUID();
475 const query = new URLSearchParams({
476 disableChunkedTranscoding: String(disableChunkedTranscoding),
477 container: settings.container,
478 audioCodec: settings.audioCodec,
479 audioLanguagePreference: options.audioLanguagePreference,
480 transcodeId,
481 });
482 if (isVideo) {
483 query.set("videoCodec", options.videoSettings.videoCodec);
484 query.set("videoBitrate", options.videoSettings.videoBitrate.toString());
485 query.set("videoEncodingSetting", options.videoSettings.videoEncodingSetting);
486 }
487 if (audioBitrate !== undefined) query.set("audioBitrate", audioBitrate.toString());
488 if (seekTo !== 0) query.set("seekTo", seekTo.toString());
489
490 return {
491 src: `${joinPath("transcode", encodePath(path))}?${query}`,
492 isVideo: isVideo,
493 transcodeId,
494 };
495 }
496 return { src: joinPath("download", encodePath(path)), isVideo: undefined };
497}
498
499export async function loadPlaylists() {
500 return await db.playlists.toArray();
501}
502
503//save playlist to indexedDB, throws error if playlist with that name already exists and overwrite=false
504export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
505 playlist.items = playlist.items.map((item) => {
506 //some less space in the db by omitting ids
507 return { path: item.path, metadata: unwrap(item.metadata) };
508 });
509
510 if (overwrite) {
511 await db.playlists.put(playlist);
512 } else {
513 await db.playlists.add(playlist);
514 }
515}
516
517export async function deletePlaylist(name: string) {
518 await db.playlists.delete(name);
519}
520
521function collectMediaFilePaths(listing: FileListing, basePath: string): string[] {
522 const paths: string[] = [];
523
524 for (const [name, item] of Object.entries(listing)) {
525 const currentPath = joinPath(basePath, name);
526
527 if ("metadata" in item) {
528 paths.push(currentPath);
529 } else if ("files" in item) {
530 paths.push(...collectMediaFilePaths(item.files, currentPath));
531 }
532 }
533
534 return paths;
535}
536
537//deletes file or directory(+recursive contents) from indexedDB and updates their download statuses
538export async function deleteFile(
539 fullPath: string,
540 item: MediaFile | Directory,
541 fetchFiles: ResourceActions<FlatFileListing>,
542) {
543 //outside the try, the finally below must only undo a startProgress that actually ran
544 if ("metadata" in item && downloadStatusMap[fullPath] !== "Synced") return;
545 startProgress(fullPath, { amount: 1 });
546 try {
547 let toDelete: string[];
548 if ("files" in item) {
549 toDelete = collectMediaFilePaths(listOfflineFiles(fullPath), fullPath);
550 } else {
551 toDelete = [fullPath];
552 }
553
554 //covers are keyed by file path (embedded art) and by directory path (directory art), so deleting a
555 //directory also has to drop its own cover row plus every nested one, or they stay orphaned forever
556 const coversToDelete = new Set(toDelete);
557 if ("files" in item) {
558 coversToDelete.add(fullPath);
559 for (const filePath of toDelete) {
560 for (const dir of parentPaths(filePath)) {
561 //stop at the deleted root itself, parents of it may still hold other synced files
562 if (dir === fullPath) break;
563 coversToDelete.add(dir);
564 }
565 }
566 }
567
568 await db.files.bulkDelete(toDelete); //delete file from database
569 await db.covers.bulkDelete([...coversToDelete]); //delete embedded/directory covers from database
570 //also drop them from the session cache, otherwise a later getCover(saveToDb) would re-add them
571 for (const coverPath of coversToDelete) coverCache.delete(coverPath);
572 deleteInOfflineTree(fullPath); //delete node from tree
573 batch(() => {
574 for (const path of toDelete) {
575 setDownloadStatusMap(path, undefined); // set all children to unsynced
576 }
577 });
578 fetchFiles.refetch();
579 } finally {
580 stopProgress(fullPath, undefined);
581 }
582}
583
584const downloadSemaphore = new AsyncSemaphore(1);
585
586//Download file to indexedDB and updates the offline file tree
587//concurrency control via the downloadSemaphore
588async function downloadFile(
589 path: string,
590 options: AppOptions,
591 item: MediaFile,
592 fetchFiles: ResourceActions<FlatFileListing>,
593 redownload: boolean,
594): Promise<void> {
595 const retry = () => downloadFile(path, options, item, fetchFiles, redownload);
596
597 // file already downloaded or download already in progress
598 const initialProgress = downloadStatusMap[path];
599 if (
600 (!redownload && initialProgress === "Synced") ||
601 //an in-progress download carries a numeric byte count, without this check clicking a
602 //downloading item would start a second fetch racing the first one on db.files.put
603 (typeof initialProgress === "object" && "status" in initialProgress && typeof initialProgress.status === "number")
604 )
605 return;
606
607 //an abort that lands during db.files.put is followed by the success path, and the parent
608 //counters must only be decremented once
609 let progressStopped = false;
610 const stopOnce = (status: DownloadStatus) => {
611 if (progressStopped) {
612 setDownloadStatusMap(path, status);
613 return;
614 }
615 progressStopped = true;
616 stopProgress(path, status);
617 };
618 const controller = new AbortController();
619 const abortDownload = () => {
620 controller.abort();
621 // if it was already synced, restore it
622 stopOnce(initialProgress === "Synced" ? "Synced" : { status: "Aborted", retry });
623 };
624 startProgress(path, { status: 0, abort: abortDownload });
625
626 try {
627 //options might change at any point, update the semaphore to the current value
628 downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
629
630 //make sure to only allow the set amount of parallel downloads. passing the signal makes an abort
631 //while still queued reject here instead of entering the section below, so the release() in its
632 //finally can't hand out a permit that was never acquired (which would leak a concurrency slot)
633 await downloadSemaphore.acquire(controller.signal);
634 try {
635 const source = await getSrc(path, item.metadata, options, false, 0, true);
636 if (!source) throw new Error("Unable to create a download URL");
637 const res = await fetch(source.src, {
638 signal: controller.signal,
639 credentials: "same-origin",
640 });
641
642 handleUnauthorized(res);
643 if (!res.ok || !res.body) throw new Error(`${statusLabel(res)}: ${await res.text().catch(() => "")}`);
644 //read the body ourselves to track progress, and assemble the blob from the same chunks -
645 //res.clone().blob() would buffer the whole body a second time and reject unobserved on a mid-body error
646 const reader = res.body.getReader();
647 //a fetch body never yields SharedArrayBuffer-backed views, so narrowing to ArrayBuffer is safe
648 const chunks: Uint8Array<ArrayBuffer>[] = [];
649 let received = 0;
650
651 let lastUpdate = 0;
652 while (true) {
653 const { done, value } = await reader.read();
654 if (done) break;
655 chunks.push(value as Uint8Array<ArrayBuffer>);
656 received += value.length;
657
658 //debouncing logic to not update UI too fast
659 const now = Date.now();
660 if (now - lastUpdate < 250) continue;
661 lastUpdate = now;
662 setDownloadStatusMap(path, { status: received, abort: abortDownload });
663 }
664 const blob = new Blob(chunks, { type: res.headers.get("content-type") ?? undefined });
665 const isVideo = item.metadata.videoCodec !== undefined;
666
667 //A video sync is transactional from the user's perspective: subtitle conversion and chapter
668 //extraction complete before the file row becomes Synced. A failed extra therefore leaves a
669 //previous redownload untouched and never exposes a half-synced video offline.
670 const videoExtras = isVideo ? await downloadVideoExtras(path, controller.signal) : undefined;
671 if (controller.signal.aborted) return;
672
673 const storedFile: StoredFile = {
674 path,
675 data: blob,
676 metadata: unwrap(item.metadata),
677 ...(videoExtras ? { videoExtras } : {}),
678 };
679 await db.files.put(storedFile);
680
681 updateFileTree(path, item, true);
682 //the row is written, so this is synced even if an abort arrived during the put
683 stopOnce("Synced");
684 fetchFiles.refetch();
685
686 //iterate through all parent directories and download their covers to database
687 for (const dir of [path, ...parentPaths(path)]) {
688 getCover(dir, options, true, false).then((cover) => URL.revokeObjectURL(cover || ""));
689 }
690 } finally {
691 downloadSemaphore.release();
692 }
693 } catch (e) {
694 if (e instanceof Error && e.name === "AbortError") return;
695 // if it was already synced, restore it
696 stopOnce(
697 initialProgress === "Synced"
698 ? "Synced"
699 : {
700 status: "Failed",
701 reason: e ? e.toString() : "Unknown error",
702 retry,
703 },
704 );
705 }
706}
707
708export async function download(
709 fullPath: string,
710 item: MediaFile | Directory,
711 options: AppOptions,
712 fetchFiles: ResourceActions<FlatFileListing>,
713 redownload = false,
714) {
715 if ("metadata" in item) {
716 await downloadFile(fullPath, options, item, fetchFiles, redownload);
717 } else {
718 updateRecursiveStatus(fullPath, 1, true);
719 try {
720 //start downloads for all children to allow them to run in parallel, then wait for the results
721 const promises: Promise<unknown>[] = [];
722 const listing = await listFiles(fullPath, false, false);
723 batch(() => {
724 for (const [childName, childItem] of Object.entries(listing)) {
725 promises.push(download(joinPath(fullPath, childName), childItem, options, fetchFiles, redownload));
726 }
727 });
728 await Promise.allSettled(promises);
729 } finally {
730 updateRecursiveStatus(fullPath, -1, true);
731 }
732 }
733}
734
735//aborts all in-progress downloads and deletes every synced file and cover from indexedDB.
736export async function clearAllDownloads(fetchFiles: ResourceActions<FlatFileListing>) {
737 //abort first, so in-flight fetches reject with AbortError instead of writing to a cleared
738 //database. a download already past its fetch (sitting at db.files.put) cannot be aborted this
739 //way and may re-insert one row after the clear - harmless, and gone on the next clear/reload
740 for (const status of Object.values(downloadStatusMap)) {
741 if (status && typeof status === "object" && "status" in status && typeof status.status === "number") {
742 status.abort();
743 }
744 }
745
746 await db.files.clear();
747 await db.covers.clear();
748 coverCache.clear();
749 offlineTreeRoot.files = {};
750
751 batch(() => {
752 for (const path of Object.keys(downloadStatusMap)) {
753 setDownloadStatusMap(path, undefined);
754 }
755 });
756 fetchFiles.refetch();
757}
758
759//deletes every local trace of the app
760export async function clearLocalData(): Promise<string | undefined> {
761 localStorage.clear();
762
763 db.close();
764 if ("serviceWorker" in navigator) {
765 const registrations = await navigator.serviceWorker.getRegistrations().catch(() => []);
766 await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
767 }
768 if ("caches" in window) {
769 const cacheNames = await caches.keys().catch(() => []);
770 //awaited, otherwise the reload races the cleanup and can leave caches behind
771 await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName).catch(() => false)));
772 }
773
774 //onsuccess alone can hang forever: another open tab blocks the delete, and errors never resolve
775 return await new Promise<string | undefined>((resolve) => {
776 const request = indexedDB.deleteDatabase("music-files");
777 request.onsuccess = () => resolve(undefined);
778 request.onerror = () => resolve("Deleting the local database failed, close all other tabs and try again.");
779 request.onblocked = () =>
780 resolve("Another tab still has this app open, close it and try again to delete the local database.");
781 });
782}
783
784export const storedFilesLive = from(
785 liveQuery(async () => await db.files.toCollection().keys()),
786 [],
787);
788
789export const storageStatsLive = from(
790 liveQuery(async () => {
791 let fileBytes = 0;
792 await db.files.each((file) => {
793 fileBytes += file.data.size;
794 for (const subtitle of file.videoExtras?.subtitles || []) fileBytes += subtitle.data.size;
795 });
796 return {
797 fileCount: await db.files.count(),
798 fileBytes,
799 coverCount: await db.covers.count(),
800 playlistCount: await db.playlists.count(),
801 };
802 }),
803 { fileCount: 0, fileBytes: 0, coverCount: 0, playlistCount: 0 },
804);
805