offline.ts
⎇
Raw
1import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
2import type { AppOptions, Playlist, PlaylistItem } from "./types";
3import { joinPath, toast } from "./utils";
4import { type Accessor, createSignal, type ResourceActions, type Signal } from "solid-js";
5import { AsyncSemaphore } from "./semaphore";
6
7interface Progress {
8 bytes: number;
9}
10
11export type SignalMapEntry = undefined | Progress | "DirectoryProgress" | "CachedFile";
12export const signalMap = new Map<string, Signal<SignalMapEntry>>();
13
14function updateOfflineFileTree(path: string, duration: number): void {
15 const basePath = path.split("/").slice(0, -1).join("/");
16 const name = path.split("/").toReversed()[0];
17 const node = getDirInTree(basePath, true);
18
19 const existingFileIndex = node.files.findIndex((f) => f.name === name);
20
21 const file = { name: name, duration: duration };
22 if (existingFileIndex >= 0) {
23 node.files[existingFileIndex] = file;
24 } else {
25 node.files.push(file);
26 }
27}
28
29export async function loadDataFromDB() {
30 try {
31 const db = await openDB();
32 const files = await db.listFiles(FileMetadataStore);
33 for (const file of files) {
34 signalMap.set(file.path, createSignal<SignalMapEntry>("CachedFile"));
35 updateOfflineFileTree(file.path, file.duration);
36 }
37 } catch (e) {
38 toast("Loading offline files failed", "error", e);
39 console.error(e);
40 }
41}
42
43interface DirectoryNode {
44 directories: Record<string, DirectoryNode>;
45 files: AudioFile[];
46}
47
48const directoryTree: DirectoryNode = {
49 directories: {},
50 files: [],
51};
52
53// get a DirectoryNode from the directory tree, and optionally create it if it doesn't exist yet
54function getDirInTree(path: string, create: true): DirectoryNode;
55function getDirInTree(path: string, create: false): DirectoryNode | null;
56function getDirInTree(path: string, create: boolean): DirectoryNode | null {
57 const parts = path.split("/").filter(Boolean);
58 let current = directoryTree;
59
60 for (const part of parts) {
61 if (!current.directories[part]) {
62 if (!create) return null;
63 current.directories[part] = { directories: {}, files: [] };
64 }
65 current = current.directories[part];
66 }
67 return current;
68}
69
70function deletePath(path: string): void {
71 const basePath = path.split("/").slice(0, -1).join("/");
72 const node = getDirInTree(basePath, false);
73 if (!node) return;
74
75 const name = path.split("/").toReversed()[0];
76 delete node.directories[name];
77 node.files = node.files.filter((file) => file.name !== name);
78}
79
80type FileEntry = { name: string } & ({ type: "Directory" } | { type: "File"; duration: number });
81const directoryCache = new Map<string, FileEntry[]>();
82export const clearDirectoryCache = (path: string) => directoryCache.delete(path);
83
84async function requestFileList(dir: string, recursive: boolean): Promise<FileListing | undefined> {
85 const response = await fetch(`/list/${dir}?recursive=${recursive}`).catch(() => {});
86 if (response?.status === 404) {
87 toast(`Directory not found: ${dir}`, "error");
88 return;
89 }
90 if (!response || !response.ok) {
91 toast(
92 `Fetching files for ${dir === "" ? "root" : dir} failed${response ? `: ${response.statusText}` : ""}`,
93 "error",
94 );
95 return;
96 }
97
98 const data: Directory = await response.json();
99 return data.files;
100}
101
102function saveFileListToCache(basePath: string, files: FileListing, recursive: boolean) {
103 const converted: FileEntry[] = files.map((item) => {
104 if ("files" in item) {
105 if (recursive) saveFileListToCache(joinPath(basePath, item.name), item.files, true);
106 return { ...item, type: "Directory" };
107 }
108 return { ...item, type: "File" };
109 });
110 directoryCache.set(basePath, converted);
111}
112
113function cachedToDirectory(fullPath: string, entries: FileEntry[] | undefined, recursive: boolean): FileListing | null {
114 if (!entries) return null;
115 const result = [];
116 for (const item of entries) {
117 const newPath = joinPath(fullPath, item.name);
118 let children = undefined;
119 if (item.type !== "File") {
120 children = recursive ? cachedToDirectory(newPath, directoryCache.get(newPath), true) : [];
121 if (!children) return null;
122 result.push({ name: item.name, files: children });
123 } else {
124 result.push({ name: item.name, duration: item.duration });
125 }
126 }
127 return result;
128}
129
130function listOfflineFiles(dir: string, recursive: boolean): FileListing {
131 const node = getDirInTree(dir, false);
132 if (!node) return [];
133 const entries: FileListing = [];
134
135 entries.push(...node.files);
136
137 if (recursive) {
138 for (const [name, childNode] of Object.entries(node.directories)) {
139 const directoryEntry: Directory = {
140 name,
141 files: childNode.files,
142 };
143 directoryEntry.files = listOfflineFiles(joinPath(dir, name), true);
144
145 entries.push(directoryEntry);
146 }
147 } else {
148 entries.push(...Object.keys(node.directories).map((dir) => ({ name: dir, files: [] })));
149 }
150
151 return entries.sort((a, b) => a.name.localeCompare(b.name));
152}
153
154export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
155 if (offline) {
156 const res = listOfflineFiles(dir, recursive);
157 return res;
158 }
159 const cached = cachedToDirectory(dir, directoryCache.get(dir), recursive);
160 if (cached !== null) return cached;
161 const files = await requestFileList(dir, recursive);
162 if (!files) return [];
163 saveFileListToCache(dir, files, recursive);
164 return files;
165}
166
167export async function getCover(dir: string, offline: boolean) {
168 const db = await openDB();
169 const dbResult = await db.get(CoverStore, dir);
170 if (dbResult !== undefined) {
171 const cached = dbResult as StoredCover;
172 return cached.data === null ? undefined : URL.createObjectURL(new Blob([cached.data]));
173 }
174
175 if (offline) return undefined;
176
177 const response = await fetch(`/cover/${dir}`, { method: "GET" }).catch(() => {});
178 if (!response || response.status === 404) {
179 db.put(CoverStore, { path: dir, data: null });
180 return undefined;
181 }
182 const blob = await response.blob();
183 db.put(CoverStore, { path: dir, data: blob });
184 return URL.createObjectURL(blob);
185}
186
187export const clearCoverCache = async (path: string) => {
188 const db = await openDB();
189 return db.delete(CoverStore, path);
190};
191
192export async function getSrc(path: string, options: AppOptions, offline: boolean) {
193 const db = await openDB();
194 const dbResult = (await db.get(FileBlobStore, path)) as StoredFileBlob;
195 if (dbResult) return URL.createObjectURL(dbResult.data);
196
197 if (offline) {
198 toast("This file is not cached, leave offline mode to play it", "error");
199 return undefined;
200 }
201 if (options.enableTranscoding) {
202 return `${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`;
203 }
204 return joinPath("/download", path);
205}
206
207export async function loadPlaylists() {
208 const db = await openDB();
209 return await db.listPlaylists(PlaylistStore);
210}
211
212export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
213 const db = await openDB();
214 playlist.items = playlist.items.map((item) => {
215 //some less space in the db by omitting ids
216 const { id, ...itemWithoutId } = item as PlaylistItem;
217 return itemWithoutId;
218 });
219 if (overwrite) await db.put(PlaylistStore, playlist);
220 else await db.add(PlaylistStore, playlist);
221}
222
223export async function deletePlaylist(name: string) {
224 const db = await openDB();
225 await db.delete(PlaylistStore, name);
226}
227
228export async function deleteFile(
229 item: AudioFile | Directory,
230 basePath: string,
231 fetchFiles: ResourceActions<FileListing>,
232 listing?: FileListing,
233) {
234 const fullPath = joinPath(basePath, item.name);
235 const signal = signalMap.get(fullPath);
236 if (!signal) return;
237
238 if (!("duration" in item)) {
239 const newBasePath = joinPath(basePath, item.name);
240 const result = listing ?? listOfflineFiles(newBasePath, true);
241 for (const childItem of result) {
242 await deleteFile(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
243 }
244 }
245
246 deletePath(fullPath);
247 const db = await openDB();
248 await db.delete(FileBlobStore, fullPath);
249 await db.delete(FileMetadataStore, fullPath);
250 signalMap.get(fullPath)?.[1](undefined);
251 fetchFiles.refetch();
252}
253
254const downloadSemaphore = new AsyncSemaphore(1);
255
256async function downloadFile(
257 path: string,
258 currentDir: Accessor<string>,
259 progressSignal: Signal<SignalMapEntry>,
260 options: AppOptions,
261 duration: number,
262 fetchFiles: ResourceActions<FileListing>,
263): Promise<void> {
264 const [getProgress, setProgress] = progressSignal;
265 const progress = getProgress();
266 if (typeof progress === "object" && "bytes" in progress) return; // download already in progress
267
268 const db = await openDB();
269 let mimeType: string | undefined;
270 try {
271 setProgress({ bytes: 0 });
272
273 downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
274 let blob: Blob;
275 try {
276 await downloadSemaphore.acquire();
277 let res: Response;
278 if (options.enableTranscoding) {
279 res = await fetch(`${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`);
280 } else {
281 res = await fetch(joinPath("/download", path));
282 }
283 //let the browser do the actual download
284 const blobPromise = res.clone().blob();
285 if (!res.ok || !res.body) throw res.statusText;
286 //we just iterate over the chunks to get the current progress
287 const reader = res.body.getReader();
288 mimeType = res.headers.get("content-type") ?? undefined;
289 let received = 0;
290
291 let lastUpdate = 0;
292 while (true) {
293 const { done, value } = await reader.read();
294 if (done) break;
295 received += value.length;
296
297 const now = Date.now();
298 if (now - lastUpdate < 250) continue;
299 lastUpdate = now;
300 setProgress({ bytes: received });
301 }
302 blob = await blobPromise;
303 } finally {
304 downloadSemaphore.release();
305 }
306
307 await db.put(FileBlobStore, {
308 path,
309 data: blob,
310 } as StoredFileBlob);
311 await db.put(FileMetadataStore, {
312 path,
313 duration,
314 } as StoredFileMetadata);
315 updateOfflineFileTree(path, duration);
316 setProgress("CachedFile");
317 const basePath = path.split("/").slice(0, -1).join("/");
318 const name = path.split("/").toReversed()[0];
319 if (currentDir() === basePath) {
320 fetchFiles.mutate((prev) => {
321 if (prev.findIndex((f) => f.name === name) !== -1) {
322 return prev;
323 }
324 return [...prev, { name, duration }].sort((a, b) => a.name.localeCompare(b.name));
325 });
326 }
327 } catch (e) {
328 toast(`Downloading ${path} failed`, "error", e);
329 setProgress(undefined);
330 }
331}
332
333export async function download(
334 item: AudioFile | Directory,
335 basePath: string,
336 currentDir: Accessor<string>,
337 options: AppOptions,
338 fetchFiles: ResourceActions<FileListing>,
339) {
340 const fullPath = joinPath(basePath, item.name);
341 let signal = signalMap.get(fullPath);
342 if (!signal) {
343 signal = createSignal<SignalMapEntry>();
344 signalMap.set(fullPath, signal);
345 }
346 const [, setProgress] = signal;
347 if ("duration" in item) {
348 await downloadFile(fullPath, currentDir, signal, options, item.duration, fetchFiles);
349 } else {
350 setProgress("DirectoryProgress");
351 await getCover(fullPath, false);
352 try {
353 const promises: Promise<unknown>[] = [];
354 for (const childItem of await listFiles(fullPath, false, false)) {
355 promises.push(download(childItem, fullPath, currentDir, options, fetchFiles));
356 }
357 await Promise.allSettled(promises);
358 } finally {
359 setProgress(undefined);
360 }
361 }
362}
363
364interface StoredFileBlob {
365 path: string;
366 data: Blob;
367}
368
369interface StoredFileMetadata {
370 path: string;
371 duration: number;
372}
373
374interface StoredCover {
375 path: string;
376 data: Blob | null;
377}
378
379//separate blobs and metadata, so we don't have to load all file contents at startup, which would consume huge amounts of ram, and be very slow
380const FileBlobStore = "fileblobs";
381const FileMetadataStore = "filemetadata";
382const CoverStore = "covers";
383const PlaylistStore = "playlists";
384
385async function openDB() {
386 return new Promise<DatabaseWrapper>((resolve, reject) => {
387 const req = indexedDB.open("music-files");
388 req.onerror = () => reject(req.error);
389 req.onupgradeneeded = () => {
390 const db = req.result;
391 if (!db.objectStoreNames.contains(FileBlobStore)) db.createObjectStore(FileBlobStore, { keyPath: "path" });
392 if (!db.objectStoreNames.contains(FileMetadataStore))
393 db.createObjectStore(FileMetadataStore, { keyPath: "path" });
394 if (!db.objectStoreNames.contains(CoverStore)) db.createObjectStore(CoverStore, { keyPath: "path" });
395 if (!db.objectStoreNames.contains(PlaylistStore)) db.createObjectStore(PlaylistStore, { keyPath: "name" });
396 };
397 req.onsuccess = () => resolve(wrapDB(req.result));
398 });
399}
400
401interface DatabaseWrapper {
402 get(store: typeof CoverStore | typeof FileBlobStore, key: string): Promise<unknown>;
403 put(store: typeof FileBlobStore, value: StoredFileBlob): Promise<void>;
404 put(store: typeof FileMetadataStore, value: StoredFileMetadata): Promise<void>;
405 put(store: typeof CoverStore, value: StoredCover): Promise<void>;
406 put(store: typeof PlaylistStore, value: Playlist): Promise<void>;
407 add(store: typeof PlaylistStore, value: Playlist): Promise<void>;
408 delete(
409 store: typeof FileBlobStore | typeof CoverStore | typeof FileMetadataStore | typeof PlaylistStore,
410 key: string,
411 ): Promise<void>;
412 clear(store: string): Promise<void>;
413 listFiles(store: typeof FileMetadataStore): Promise<StoredFileMetadata[]>;
414 listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
415}
416
417function wrapDB(db: IDBDatabase): DatabaseWrapper {
418 return {
419 get: (store: string, key: string) =>
420 new Promise((res, rej) => {
421 const tx = db.transaction(store, "readonly");
422 const req = tx.objectStore(store).get(key);
423 req.onsuccess = () => res(req.result);
424 req.onerror = () => rej(req.error);
425 }),
426 put: (store: string, value: unknown) =>
427 new Promise<void>((res, rej) => {
428 const tx = db.transaction(store, "readwrite");
429 const req: IDBRequest<IDBValidKey> = tx.objectStore(store).put(value);
430 req.onsuccess = () => res();
431 req.onerror = () => rej(req.error);
432 }),
433 add: (store: string, value: unknown) =>
434 new Promise<void>((res, rej) => {
435 const tx = db.transaction(store, "readwrite");
436 const req: IDBRequest<IDBValidKey> = tx.objectStore(store).add(value);
437 req.onsuccess = () => res();
438 req.onerror = () => rej(req.error);
439 }),
440 delete: (store: string, key: string) =>
441 new Promise<void>((res, rej) => {
442 const tx = db.transaction(store, "readwrite");
443 const req = tx.objectStore(store).delete(key);
444 req.onsuccess = () => res();
445 req.onerror = () => rej(req.error);
446 }),
447 clear: (store: string) =>
448 new Promise<void>((res, rej) => {
449 const tx = db.transaction(store, "readwrite");
450 const req = tx.objectStore(store).clear();
451 req.onsuccess = () => res();
452 req.onerror = () => rej(req.error);
453 }),
454 listFiles: (store: typeof FileMetadataStore) =>
455 new Promise((res, rej) => {
456 const tx = db.transaction(store, "readonly");
457 const req = tx.objectStore(store).getAll();
458 req.onsuccess = () => res(req.result);
459 req.onerror = () => rej(req.error);
460 }),
461 listPlaylists: (store: typeof PlaylistStore) =>
462 new Promise((res, rej) => {
463 const tx = db.transaction(store, "readonly");
464 const req = tx.objectStore(store).getAll();
465 req.onsuccess = () => res(req.result);
466 req.onerror = () => rej(req.error);
467 }),
468 };
469}
470