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