offline.ts
⎇
Raw
1import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
2import type { AppOptions, Playlist } from "./types";
3import { joinPath, toast } from "./utils";
4import { createSignal, type ResourceActions, type Signal } from "solid-js";
5
6interface CachedFile {
7 duration: number;
8}
9interface Progress {
10 bytes: number;
11}
12
13export type SignalMapEntry = undefined | Progress | "DirectoryProgress" | "CachedFile";
14export const signalMap = new Map<string, Signal<SignalMapEntry>>();
15const metaDataMap = new Map<string, number>();
16const playlists: Playlist[] = [];
17
18export async function loadDataFromDB() {
19 try {
20 const db = await openDB();
21 const files = await db.listFiles(FileMetadataStore);
22 for (const file of files) {
23 signalMap.set(file.path, createSignal<SignalMapEntry>("CachedFile"));
24 metaDataMap.set(file.path, file.duration);
25 }
26 } catch (e) {
27 toast("Loading offline files failed", "error", e);
28 console.error(e);
29 }
30}
31
32type FileEntry = { name: string } & ({ type: "Directory" } | { type: "File"; duration: number });
33const directoryCache = new Map<string, FileEntry[]>();
34export const clearDirectoryCache = (path: string) => directoryCache.delete(path);
35
36async function requestFileList(dir: string, recursive: boolean): Promise<FileListing | undefined> {
37 const response = await fetch(`/list/${dir}?recursive=${recursive}`).catch(() => {});
38 if (response?.status === 404) {
39 toast(`Directory not found: ${dir}`, "error");
40 return;
41 }
42 if (!response || !response.ok) {
43 toast(`Fetching files for ${dir} failed${response ? `: ${response.statusText}` : ""}`, "error");
44 return;
45 }
46
47 const data: Directory = await response.json();
48 return data.files;
49}
50
51function saveFileListToCache(basePath: string, files: FileListing, recursive: boolean) {
52 const converted: FileEntry[] = files.map((item) => {
53 if ("files" in item) {
54 if (recursive) saveFileListToCache(joinPath(basePath, item.name), item.files, true);
55 return { ...item, type: "Directory" };
56 }
57 return { ...item, type: "File" };
58 });
59 directoryCache.set(basePath, converted);
60}
61
62function cachedToDirectory(fullPath: string, entries: FileEntry[] | undefined, recursive: boolean): FileListing | null {
63 if (!entries) return null;
64 const result = [];
65 for (const item of entries) {
66 const newPath = joinPath(fullPath, item.name);
67 let children = undefined;
68 if (item.type !== "File") {
69 children = recursive ? cachedToDirectory(newPath, directoryCache.get(newPath), true) : [];
70 if (!children) return null;
71 result.push({ name: item.name, files: children });
72 } else {
73 result.push({ name: item.name, duration: item.duration });
74 }
75 }
76 return result;
77}
78
79interface TreeDir {
80 dirs: Map<string, TreeDir>;
81 files: Map<string, AudioFile>;
82}
83function buildOfflineTree(): TreeDir {
84 const root: TreeDir = { dirs: new Map(), files: new Map() };
85 for (const [path, duration] of metaDataMap.entries()) {
86 const parts = path.split("/");
87 let node = root;
88 for (let i = 0; i < parts.length - 1; ++i) {
89 const part = parts[i];
90 let nextNodes = node.dirs.get(part);
91 if (!nextNodes) {
92 nextNodes = { dirs: new Map(), files: new Map() };
93 node.dirs.set(part, nextNodes);
94 }
95 node = nextNodes;
96 }
97 const filename = parts[parts.length - 1];
98 node.files.set(filename, { name: filename, duration });
99 }
100 return root;
101}
102
103/**
104 * Returns a directory listing mimicking the real filesystem,
105 * based entirely on in-memory offlineCache keys.
106 * @param dir Directory path, "" for root, else e.g. "a/b"
107 * @param recursive Whether to expand subdirectories recursively.
108 * @returns (Array of AudioFile | Directory)
109 */
110function listOfflineFiles(targetDir: string, recursive: boolean): FileListing {
111 const tree = buildOfflineTree();
112 // Normalize targetDir and walk down the tree
113 const normParts = targetDir === "" ? [] : targetDir.split("/").filter(Boolean);
114 let node = tree;
115 for (const part of normParts) {
116 const next = node.dirs.get(part);
117 if (!next) return [];
118 node = next;
119 }
120 // Now node corresponds to the directory we're listing
121 function dirEntries(n: TreeDir): FileListing {
122 const dirs: Directory[] = [];
123 for (const [name, childDir] of n.dirs) {
124 dirs.push({
125 name,
126 files: recursive ? dirEntries(childDir) : [],
127 });
128 }
129 const files: AudioFile[] = [];
130 for (const [name, f] of n.files) {
131 files.push({ name, duration: f.duration });
132 }
133 return [...dirs, ...files].sort((a, b) => a.name.localeCompare(b.name));
134 }
135 return dirEntries(node);
136}
137
138export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
139 if (offline) {
140 return listOfflineFiles(dir, recursive);
141 }
142 const cached = cachedToDirectory(dir, directoryCache.get(dir), recursive);
143 if (cached !== null) return cached;
144 const files = await requestFileList(dir, recursive);
145 if (!files) return [];
146 saveFileListToCache(dir, files, recursive);
147 return files;
148}
149
150export async function getCover(dir: string, offline: boolean) {
151 const db = await openDB();
152 const dbResult = await db.get(CoverStore, dir);
153 if (dbResult !== undefined) {
154 const cached = dbResult as StoredCover;
155 return cached.data === null ? undefined : URL.createObjectURL(new Blob([cached.data]));
156 }
157
158 if (offline) return undefined;
159
160 const response = await fetch(`/cover/${dir}`, { method: "GET" }).catch(() => {});
161 if (!response || response.status === 404) {
162 db.put(CoverStore, { path: dir, data: null });
163 return undefined;
164 }
165 const blob = await response.blob();
166 db.put(CoverStore, { path: dir, data: new Uint8Array(await blob.arrayBuffer()) });
167 return URL.createObjectURL(blob);
168}
169
170export const clearCoverCache = async (path: string) => {
171 const db = await openDB();
172 return db.delete(CoverStore, path);
173};
174
175export async function getSrc(path: string, options: AppOptions) {
176 const db = await openDB();
177 const dbResult = (await db.get(FileBlobStore, path)) as StoredFileBlob;
178 if (dbResult) return URL.createObjectURL(new Blob([dbResult.data]));
179
180 if (options.enableTranscoding) {
181 return `${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`;
182 }
183 return joinPath("/download", path);
184}
185
186export async function loadPlaylists() {
187 const db = await openDB();
188 return await db.listPlaylists(PlaylistStore);
189}
190
191export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
192 const db = await openDB();
193 if(overwrite) await db.put(PlaylistStore, playlist);
194 else await db.add(PlaylistStore, playlist);
195}
196
197export async function deletePlaylist(name: string) {
198 const db = await openDB();
199 await db.delete(PlaylistStore, name);
200}
201
202async function downloadFile(
203 path: string,
204 progressSignal: Signal<SignalMapEntry>,
205 options: AppOptions,
206 duration: number,
207 fetchFiles: ResourceActions<FileListing>,
208): Promise<void> {
209 const [getProgress, setProgress] = progressSignal;
210 const progress = getProgress();
211 if (typeof progress === "object" && "bytes" in progress) return; // download already in progress
212
213 const db = await openDB();
214 try {
215 setProgress({ bytes: 0 });
216
217 let res: Response;
218 if (options.enableTranscoding) {
219 res = await fetch(`${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`);
220 } else {
221 res = await fetch(joinPath("/download", path));
222 }
223 if (!res.ok || !res.body) throw res.statusText;
224 const reader = res.body.getReader();
225 const chunks: Uint8Array[] = [];
226 let received = 0;
227
228 let lastUpdate = 0;
229 while (true) {
230 const { done, value } = await reader.read();
231 if (done) break;
232 chunks.push(value);
233 received += value.length;
234
235 const now = Date.now();
236 if (now - lastUpdate < 100) continue;
237 lastUpdate = now;
238 setTimeout(() => {
239 setProgress({ bytes: received });
240 }, 0);
241 }
242
243 const data = concatChunks(chunks);
244
245 await db.put(FileBlobStore, {
246 path,
247 data,
248 } as StoredFileBlob);
249 await db.put(FileMetadataStore, {
250 path,
251 duration,
252 } as StoredFileMetadata);
253 metaDataMap.set(path, duration);
254 setProgress("CachedFile");
255 fetchFiles.refetch();
256 } catch (e) {
257 toast(`Downloading ${path} failed`, "error", e);
258 setProgress(undefined);
259 }
260}
261
262export async function deleteFile(
263 item: AudioFile | Directory,
264 basePath: string,
265 fetchFiles: ResourceActions<FileListing>, //allows triggering reloads in the file view
266 listing?: FileListing,
267) {
268 const fullPath = joinPath(basePath, item.name);
269 const signal = signalMap.get(fullPath);
270 if (!signal) return;
271 const [getProgress, setProgress] = signal;
272 const isFile = "duration" in item;
273 if (isFile && getProgress() !== "CachedFile") return;
274 if (!isFile) {
275 const newBasePath = joinPath(basePath, item.name);
276 const result = listing ?? listOfflineFiles(newBasePath, true);
277 for (const childItem of result) {
278 deleteFile(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
279 }
280 }
281
282 const db = await openDB();
283 await db.delete(FileBlobStore, fullPath);
284 await db.delete(FileMetadataStore, fullPath);
285 metaDataMap.delete(fullPath);
286 setProgress(undefined);
287 //TODO: check if current directory shown includes this file, otherwise we uncessesarily refresh the view
288 //idea: maybe make files a signal, sync it to the resource with a create effect, and we just overwrite the signal with the new item here
289 fetchFiles.refetch();
290}
291
292/**
293 * Download an AudioFile or Directory to local IndexedDB recursively.
294 */
295export async function download(
296 item: AudioFile | Directory,
297 basePath: string,
298 options: AppOptions,
299 fetchFiles: ResourceActions<FileListing>,
300 listing?: FileListing,
301) {
302 const fullPath = joinPath(basePath, item.name);
303 let signal = signalMap.get(fullPath);
304 if (!signal) {
305 signal = createSignal<SignalMapEntry>();
306 signalMap.set(fullPath, signal);
307 }
308 const [, setProgress] = signal;
309 if ("duration" in item) {
310 await downloadFile(fullPath, signal, options, item.duration, fetchFiles);
311 } else {
312 getCover(fullPath, false);
313 setProgress("DirectoryProgress");
314 try {
315 const result = listing ?? (await listFiles(fullPath, false, true));
316 for (const childItem of result) {
317 await download(childItem, fullPath, options, fetchFiles, "files" in childItem ? childItem.files : undefined);
318 }
319 } finally {
320 setProgress(undefined);
321 }
322 }
323}
324
325interface StoredFileBlob {
326 path: string;
327 data: Uint8Array;
328}
329
330interface StoredFileMetadata {
331 path: string;
332 duration: number;
333}
334
335interface StoredCover {
336 path: string;
337 data: Uint8Array | null;
338}
339
340const FileBlobStore = "fileblobs";
341const FileMetadataStore = "filemetadata";
342const CoverStore = "covers";
343const PlaylistStore = "playlists";
344
345async function openDB() {
346 return new Promise<DatabaseWrapper>((resolve, reject) => {
347 const req = indexedDB.open("music-files");
348 req.onerror = () => reject(req.error);
349 req.onupgradeneeded = () => {
350 const db = req.result;
351 if (!db.objectStoreNames.contains(FileBlobStore)) db.createObjectStore(FileBlobStore, { keyPath: "path" });
352 if (!db.objectStoreNames.contains(FileMetadataStore))
353 db.createObjectStore(FileMetadataStore, { keyPath: "path" });
354 if (!db.objectStoreNames.contains(CoverStore)) db.createObjectStore(CoverStore, { keyPath: "path" });
355 if (!db.objectStoreNames.contains(PlaylistStore)) db.createObjectStore(PlaylistStore, { keyPath: "name" });
356 };
357 req.onsuccess = () => resolve(wrapDB(req.result));
358 });
359}
360
361interface DatabaseWrapper {
362 get(store: typeof CoverStore | typeof FileBlobStore, key: string): Promise<unknown>;
363 put(store: typeof FileBlobStore, value: StoredFileBlob): Promise<void>;
364 put(store: typeof FileMetadataStore, value: StoredFileMetadata): Promise<void>;
365 put(store: typeof CoverStore, value: StoredCover): Promise<void>;
366 put(store: typeof PlaylistStore, value: Playlist): Promise<void>;
367 add(store: typeof PlaylistStore, value: Playlist): Promise<void>;
368 delete(
369 store: typeof FileBlobStore | typeof CoverStore | typeof FileMetadataStore | typeof PlaylistStore,
370 key: string,
371 ): Promise<void>;
372 clear(store: string): Promise<void>;
373 listFiles(store: typeof FileMetadataStore): Promise<StoredFileMetadata[]>;
374 listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
375}
376
377function wrapDB(db: IDBDatabase): DatabaseWrapper {
378 return {
379 get: (store: string, key: string) =>
380 new Promise((res, rej) => {
381 const tx = db.transaction(store, "readonly");
382 const req = tx.objectStore(store).get(key);
383 req.onsuccess = () => res(req.result);
384 req.onerror = () => rej(req.error);
385 }),
386 put: (store: string, value: unknown) =>
387 new Promise<void>((res, rej) => {
388 const tx = db.transaction(store, "readwrite");
389 const req: IDBRequest<IDBValidKey> = tx.objectStore(store).put(value);
390 req.onsuccess = () => res();
391 req.onerror = () => rej(req.error);
392 }),
393 add: (store: string, value: unknown) =>
394 new Promise<void>((res, rej) => {
395 const tx = db.transaction(store, "readwrite");
396 const req: IDBRequest<IDBValidKey> = tx.objectStore(store).add(value);
397 req.onsuccess = () => res();
398 req.onerror = () => rej(req.error);
399 }),
400 delete: (store: string, key: string) =>
401 new Promise<void>((res, rej) => {
402 const tx = db.transaction(store, "readwrite");
403 const req = tx.objectStore(store).delete(key);
404 req.onsuccess = () => res();
405 req.onerror = () => rej(req.error);
406 }),
407 clear: (store: string) =>
408 new Promise<void>((res, rej) => {
409 const tx = db.transaction(store, "readwrite");
410 const req = tx.objectStore(store).clear();
411 req.onsuccess = () => res();
412 req.onerror = () => rej(req.error);
413 }),
414 listFiles: (store: typeof FileMetadataStore) =>
415 new Promise((res, rej) => {
416 const tx = db.transaction(store, "readonly");
417 const req = tx.objectStore(store).getAll();
418 req.onsuccess = () => res(req.result);
419 req.onerror = () => rej(req.error);
420 }),
421 listPlaylists: (store: typeof PlaylistStore) =>
422 new Promise((res, rej) => {
423 const tx = db.transaction(store, "readonly");
424 const req = tx.objectStore(store).getAll();
425 req.onsuccess = () => res(req.result);
426 req.onerror = () => rej(req.error);
427 }),
428 };
429}
430
431function concatChunks(chunks: Uint8Array[]): Uint8Array {
432 let total = 0;
433 for (const c of chunks) total += c.length;
434 const result = new Uint8Array(total);
435 let offset = 0;
436 for (const c of chunks) {
437 result.set(c, offset);
438 offset += c.length;
439 }
440 return result;
441}
442