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