split cover cache to permanent and temporary, make filebrowser refresh only current directory, add cover transcoding
MContainerfile
@@ -6,7 +6,7 @@ RUN bun install --cwd server
RUN bun run --cwd server compile
FROM docker.io/alpine:3.21
RUN apk add catatonit ffmpeg tar
RUN apk add catatonit ffmpeg tar libavif-apps
WORKDIR /app
COPY --from=compile /app/server/dist .
MREADME.md
@@ -29,6 +29,13 @@ MUSIC_ROOT=/path/to/your/music
docker-compose up # or podman-compose up
```
## Runtime Dependencies
If you choose to not run this via the include Containerfile, you need to ensure these dependencies are installed:
- `bun`: Used for building, also the backend is using some bun-specific APIs
- `tar`: To bundle playlists to a single file when downloading them
- `ffmpeg`: For transcoding audio
- `avifenc`: For transcoding cover art
## Configuration
You can configure the backend with the following environment variables:
- `COVER_REGEX`: JS-compatible regex to match filenames in a directory to find a matching cover art. Searches the directory structure upwards. Set to an empty string to disable cover detection.
Mserver/package.json
@@ -14,17 +14,19 @@
"devDependencies": {
"@types/command-exists": "^1.2.3",
"@types/range-parser": "^1.2.7",
"@types/tmp": "^0.2.6",
"bun-types": "latest",
"concurrently": "^9.1.2"
},
"dependencies": {
"elysia": "^1.2.0",
"@elysiajs/static": "^1.2.0",
"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"
"range-parser": "^1.2.1",
"tmp": "^0.2.3"
},
"module": "src/index.js"
}
Mserver/src/index.ts
@@ -11,7 +11,16 @@ import {
mediaTypes,
generatedPlaylistIds,
} from "./shared";
import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile } from "./utils";
import {
getPathInfo,
listFiles,
findCover,
matchesType,
convertWithFFmpeg,
packWithTar,
probeFile,
toAvif,
} from "./utils";
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
@@ -166,24 +175,34 @@ const app = setup
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
.get("/cover/*", async ({ params, set }) => {
set.status = "Not Found";
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const fileScanResult = await findCover(dirPath);
if ("path" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
//wrapping in new response discards the accept-range header, which we don't support here
return new Response(Bun.file(fileScanResult.path));
}
if ("content" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
return new Response(fileScanResult.content);
}
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
})
.get(
"/cover/*",
async ({ params, set, query }) => {
set.status = "Not Found";
const dirPath = path.join(musicRoot, decodePath(params["*"]));
const fileScanResult = await findCover(dirPath);
const setContentType = (type: string) => {
set.headers["Content-Type"] = type;
};
if ("path" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
if (query.transcode)
return new Response(await toAvif(await Bun.file(fileScanResult.path).bytes(), fileScanResult.info.mimeType, setContentType));
//wrapping in new response discards the accept-range header, which we don't support here
return new Response(Bun.file(fileScanResult.path));
}
if ("content" in fileScanResult) {
set.headers["Content-Type"] = fileScanResult.info.mimeType;
set.status = "OK";
if (query.transcode) return new Response(await toAvif(fileScanResult.content, fileScanResult.info.mimeType, setContentType));
return new Response(fileScanResult.content);
}
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
},
{ query: t.Optional(t.Object({ transcode: t.Boolean() })) },
)
.post("/prepare-playlist", async ({ set, body }) => {
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
Mserver/src/utils.ts
@@ -14,6 +14,8 @@ import {
ServerError,
} from "./shared";
import { parseFile } from "music-metadata";
import tmp from "tmp";
import { readFileSync, writeFileSync } from "node:fs";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
@@ -171,7 +173,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
}
}
async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
@@ -189,7 +191,7 @@ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<strin
offset += chunk.length;
}
return new TextDecoder().decode(combined);
return combined;
} finally {
reader.releaseLock();
}
@@ -243,7 +245,7 @@ export function convertWithFFmpeg(filePath: string, bitrate: number, format: Med
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("ffmpeg failed with status code", exitCode);
console.error(await streamToString(subprocess.stderr as ReadableStream));
console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
}
},
},
@@ -275,8 +277,43 @@ export function packWithTar(files: string[]) {
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("tar failed with status code", exitCode);
console.error(await streamToString(subprocess.stderr as ReadableStream));
console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
}
},
});
}
//copy to a temp file and then use avifenc to convert
export async function toAvif(
data: Uint8Array,
mimeType: string,
setContentType: (mimeType: string) => void,
): Promise<Uint8Array> {
const tmpFile = tmp.fileSync({
postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/jpeg") ? ".png" : undefined,
});
const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
try {
writeFileSync(tmpFile.fd, data);
const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("avifenc failed with status code", exitCode);
console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
}
},
});
await cmd.exited;
setContentType("image/avif");
return readFileSync(tmpFile2.fd);
} catch (e) {
setContentType(mimeType);
return data;
} finally {
tmpFile.removeCallback();
tmpFile2.removeCallback();
}
}
Mwebclient/src/App.tsx
@@ -22,8 +22,8 @@ import { createStore, reconcile } from "solid-js/store";
import { decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
//TODO: add bitrate+format to metadata
//TODO: more file display options
//TODO: cover transcoding
//TODO: keep synced-aborted in downloadmanager
//TODO: bypass music-metadata if it fails
//TODO: Add documentation to functions
@@ -58,6 +58,7 @@ const App: Component = () => {
format: MediaFormat["opus/webm"],
maxTranscodes: 4,
maxDownloads: 2,
transcodeCovers: false,
hideVolume: false,
hideCover: false,
@@ -125,7 +126,12 @@ const App: Component = () => {
}
const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
const artwork = await getCover(playerState.currentTrack.path.split("/").slice(0, -1).join("/"), isOffline());
const artwork = await getCover(
playerState.currentTrack.path.split("/").slice(0, -1).join("/"),
options,
false,
isOffline(),
);
navigator.mediaSession.metadata = new MediaMetadata({
title: formatFilename(playerState.currentTrack, options.controlsDisplay),
artwork: [
Mwebclient/src/components/FileBrowser.tsx
@@ -11,13 +11,12 @@ import {
type ResourceActions,
type Setter,
Show,
Suspense,
} from "solid-js";
import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { formatFilename, generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
import { clearDirectoryCache, getCover, listFiles } from "../offline";
import { DownloadButton } from "./DownloadButton";
import { LoadingScreen } from "./LoadingScreen";
import type { FlatFileListing } from "../App";
@@ -59,7 +58,7 @@ export default function FileBrowser(props: FileBrowserProps) {
() => ({ 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);
return await getCover(args.dir, props.options, false, args.offline);
},
{ initialValue: undefined },
);
@@ -175,11 +174,8 @@ export default function FileBrowser(props: FileBrowserProps) {
}
function reloadDirectory() {
clearDirectoryCache(props.currentDir());
//TODO: only clears the current dir, not recursively like clearDirectoryCache
deleteCover(props.currentDir());
props.fetchFiles.refetch();
fetchCover.refetch();
clearDirectoryCache(props.currentDir()).then(() => props.fetchFiles.refetch());
getCover(props.currentDir(), props.options, false, props.isOffline(), true).then(() => fetchCover.refetch());
}
let filelistElement!: HTMLDivElement;
@@ -335,7 +331,7 @@ export default function FileBrowser(props: FileBrowserProps) {
<Show when={!props.isOffline()}>
<button
class="refresh-button"
title="Refresh current directory, bypassing cache"
title="Refresh current directory listing and cover art, refreshing the cache"
onClick={reloadDirectory}
>
<RefreshSVG />
@@ -361,7 +357,6 @@ export default function FileBrowser(props: FileBrowserProps) {
<div class="file-list" ref={filelistElement}>
<For each={fileStore}>
{({ fullPath, displayName, file }) => {
console.log(fullPath);
const isDirectory = !("metadata" in file);
return (
<>
@@ -404,9 +399,10 @@ export default function FileBrowser(props: FileBrowserProps) {
</div>
<Show when={!props.options.hideCover && (cover.loading || cover())}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
</Suspense>
<Show when={cover.loading}>
<LoadingScreen />
</Show>
<img src={cover()} alt="" />
</div>
</Show>
<Show when={loadingActions() > 0}>
Mwebclient/src/components/OptionsModal.tsx
@@ -144,6 +144,15 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Match>
<Match when={optionsPage() === 2}>
<div>
Mwebclient/src/components/Playlist.tsx
@@ -9,7 +9,6 @@ import {
Show,
on,
batch,
Suspense,
createResource,
} from "solid-js";
import "./Playlist.css";
@@ -43,7 +42,7 @@ export default function Playlist(props: PlaylistProps) {
async (args, info) => {
if (info.value) URL.revokeObjectURL(info.value as string);
if (!args.filePath) return undefined;
return await getCover(args.filePath, args.offline);
return await getCover(args.filePath, props.options, false, args.offline);
},
{ initialValue: undefined },
);
@@ -229,9 +228,10 @@ export default function Playlist(props: PlaylistProps) {
</DraggableList>
<Show when={!props.options.hidePlaylistCover && (cover.loading || cover())}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
</Suspense>
<Show when={cover.loading}>
<LoadingScreen />
</Show>
<img src={cover()} alt="" />
</div>
</Show>
</div>
Mwebclient/src/offline.ts
@@ -101,6 +101,10 @@ export async function loadDataFromDB() {
setDownloadStatusMap(file.path, "Synced");
updateFileTree(file.path, { metadata: file.metadata }, true);
}
const covers = await db.covers.toArray();
for (const cover of covers) {
coverCache.set(cover.path, { data: cover.data });
}
} catch (e) {
toast("Loading offline files failed", "error", e);
console.error(e);
@@ -193,16 +197,30 @@ function deleteInOfflineTree(path: string): void {
if (!node) return;
node.files.delete(fileName);
if (node.files.size === 0) deleteInOfflineTree(basePath);
if (node.files.size === 0) {
db.covers.delete(basePath); //remove stored cover for this dir if there are not files left
deleteInOfflineTree(basePath);
}
}
export const clearDirectoryCache = (path: string) => {
export const clearDirectoryCache = async (path: string) => {
const node = getDirInTree(path, false, false);
if (!node) return;
// Save existing children before clearing
const preservedChildren = new Map(node.files);
node.files.clear();
node.status = "Unknown";
};
// restore any preserved subtrees where the parent directory still exist
const newListing = await listFiles(path, false, false);
for (const [name, child] of preservedChildren.entries()) {
if ("files" in child && newListing.has(name)) {
node.files.set(name, child);
}
}
};
async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileListingWithStatus | undefined> {
const response = await fetch(`/list/${encodePath(dir)}?recursive=${recursive}`).catch(() => {});
if (response?.status === 404) {
@@ -286,33 +304,46 @@ export async function listFiles(dir: string, offline: boolean, recursive: boolea
return recursive ? combined : pruneChildren(combined);
}
const coverCache = new Map<string, { data: Blob | null }>();
//get cover art object-url for a file or directory with cache-first strategy
export async function getCover(dir: string, offline: boolean) {
export async function getCover(
dir: string,
options: AppOptions,
saveToDb: boolean,
offline: boolean,
forceRefresh = false,
) {
try {
const cached = await db.covers.get(dir);
if (cached !== undefined) {
return cached.data === null ? undefined : URL.createObjectURL(new Blob([cached.data]));
const cached = coverCache.get(dir);
if (!forceRefresh && cached !== undefined) {
if (saveToDb) db.covers.add({ path: dir, data: cached.data }).catch(() => {});
return cached.data === null ? undefined : URL.createObjectURL(cached.data);
}
if (offline) return undefined;
const response = await fetch(`/cover/${encodePath(dir)}`, { method: "GET" }).catch(() => {});
const response = await fetch(`/cover/${encodePath(dir)}${options.transcodeCovers ? "?transcode=true" : ""}`, {
method: "GET",
}).catch(() => {});
if (!response || response.status === 404) {
await db.covers.put({ path: dir, data: null });
coverCache.set(dir, { data: null });
if (saveToDb) db.covers.add({ path: dir, data: null }).catch(() => {});
//if cover already exists in database, update it
else if (forceRefresh && db.covers.get(dir) !== undefined) db.covers.put({ path: dir, data: null });
return undefined;
}
const blob = await response.blob();
await db.covers.put({ path: dir, data: blob });
coverCache.set(dir, { data: blob });
if (saveToDb) db.covers.add({ path: dir, data: blob }).catch(() => {});
//if cover already exists in database, update it
else if (forceRefresh && db.covers.get(dir) !== undefined) db.covers.put({ path: dir, data: blob });
return URL.createObjectURL(blob);
} catch {
return undefined;
}
}
export async function clearCoverCache(path: string) {
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) {
@@ -475,6 +506,14 @@ async function downloadFile(
updateFileTree(path, item, true);
stopProgress(path, "Synced");
fetchFiles.refetch();
//iterate through all parent directories and download their covers to database
const parts = path.split("/");
while (true) {
getCover(parts.join("/"), options, true, false).then((cover) => URL.revokeObjectURL(cover || ""));
parts.pop();
if (parts.length === 0) break;
}
} finally {
downloadSemaphore.release();
}
@@ -506,7 +545,6 @@ export async function download(
} else {
updateRecursiveStatus(fullPath, 1, true);
try {
getCover(fullPath, false).then((cover) => URL.revokeObjectURL(cover || ""));
//start downloads for all children to allow them to run in parallel, then wait for the results
const promises: Promise<unknown>[] = [];
const listing = await listFiles(fullPath, false, false);
Mwebclient/src/types.ts
@@ -33,6 +33,7 @@ export interface AppOptions {
format: MediaFormat;
maxTranscodes: number;
maxDownloads: number;
transcodeCovers: boolean;
hideVolume: boolean;
hideCover: boolean;
@@ -41,7 +42,7 @@ export interface AppOptions {
playlistDisplay: FileDisplay;
controlsDisplay: FileDisplay;
fontResolution: number;
rememberDir: boolean;
duplicateWarning: boolean;
}