use music-metadata instead of ffprobe, disable playback of uncached files when offline, better download behavior for many directories

AuthorKonata <konata@posteo.jp>
Date
Commit155e26acfac6a1333ebe43ea91a0fca4430555a7
Parent6824e9f
6 files changed, 49 insertions(+), 63 deletions(-)
▾Mserver/package.json
@@ -16,6 +16,7 @@
"command-exists": "^1.2.9",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"music-metadata": "^11.2.0",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1"
},
▾Mserver/src/utils.ts
@@ -4,6 +4,7 @@ import { MediaFormat, type Directory, type FileListing } from "music-server-shar
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
import { parseFile } from "music-metadata";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
@@ -43,36 +44,13 @@ export async function findCover(dirPath: string): Promise<CoverResult> {
}
export type FfprobeData = {
format: {
duration: number;
bit_rate: number;
};
duration: number;
bitrate: number;
};
// Rewritten to spawn ffprobe via bun.spawn
export async function probeFile(filePath: string): Promise<FfprobeData> {
const cached = probeCache.get(filePath);
if (cached) {
return cached;
}
const ffprobeArgs = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath];
const proc = Bun.spawn(["ffprobe", ...ffprobeArgs], {
stdout: "pipe",
stderr: "pipe",
});
const text = await new Response(proc.stdout).text();
let json: FfprobeData;
try {
json = JSON.parse(text);
} catch (e) {
const errText = await new Response(proc.stderr).text();
throw new ServerError(StatusMap["Internal Server Error"], `ffprobe failed: ${errText || text}`);
}
probeCache.set(filePath, json);
return json;
const parsed = await parseFile(filePath, { duration: true, skipCovers: true });
return { duration: parsed.format.duration as number, bitrate: parsed.format.bitrate as number };
}
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
@@ -109,7 +87,7 @@ export function matchesType(mimeType: string, types: string[]): boolean {
export function needsTranscoding(probe: FfprobeData, requestBitrate?: unknown) {
const bitrate = Number(requestBitrate);
return probe.format.bit_rate && bitrate > 0 && bitrate < probe.format.bit_rate;
return probe.bitrate && bitrate > 0 && bitrate < probe.bitrate;
}
export function isBelow(basePath: string, targetPath: string) {
@@ -141,7 +119,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Di
let duration = 0;
try {
const probe = await probeFile(itemPath);
duration = probe.format.duration || 0;
duration = probe.duration || 0;
} catch (probeError) {
console.error("ffprobe error:", probeError);
}
▾Mwebclient/index.html
@@ -55,7 +55,7 @@
<div id="root">
<div id="inital-loading-screen">
<span>
Loading required assets to start...
Loading required assets and local caches from database...
</span>
</div>
</div>
▾Mwebclient/src/App.tsx
@@ -47,6 +47,7 @@ const App: Component = () => {
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
const [isRepeated, setRepeated] = createSignal(false);
const [isOffline, setOffline] = createSignal(true);
createEffect(() => {
const parts = currentDir().split("/").filter(Boolean);
@@ -92,21 +93,24 @@ const App: Component = () => {
async function playTrack(track: PlaylistItem) {
if (playRequested) return;
playRequested = true;
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
//need both of these lines to work without logging an error
//just removeAttribute doesn't work at all
//just srcObject = null works, but logs a weird csp error
audioElement.removeAttribute("src");
audioElement.srcObject = null;
audioElement.src = await getSrc(track.path, options());
setPlayerState({
...playerState(),
currentTrack: track,
isPlaying: true,
currentTime: 0,
});
try {
const src = await getSrc(track.path, options(), isOffline());
console.log(src);
if (!src) return;
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
//need both of these lines to work without logging an error
//just removeAttribute doesn't work at all
//just srcObject = null works, but logs a weird csp error
audioElement.removeAttribute("src");
audioElement.srcObject = null;
audioElement.src = src;
setPlayerState({
...playerState(),
currentTrack: track,
isPlaying: true,
currentTime: 0,
});
await audioElement.play();
} catch (e) {
setPlayerState({
@@ -306,6 +310,8 @@ const App: Component = () => {
showFileBrowser={showFileBrowser}
setShowFileBrowser={setShowFileBrowser}
options={options}
isOffline={isOffline}
setOffline={setOffline}
/>
<Playlist
▾Mwebclient/src/components/FileBrowser.tsx
@@ -28,18 +28,19 @@ interface FileBrowserProps {
showFileBrowser: Accessor<boolean>;
setShowFileBrowser: Setter<boolean>;
options: Accessor<AppOptions>;
isOffline: Accessor<boolean>;
setOffline: Setter<boolean>;
}
export default function FileBrowser(props: FileBrowserProps) {
const [isOffline, setOffline] = createSignal(true);
const [loadingActions, setLoadingActions] = createSignal(0);
const [files, fetchFiles] = createResource(
() => ({ dir: props.currentDir(), offline: isOffline() }),
() => ({ dir: props.currentDir(), offline: props.isOffline() }),
async (args) => await listFiles(args.dir, args.offline, false),
{ initialValue: [] },
);
const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
() => ({ dir: props.currentDir(), offline: isOffline() }),
() => ({ dir: props.currentDir(), offline: props.isOffline() }),
async (args, info) => {
if (info.value) URL.revokeObjectURL(info.value as string);
return await getCover(args.dir, args.offline);
@@ -107,7 +108,7 @@ export default function FileBrowser(props: FileBrowserProps) {
}
try {
const selectedDir = dir || props.currentDir();
const files = await listFiles(selectedDir, isOffline(), recursive);
const files = await listFiles(selectedDir, props.isOffline(), recursive);
const newPlaylist = collectAudioFiles(files, selectedDir);
const oldPlaylist = props.playlist();
if (queue) {
@@ -134,14 +135,14 @@ export default function FileBrowser(props: FileBrowserProps) {
onMount(() => {
try {
const savedOffline = localStorage.getItem(LocalStorageValues.offline);
setOffline(savedOffline === "true");
props.setOffline(savedOffline === "true");
} catch (e) {
toast("loading values from localStorage failed", "error", e);
localStorage.clear();
}
createEffect(() => {
createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
createEffect(() => localStorage.setItem(LocalStorageValues.offline, props.isOffline().toString()));
});
});
@@ -180,7 +181,7 @@ export default function FileBrowser(props: FileBrowserProps) {
</button>
</div>
<div class="spacer" />
<Show when={!isOffline()}>
<Show when={!props.isOffline()}>
<button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
<RefreshSVG />
</button>
@@ -188,9 +189,9 @@ export default function FileBrowser(props: FileBrowserProps) {
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: isOffline() ? "lightgreen" : "indianred" }}
textContent={isOffline() ? "Go online" : "Go offline"}
onclick={() => setOffline(!isOffline())}
style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
textContent={props.isOffline() ? "Go online" : "Go offline"}
onclick={() => props.setOffline(!props.isOffline())}
/>
</div>
@@ -216,7 +217,7 @@ export default function FileBrowser(props: FileBrowserProps) {
<DownloadButton
item={item}
fetchFiles={fetchFiles}
isOffline={isOffline}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
/>
▾Mwebclient/src/offline.ts
@@ -189,11 +189,15 @@ export const clearCoverCache = async (path: string) => {
return db.delete(CoverStore, path);
};
export async function getSrc(path: string, options: AppOptions) {
export async function getSrc(path: string, options: AppOptions, offline: boolean) {
const db = await openDB();
const dbResult = (await db.get(FileBlobStore, path)) as StoredFileBlob;
if (dbResult) return URL.createObjectURL(dbResult.data);
if (offline) {
toast("This file is not cached, leave offline mode to play it", "error");
return undefined;
}
if (options.enableTranscoding) {
return `${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`;
}
@@ -323,7 +327,6 @@ export async function download(
basePath: string,
options: AppOptions,
fetchFiles: ResourceActions<FileListing>,
listing?: FileListing,
) {
const fullPath = joinPath(basePath, item.name);
let signal = signalMap.get(fullPath);
@@ -335,15 +338,12 @@ export async function download(
if ("duration" in item) {
await downloadFile(fullPath, signal, options, item.duration, fetchFiles);
} else {
getCover(fullPath, false);
setProgress("DirectoryProgress");
await getCover(fullPath, false);
try {
const result = listing ?? (await listFiles(fullPath, false, true));
const promises: Promise<unknown>[] = [];
for (const childItem of result) {
promises.push(
download(childItem, fullPath, options, fetchFiles, "files" in childItem ? childItem.files : undefined),
);
for (const childItem of await listFiles(fullPath, false, false)) {
promises.push(download(childItem, fullPath, options, fetchFiles));
}
await Promise.allSettled(promises);
} finally {