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