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