use dexie for indexeddb access

AuthorKonata <konata@posteo.jp>
Date
Commit093ccef3e53dccd3cc7ef83b38596e6ea84a68c5
Parentcb888ee
2 files changed, 49 insertions(+), 147 deletions(-)
▾Mwebclient/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"bun-types": "latest",
"dexie": "^4.0.11",
"lightningcss": "^1.29.3",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.5",
▾Mwebclient/src/offline.ts
@@ -13,6 +13,33 @@ import { AsyncSemaphore } from "./semaphore";
import { encodePath } from "music-server-shared/utils";
import type { FlatFileListing } from "./App";
import { createStore } from "solid-js/store";
import Dexie from "dexie";
interface StoredFile {
path: string;
data: Blob;
metadata: {
duration: number;
};
}
interface StoredCover {
path: string;
data: Blob | null;
}
interface Database extends Dexie {
files: Dexie.Table<StoredFile, string>;
covers: Dexie.Table<StoredCover, string>;
playlists: Dexie.Table<Playlist, string>;
}
const db = new Dexie("music-files") as Database;
db.version(1).stores({
files: "path",
covers: "path",
playlists: "name",
});
export type Progress =
| { status: number; abort: () => void }
@@ -68,8 +95,7 @@ function stopProgress(path: string, status: DownloadStatus) {
//initialize filetrees and download statuses from indexedDB
export async function loadDataFromDB() {
try {
const db = await openDB();
const files = await db.listFiles(FileStore);
const files = await db.files.toArray();
for (const file of files) {
setDownloadStatusMap(file.path, "Synced");
updateFileTree(file.path, { metadata: file.metadata }, true);
@@ -130,7 +156,7 @@ function updateFileTree(path: string, file: DirectoryWithStatus | MediaFile, off
}
}
//overloads for typecheking
//overloads for typechecking
function getDirInTree(path: string, create: true, offline: true): Directory;
function getDirInTree(path: string, create: true, offline: false): DirectoryWithStatus;
function getDirInTree(path: string, create: false, offline: true): Directory | null;
@@ -261,10 +287,8 @@ export async function listFiles(dir: string, offline: boolean, recursive: boolea
//get cover art for a directory with cache-first strategy
export async function getCover(dir: string, offline: boolean) {
const db = await openDB();
const dbResult = await db.get(CoverStore, dir);
if (dbResult !== undefined) {
const cached = dbResult as StoredCover;
const cached = await db.covers.get(dir);
if (cached !== undefined) {
return cached.data === null ? undefined : URL.createObjectURL(new Blob([cached.data]));
}
@@ -272,25 +296,23 @@ export async function getCover(dir: string, offline: boolean) {
const response = await fetch(`/cover/${encodePath(dir)}`, { method: "GET" }).catch(() => {});
if (!response || response.status === 404) {
db.put(CoverStore, { path: dir, data: null });
await db.covers.put({ path: dir, data: null });
return undefined;
}
const blob = await response.blob();
db.put(CoverStore, { path: dir, data: blob });
await db.covers.put({ path: dir, data: blob });
return URL.createObjectURL(blob);
}
export const clearCoverCache = async (path: string) => {
const db = await openDB();
return db.delete(CoverStore, path);
await db.covers.delete(path);
};
//returns an object url to the blob if synced, otherwise the server url
export async function getSrc(path: string, options: AppOptions, offline: boolean, forceUrl?: true): Promise<string>;
export async function getSrc(path: string, options: AppOptions, offline: boolean, forceUrl?: boolean) {
if (!forceUrl) {
const db = await openDB();
const dbResult = (await db.get(FileStore, path)) as StoredFile;
const dbResult = await db.files.get(path);
if (dbResult) return URL.createObjectURL(dbResult.data);
if (offline) {
@@ -305,25 +327,26 @@ export async function getSrc(path: string, options: AppOptions, offline: boolean
}
export async function loadPlaylists() {
const db = await openDB();
return await db.listPlaylists(PlaylistStore);
return await db.playlists.toArray();
}
//save playlist to indexedDB, throws error if playlist with that name already exists and overwrite=false
export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
const db = await openDB();
playlist.items = playlist.items.map((item) => {
//some less space in the db by omitting ids
//spread item.metadata because it might be a proxy object
return { path: item.path, metadata: { ...item.metadata } };
});
if (overwrite) await db.put(PlaylistStore, playlist);
else await db.add(PlaylistStore, playlist);
if (overwrite) {
await db.playlists.put(playlist);
} else {
await db.playlists.add(playlist);
}
}
export async function deletePlaylist(name: string) {
const db = await openDB();
await db.delete(PlaylistStore, name);
await db.playlists.delete(name);
}
function collectMediaFilePaths(listing: FileListing, basePath: string): string[] {
@@ -361,8 +384,7 @@ export async function deleteFile(
toDelete = [fullPath];
}
const db = await openDB();
await db.deleteAll(FileStore, toDelete); //delete from database
await db.files.bulkDelete(toDelete); //delete from database
deleteInOfflineTree(fullPath); //delete node from tree
for (const path of toDelete) {
setDownloadStatusMap(path, undefined); // set all children to unsynced
@@ -407,8 +429,6 @@ async function downloadFile(
startProgress(path, { status: 0, abort: abortResolve });
try {
const db = await openDB();
//options might change at any point, update the semaphore to the current value
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
@@ -439,11 +459,12 @@ async function downloadFile(
}
const blob = await blobPromise;
await db.put(FileStore, {
await db.files.put({
path,
data: blob,
metadata: { ...item.metadata }, //spread needed, because item might be a proxy object from the filebrowser, which can't be stored
} as StoredFile);
});
updateFileTree(path, item, true);
stopProgress(path, "Synced");
fetchFiles.refetch();
@@ -497,125 +518,5 @@ export async function download(
}
export async function getStoredFiles() {
const db = await openDB();
return await db.getKeys(FileStore);
}
interface StoredFile {
path: string;
data: Blob;
metadata: {
duration: number;
};
}
interface StoredCover {
path: string;
data: Blob | null;
}
const FileStore = "fileblobs";
const CoverStore = "covers";
const PlaylistStore = "playlists";
async function openDB() {
return new Promise<DatabaseWrapper>((resolve, reject) => {
const req = indexedDB.open("music-files");
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(FileStore))
db.createObjectStore(FileStore, { keyPath: "path" }).createIndex("index", "path", { unique: true });
if (!db.objectStoreNames.contains(CoverStore))
db.createObjectStore(CoverStore, { keyPath: "path" }).createIndex("index", "path", { unique: true });
if (!db.objectStoreNames.contains(PlaylistStore))
db.createObjectStore(PlaylistStore, { keyPath: "name" }).createIndex("index", "name", { unique: true });
};
req.onsuccess = () => resolve(wrapDB(req.result));
});
}
interface DatabaseWrapper {
get(store: typeof CoverStore | typeof FileStore, key: string): Promise<unknown>;
put(store: typeof FileStore, value: StoredFile): Promise<void>;
put(store: typeof CoverStore, value: StoredCover): Promise<void>;
put(store: typeof PlaylistStore, value: Playlist): Promise<void>;
add(store: typeof PlaylistStore, value: Playlist): Promise<void>;
delete(store: typeof CoverStore | typeof PlaylistStore, key: string): Promise<void>;
deleteAll(store: typeof FileStore, keys: string[]): Promise<void>;
clear(store: string): Promise<void>;
listFiles(store: typeof FileStore): Promise<StoredFile[]>;
listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
getKeys(store: typeof FileStore): Promise<string[]>;
}
function wrapDB(db: IDBDatabase): DatabaseWrapper {
return {
get: (store: string, key: string) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).get(key);
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
put: (store: string, value: unknown) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
const req: IDBRequest<IDBValidKey> = tx.objectStore(store).put(value);
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
add: (store: string, value: unknown) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
const req: IDBRequest<IDBValidKey> = tx.objectStore(store).add(value);
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
delete: (store: string, key: string) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
const req = tx.objectStore(store).delete(key);
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
deleteAll: (store: typeof FileStore, keys: string[]) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
const objectStore = tx.objectStore(store);
for (const key of keys) {
objectStore.delete(key);
}
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
}),
clear: (store: string) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
const req = tx.objectStore(store).clear();
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
listFiles: (store: typeof FileStore) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).getAll();
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
listPlaylists: (store: typeof PlaylistStore) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).getAll();
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
getKeys: (store: typeof FileStore) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).getAllKeys();
req.onsuccess = () => res(req.result as string[]);
req.onerror = () => rej(req.error);
}),
};
return await db.files.toCollection().primaryKeys();
}