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