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//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 ? `: ${response.statusText}` : ""}`,
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(`Fetching video extras for ${path} failed: ${response?.statusText || "server unavailable"}`);
393}
394
395export async function loadVideoExtras(path: string, offline: boolean): Promise<LoadedVideoExtras> {
396 const stored = await db.files.get(path);
397 if (stored?.videoExtras) {
398 const subtitleSources: Record<number, string> = {};
399 const subtitleTracks: SubtitleTrack[] = [];
400 for (const subtitle of stored.videoExtras.subtitles) {
401 const { data, ...track } = subtitle;
402 subtitleTracks.push(track);
403 subtitleSources[track.streamIndex] = URL.createObjectURL(data);
404 }
405 return { subtitleTracks, chapters: stored.videoExtras.chapters, subtitleSources };
406 }
407 if (offline) return emptyVideoExtras();
408
409 const response = await fetch(videoInfoUrl(path), { credentials: "same-origin" }).catch(() => undefined);
410 checkVideoExtrasResponse(response, path);
411 const extras = (await response.json()) as VideoExtras;
412 const subtitleSources: Record<number, string> = {};
413 for (const track of extras.subtitleTracks) subtitleSources[track.streamIndex] = subtitleUrl(path, track.streamIndex);
414 return { ...extras, subtitleSources };
415}
416
417export function revokeVideoExtras(extras: LoadedVideoExtras | undefined): void {
418 if (!extras) return;
419 for (const source of Object.values(extras.subtitleSources)) {
420 if (source.startsWith("blob:")) URL.revokeObjectURL(source);
421 }
422}
423
424//Unlike normal playback metadata, sync treats extras as part of the file request: a failed chapter probe
425//or a failed subtitle conversion rejects before the media blob is committed to IndexedDB.
426async function downloadVideoExtras(path: string, signal: AbortSignal): Promise<StoredVideoExtras> {
427 const response = await fetch(videoInfoUrl(path), { credentials: "same-origin", signal });
428 checkVideoExtrasResponse(response, path);
429 const extras = (await response.json()) as VideoExtras;
430 const subtitles = await Promise.all(
431 extras.subtitleTracks.map(async (track) => {
432 const subtitleResponse = await fetch(subtitleUrl(path, track.streamIndex), {
433 credentials: "same-origin",
434 signal,
435 });
436 if (handleUnauthorized(subtitleResponse) || !subtitleResponse.ok) {
437 throw new Error(
438 `Fetching subtitle track ${track.streamIndex} for ${path} failed: ${subtitleResponse?.statusText || "server unavailable"}`,
439 );
440 }
441 return { ...track, data: await subtitleResponse.blob() };
442 }),
443 );
444 return { chapters: extras.chapters, subtitles };
445}
446
447//returns an object url to the blob if synced, otherwise the server url
448//with forceUrl it always resolves, without it returns undefined for an uncached file in offline mode
449export async function getSrc(
450 path: string,
451 metadata: Metadata,
452 options: AppOptions,
453 offline: boolean,
454 seekTo: number,
455 forceUrl = false,
456): Promise<{ src: string; isVideo?: boolean; transcodeId?: string } | undefined> {
457 if (!forceUrl) {
458 const dbResult = await db.files.get(path);
459 if (dbResult) return { src: URL.createObjectURL(dbResult.data), isVideo: undefined };
460
461 if (offline) {
462 toast("This file is not cached, leave offline mode to play it", "error");
463 return undefined;
464 }
465 }
466 if (options.enableTranscoding) {
467 const isVideo = metadata.videoCodec !== undefined;
468 const settings = isVideo ? options.videoSettings : options.audioSettings;
469 const disableChunkedTranscoding = settings.streamingMode === StreamingMode.buffered;
470 const audioBitrate = audioCodecHasBitrate(settings.audioCodec) ? settings.audioBitrate : undefined;
471
472 const transcodeId = crypto.randomUUID();
473 const query = new URLSearchParams({
474 disableChunkedTranscoding: String(disableChunkedTranscoding),
475 container: settings.container,
476 audioCodec: settings.audioCodec,
477 audioLanguagePreference: options.audioLanguagePreference,
478 transcodeId,
479 });
480 if (isVideo) {
481 query.set("videoCodec", options.videoSettings.videoCodec);
482 query.set("videoBitrate", options.videoSettings.videoBitrate.toString());
483 query.set("videoEncodingSetting", options.videoSettings.videoEncodingSetting);
484 }
485 if (audioBitrate !== undefined) query.set("audioBitrate", audioBitrate.toString());
486 if (seekTo !== 0) query.set("seekTo", seekTo.toString());
487
488 return {
489 src: `${joinPath("transcode", encodePath(path))}?${query}`,
490 isVideo: isVideo,
491 transcodeId,
492 };
493 }
494 return { src: joinPath("download", encodePath(path)), isVideo: undefined };
495}
496
497export async function loadPlaylists() {
498 return await db.playlists.toArray();
499}
500
501//save playlist to indexedDB, throws error if playlist with that name already exists and overwrite=false
502export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
503 playlist.items = playlist.items.map((item) => {
504 //some less space in the db by omitting ids
505 return { path: item.path, metadata: unproxy(item.metadata) };
506 });
507
508 if (overwrite) {
509 await db.playlists.put(playlist);
510 } else {
511 await db.playlists.add(playlist);
512 }
513}
514
515export async function deletePlaylist(name: string) {
516 await db.playlists.delete(name);
517}
518
519function collectMediaFilePaths(listing: FileListing, basePath: string): string[] {
520 const paths: string[] = [];
521
522 for (const [name, item] of Object.entries(listing)) {
523 const currentPath = joinPath(basePath, name);
524
525 if ("metadata" in item) {
526 paths.push(currentPath);
527 } else if ("files" in item) {
528 paths.push(...collectMediaFilePaths(item.files, currentPath));
529 }
530 }
531
532 return paths;
533}
534
535//deletes file or directory(+recursive contents) from indexedDB and updates their download statuses
536export async function deleteFile(
537 fullPath: string,
538 item: MediaFile | Directory,
539 fetchFiles: ResourceActions<FlatFileListing>,
540) {
541 try {
542 if ("metadata" in item && downloadStatusMap[fullPath] !== "Synced") return;
543 startProgress(fullPath, { amount: 1 });
544
545 let toDelete: string[];
546 if ("files" in item) {
547 toDelete = collectMediaFilePaths(listOfflineFiles(fullPath), fullPath);
548 } else {
549 toDelete = [fullPath];
550 }
551
552 //covers are keyed by file path (embedded art) and by directory path (directory art), so deleting a
553 //directory also has to drop its own cover row plus every nested one, or they stay orphaned forever
554 const coversToDelete = new Set(toDelete);
555 if ("files" in item) {
556 coversToDelete.add(fullPath);
557 for (const filePath of toDelete) {
558 for (const dir of parentPaths(filePath)) {
559 //stop at the deleted root itself, parents of it may still hold other synced files
560 if (dir === fullPath) break;
561 coversToDelete.add(dir);
562 }
563 }
564 }
565
566 await db.files.bulkDelete(toDelete); //delete file from database
567 await db.covers.bulkDelete([...coversToDelete]); //delete embedded/directory covers from database
568 //also drop them from the session cache, otherwise a later getCover(saveToDb) would re-add them
569 for (const coverPath of coversToDelete) coverCache.delete(coverPath);
570 deleteInOfflineTree(fullPath); //delete node from tree
571 batch(() => {
572 for (const path of toDelete) {
573 setDownloadStatusMap(path, undefined); // set all children to unsynced
574 }
575 });
576 fetchFiles.refetch();
577 } finally {
578 stopProgress(fullPath, undefined);
579 }
580}
581
582const downloadSemaphore = new AsyncSemaphore(1);
583
584//Download file to indexedDB and updates the offline file tree
585//concurrency control via the downloadSemaphore
586async function downloadFile(
587 path: string,
588 options: AppOptions,
589 item: MediaFile,
590 fetchFiles: ResourceActions<FlatFileListing>,
591 redownload: boolean,
592): Promise<void> {
593 const retry = () => downloadFile(path, options, item, fetchFiles, redownload);
594
595 // file already downloaded or download already in progress
596 const initialProgress = downloadStatusMap[path];
597 if (
598 (!redownload && initialProgress === "Synced") ||
599 //an in-progress download carries a numeric byte count, without this check clicking a
600 //downloading item would start a second fetch racing the first one on db.files.put
601 (typeof initialProgress === "object" && "status" in initialProgress && typeof initialProgress.status === "number")
602 )
603 return;
604
605 const controller = new AbortController();
606 const abortDownload = () => {
607 controller.abort();
608 stopProgress(
609 path,
610 // if it was already synced, restore it
611 initialProgress === "Synced" ? "Synced" : { status: "Aborted", retry },
612 );
613 };
614 startProgress(path, { status: 0, abort: abortDownload });
615
616 try {
617 //options might change at any point, update the semaphore to the current value
618 downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
619
620 //make sure to only allow the set amount of parallel downloads. passing the signal makes an abort
621 //while still queued reject here instead of entering the section below, so the release() in its
622 //finally can't hand out a permit that was never acquired (which would leak a concurrency slot)
623 await downloadSemaphore.acquire(controller.signal);
624 try {
625 const source = await getSrc(path, item.metadata, options, false, 0, true);
626 if (!source) throw new Error("Unable to create a download URL");
627 const res = await fetch(source.src, {
628 signal: controller.signal,
629 credentials: "same-origin",
630 });
631
632 handleUnauthorized(res);
633 if (!res.ok || !res.body) throw res.statusText;
634 //read the body ourselves to track progress, and assemble the blob from the same chunks -
635 //res.clone().blob() would buffer the whole body a second time and reject unobserved on a mid-body error
636 const reader = res.body.getReader();
637 //a fetch body never yields SharedArrayBuffer-backed views, so narrowing to ArrayBuffer is safe
638 const chunks: Uint8Array<ArrayBuffer>[] = [];
639 let received = 0;
640
641 let lastUpdate = 0;
642 while (true) {
643 const { done, value } = await reader.read();
644 if (done) break;
645 chunks.push(value as Uint8Array<ArrayBuffer>);
646 received += value.length;
647
648 //debouncing logic to not update UI too fast
649 const now = Date.now();
650 if (now - lastUpdate < 250) continue;
651 lastUpdate = now;
652 setDownloadStatusMap(path, { status: received, abort: abortDownload });
653 }
654 const blob = new Blob(chunks, { type: res.headers.get("content-type") ?? undefined });
655 const isVideo = item.metadata.videoCodec !== undefined;
656
657 //A video sync is transactional from the user's perspective: subtitle conversion and chapter
658 //extraction complete before the file row becomes Synced. A failed extra therefore leaves a
659 //previous redownload untouched and never exposes a half-synced video offline.
660 const videoExtras = isVideo ? await downloadVideoExtras(path, controller.signal) : undefined;
661 if (controller.signal.aborted) return;
662
663 const storedFile: StoredFile = {
664 path,
665 data: blob,
666 metadata: unproxy(item.metadata),
667 ...(videoExtras ? { videoExtras } : {}),
668 };
669 await db.files.put(storedFile);
670
671 updateFileTree(path, item, true);
672 stopProgress(path, "Synced");
673 fetchFiles.refetch();
674
675 //iterate through all parent directories and download their covers to database
676 for (const dir of [path, ...parentPaths(path)]) {
677 getCover(dir, options, true, false).then((cover) => URL.revokeObjectURL(cover || ""));
678 }
679 } finally {
680 downloadSemaphore.release();
681 }
682 } catch (e) {
683 if (e instanceof Error && e.name === "AbortError") return;
684 stopProgress(
685 path,
686 // if it was already synced, restore it
687 initialProgress === "Synced"
688 ? "Synced"
689 : {
690 status: "Failed",
691 reason: e ? e.toString() : "Unknown error",
692 retry,
693 },
694 );
695 }
696}
697
698export async function download(
699 fullPath: string,
700 item: MediaFile | Directory,
701 options: AppOptions,
702 fetchFiles: ResourceActions<FlatFileListing>,
703 redownload = false,
704) {
705 if ("metadata" in item) {
706 await downloadFile(fullPath, options, item, fetchFiles, redownload);
707 } else {
708 updateRecursiveStatus(fullPath, 1, true);
709 try {
710 //start downloads for all children to allow them to run in parallel, then wait for the results
711 const promises: Promise<unknown>[] = [];
712 const listing = await listFiles(fullPath, false, false);
713 batch(() => {
714 for (const [childName, childItem] of Object.entries(listing)) {
715 promises.push(download(joinPath(fullPath, childName), childItem, options, fetchFiles, redownload));
716 }
717 });
718 await Promise.allSettled(promises);
719 } finally {
720 updateRecursiveStatus(fullPath, -1, true);
721 }
722 }
723}
724
725//aborts all in-progress downloads and deletes every synced file and cover from indexedDB.
726export async function clearAllDownloads(fetchFiles: ResourceActions<FlatFileListing>) {
727 //abort first, so in-flight fetches reject with AbortError instead of writing to a cleared
728 //database. a download already past its fetch (sitting at db.files.put) cannot be aborted this
729 //way and may re-insert one row after the clear - harmless, and gone on the next clear/reload
730 for (const status of Object.values(downloadStatusMap)) {
731 if (status && typeof status === "object" && "status" in status && typeof status.status === "number") {
732 status.abort();
733 }
734 }
735
736 await db.files.clear();
737 await db.covers.clear();
738 coverCache.clear();
739 offlineTreeRoot.files = {};
740
741 batch(() => {
742 for (const path of Object.keys(downloadStatusMap)) {
743 setDownloadStatusMap(path, undefined);
744 }
745 });
746 fetchFiles.refetch();
747}
748
749//deletes every local trace of the app
750export async function clearLocalData(): Promise<string | undefined> {
751 localStorage.clear();
752
753 db.close();
754 if ("serviceWorker" in navigator) {
755 const registrations = await navigator.serviceWorker.getRegistrations().catch(() => []);
756 await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
757 }
758 if ("caches" in window) {
759 const cacheNames = await caches.keys().catch(() => []);
760 //awaited, otherwise the reload races the cleanup and can leave caches behind
761 await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName).catch(() => false)));
762 }
763
764 //onsuccess alone can hang forever: another open tab blocks the delete, and errors never resolve
765 return await new Promise<string | undefined>((resolve) => {
766 const request = indexedDB.deleteDatabase("music-files");
767 request.onsuccess = () => resolve(undefined);
768 request.onerror = () => resolve("Deleting the local database failed, close all other tabs and try again.");
769 request.onblocked = () =>
770 resolve("Another tab still has this app open, close it and try again to delete the local database.");
771 });
772}
773
774export const storedFilesLive = from(
775 liveQuery(async () => await db.files.toCollection().keys()),
776 [],
777);
778
779export const storageStatsLive = from(
780 liveQuery(async () => {
781 let fileBytes = 0;
782 await db.files.each((file) => {
783 fileBytes += file.data.size;
784 for (const subtitle of file.videoExtras?.subtitles || []) fileBytes += subtitle.data.size;
785 });
786 return {
787 fileCount: await db.files.count(),
788 fileBytes,
789 coverCount: await db.covers.count(),
790 playlistCount: await db.playlists.count(),
791 };
792 }),
793 { fileCount: 0, fileBytes: 0, coverCount: 0, playlistCount: 0 },
794);
795