request persistent storage + faq, fix browser freeze when syncing huge playlists
MREADME.md
@@ -45,6 +45,9 @@ You can configure the backend with the following environment variables:
- By default the backend sends the transcoded media as it progresses, which uses "chunked transfer", i.e. without the usual content-length of normal HTTP requests. Some browser (safari cough cough) seem to be very selective about the sources of \<audio\> elements, and choke on sources that are transferred via this method. This option forces the server to transcode the whole file before sending it, improving compatibility with these browser, at the cost of increased latency.
- The playback stops automatically at some point on my mobile device when I put the browser in the background or turning my screen off
- In my testing at least mobile firefox behaves this way, with no apparent solution. It seems to stop after changing the sources of the audio element at some point. Either use a browser that doesn't behave like this (Chrome on Android seems to work), or wrap the PWA in a native app (e.g. https://www.pwabuilder.com/)
- I'm getting "Quota exceeded" errors when trying to sync files *or*
- My synced files randomly don't work, are missing
- By default websites aren't granted infinite storage or have persisten data. The frontend requests these permissions by calling `navigator.storage.persist()` on startup, which causes some browsers to show a permissions popup (e.g. firefox), while others just seem to ignore this request and use a heuristic like interaction amount and bookmarking instead. So if you have the error described above you either are running out of actual disk storage, or you need to check what heuristics your browser uses and trigger them.
## Attributions
- Font: https://github.com/TakWolf/ark-pixel-font
Mwebclient/src/components/DownloadButton.tsx
@@ -3,7 +3,7 @@ import type { MediaFile, Directory, FileListing } from "music-server-shared/type
import { deleteFile, download, downloadStatusMap } from "../offline";
import type { Accessor, ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
import { joinPath } from "../utils";
import { formatBytes, joinPath } from "../utils";
import { DeleteSVG, RefreshSVG, SyncSVG } from "./SVG";
import { LoadingSpinner } from "./LoadingScreen";
@@ -16,17 +16,6 @@ interface DownloadButtonProps {
options: AppOptions;
}
function formatProgress(bytes: number): string {
if (bytes === 0) return "Queued...";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${value.toFixed(2)} ${sizes[i]}`;
}
export function DownloadButton(props: DownloadButtonProps) {
const fullPath = joinPath(props.currentDir(), props.fileName);
@@ -65,7 +54,7 @@ export function DownloadButton(props: DownloadButtonProps) {
);
}
if ("bytes" in progress) return <span>{formatProgress(progress.bytes)}</span>;
if ("bytes" in progress) return <span>{progress.bytes === 0 ? "Queued..." : formatBytes(progress.bytes)}</span>;
console.error("Unexpected", progress);
})()}
</div>
Mwebclient/src/components/Playlist.tsx
@@ -7,12 +7,13 @@ import {
createEffect,
onMount,
Show,
on,
} from "solid-js";
import "./Playlist.css";
import { getItemName, type PlayerState, type PlaylistItem, type AppOptions } from "../types";
import { formatTime, LocalStorageValues, toast } from "../utils";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
import { savePlaylist, isFileSynced, download, downloadStatusMap } from "../offline";
import { savePlaylist, download, downloadStatusMap, getStoredFiles } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FileListing } from "music-server-shared/types";
@@ -68,15 +69,18 @@ export default function Playlist(props: PlaylistProps) {
const progressToast = toast("Syncing files in playlist...", "progress");
try {
const itemsToSync: PlaylistItem[] = [];
for (const item of props.playlist()) {
if (!(await isFileSynced(item.path, downloadStatusMap[item.path]))) itemsToSync.push(item);
const playlist = props.playlist();
const unique = playlist.filter((item) => playlist.some((other) => item.path !== other.path));
const storedFiles = await getStoredFiles();
for (const item of unique) {
if (!storedFiles.includes(item.path)) itemsToSync.push(item);
}
const syncPromises = itemsToSync.map((item) => {
const downloadPromises = itemsToSync.map((item) => {
const basePath = item.path.split("/").slice(0, -1).join("/");
const fileName = item.path.split("/").toReversed()[0];
return download(fileName, item, basePath, props.currentDir, props.options, props.fetchFiles);
});
await Promise.allSettled(syncPromises);
await Promise.allSettled(downloadPromises);
progressToast.hideToast();
toast("Playlist sync completed", "success");
} catch (e) {
@@ -112,20 +116,16 @@ export default function Playlist(props: PlaylistProps) {
}
const [syncedCount, setSyncedCount] = createSignal(0);
createEffect(async () => {
const playlist = props.playlist();
let count = 0;
const promises = [];
for (const item of playlist) {
promises.push(
isFileSynced(item.path, downloadStatusMap[item.path]).then((res) => {
if (res) count++;
}),
);
}
await Promise.all(promises);
setSyncedCount(count);
});
createEffect(
on(
() => props.playlist().map((item) => downloadStatusMap[item.path]),
async () => {
const playlist = props.playlist();
const storedFiles = await getStoredFiles();
setSyncedCount(playlist.filter((item) => storedFiles.includes(item.path)).length);
},
),
);
onMount(() => {
const savedPlaylistButtons = localStorage.getItem(LocalStorageValues.playlistButtons);
Mwebclient/src/index.tsx
@@ -13,6 +13,8 @@ if (!(root instanceof HTMLElement)) {
);
}
navigator?.storage?.persist()?.catch(() => {});
function hasTouch() {
return (
"ontouchstart" in document.documentElement ||
Mwebclient/src/offline.ts
@@ -412,14 +412,9 @@ export async function download(
}
}
export async function isFileSynced(path: string, status: DownloadStatus | undefined): Promise<boolean> {
if (status === "Synced") return true;
//if in progress, it might already exist anyway and just is being re-synced right now
if (typeof status === "object" && "bytes" in status) {
const db = await openDB();
return await db.exist(FileStore, path);
}
return false;
export async function getStoredFiles() {
const db = await openDB();
return await db.getKeys(FileStore);
}
interface StoredFile {
@@ -445,9 +440,12 @@ async function openDB() {
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(FileStore)) db.createObjectStore(FileStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(CoverStore)) db.createObjectStore(CoverStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(PlaylistStore)) db.createObjectStore(PlaylistStore, { keyPath: "name" });
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: "path" }).createIndex("index", "path", { unique: true });
};
req.onsuccess = () => resolve(wrapDB(req.result));
});
@@ -463,7 +461,7 @@ interface DatabaseWrapper {
clear(store: string): Promise<void>;
listFiles(store: typeof FileStore): Promise<StoredFile[]>;
listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
exist(store: typeof FileStore, key: string): Promise<boolean>;
getKeys(store: typeof FileStore): Promise<string[]>;
}
function wrapDB(db: IDBDatabase): DatabaseWrapper {
@@ -517,11 +515,11 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
exist: (store: typeof FileStore, key: string) =>
getKeys: (store: typeof FileStore) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).count(key);
req.onsuccess = () => res(req.result !== 0);
const req = tx.objectStore(store).getAllKeys();
req.onsuccess = () => res(req.result as string[]);
req.onerror = () => rej(req.error);
}),
};
Mwebclient/src/utils.ts
@@ -81,3 +81,14 @@ export function fileListingToObject(listing: FileListing): FileListingRecord {
return result;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${value.toFixed(2)} ${sizes[i]}`;
}