space to toggle play/pause, format selection in settings, server side tar downloads, less errors from replacing songs quickly
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
RUN apk add catatonit ffmpeg tar
WORKDIR /app
COPY --from=compile /app/server/dist .
Mserver/package.json
@@ -5,7 +5,6 @@
"scripts": {
"clean": "rm -rf dist node_modules ../webclient/dist ../webclient/node_modules",
"postinstall": "bun install --cwd ../webclient",
//TODO: use native bun bundler instead of vite once supported (waiting for a solidjs plugin)
"build-watch:frontend": "cd ../webclient && bun run build -c vite.watch.config.ts --watch --emptyOutDir --outDir ../server/dist",
"start": "bun run --watch src/index.ts --serve dist",
"dev": "concurrently \"bun run build-watch:frontend\" \"sleep 1 && bun run start\"",
@@ -14,12 +13,14 @@
},
"dependencies": {
"@elysiajs/static": "^1.2.0",
"command-exists": "^1.2.9",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1"
},
"devDependencies": {
"@types/command-exists": "^1.2.3",
"@types/range-parser": "^1.2.7",
"bun-types": "latest",
"concurrently": "^9.1.2"
Mserver/src/index.ts
@@ -11,11 +11,13 @@ import {
matchesType,
needsTranscoding,
convertWithFFmpeg,
packWithTar,
} from "./utils";
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { createReadStream } from "node:fs";
import { MediaFormat } from "music-server-shared/types";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
@@ -81,32 +83,36 @@ const app = new Elysia()
set.status = "Partial Content";
set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
set.headers["Content-Length"] = chunkSize.toString();
set.headers["Content-Length"] = chunkSize; //currently broken in bun/elysiajs, not present in final response
return fileStream;
})
.get("/transcode/*", async ({ request, query, set, params }) => {
const filePath = path.join(musicRoot, decodeURI(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
return fileScan?.error || "Not a file";
}
if (!matchesType(fileScan.mimeType, allowedTypes)) {
set.status = "Forbidden";
return "Forbidden file type";
}
const bitrate = Number.parseInt(query.bitrate) || 96000;
if (!matchesType(fileScan.mimeType, mediaTypes) || !needsTranscoding(await probeFile(filePath), bitrate)) {
set.status = "Temporary Redirect";
set.headers.Location = `/download/${params["*"]}`;
return "No transcoding necessary or probe failed, redirecting to normal endpoint";
}
.get(
"/transcode/*",
async ({ request, query, set, params }) => {
const filePath = path.join(musicRoot, decodeURI(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
return fileScan?.error || "Not a file";
}
if (!matchesType(fileScan.mimeType, allowedTypes)) {
set.status = "Forbidden";
return "Forbidden file type";
}
const bitrate = query.bitrate || 96000;
if (!matchesType(fileScan.mimeType, mediaTypes) || !needsTranscoding(await probeFile(filePath), bitrate)) {
set.status = "Temporary Redirect";
set.headers.Location = `/download/${params["*"]}`;
return "No transcoding necessary or probe failed, redirecting to normal endpoint";
}
const cmd = convertWithFFmpeg(filePath, bitrate);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = "audio/opus";
return new Response(cmd.stdout);
})
const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
return new Response(cmd.stdout);
},
{ query: t.Optional(t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat) })) },
)
.get(
"/list/*",
async ({ params, set, query }) => {
@@ -135,6 +141,19 @@ const app = new Elysia()
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
})
.post(
"/download-playlist",
async ({ set, body: { files } }) => {
console.log(files);
set.status = "OK";
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
//needed to support stream download with complex body in request
return new Response(packWithTar(JSON.parse(decodeURIComponent(atob(files))) as string[]).stdout);
},
{ body: t.Object({ files: t.String({ minLength: 1 }) }) },
)
.listen(3000);
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
Mserver/src/utils.ts
@@ -1,6 +1,6 @@
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import type { Directory, FileListing } from "music-server-shared/types";
import { MediaFormat, type Directory, type FileListing } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
@@ -184,17 +184,83 @@ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<strin
}
}
export function convertWithFFmpeg(filePath: string, bitrate: number) {
const cmd = Bun.spawn(["ffmpeg", "-i", filePath, "-f", "opus", "-b:a", bitrate.toString(), "-"], {
export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat) {
let codec = undefined;
let container = undefined;
let mimeType = undefined;
switch (format) {
case MediaFormat["opus/ogg"]: {
codec = "libopus";
container = "ogg";
mimeType = 'audio/ogg; codecs="opus"';
break;
}
case MediaFormat["opus/webm"]: {
codec = "libopus";
container = "webm";
mimeType = 'audio/webm; codecs="opus"';
break;
}
case MediaFormat.mp3: {
codec = "libmp3lame";
container = "mp3";
mimeType = "audio/mpeg";
break;
}
case MediaFormat.vorbis: {
codec = "libvorbis";
container = "ogg";
mimeType = 'audio/ogg; codecs="vorbis"';
break;
}
default: {
throw new Error(`Unsupported format: ${format}`);
}
}
const cmd = Bun.spawn(
["ffmpeg", "-i", filePath, "-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"],
{
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("ffmpeg failed with status code", exitCode);
console.error(await streamToString(subprocess.stderr as ReadableStream));
}
},
},
);
return { cmd, mimeType };
}
export function packWithTar(files: string[]) {
console.log(files);
// Ensure all files are within musicRoot
const absoluteFiles = files.map((f) => path.join(musicRoot, decodeURI(f)));
for (const f of absoluteFiles) {
if (!isBelow(musicRoot, f)) {
throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
}
}
const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
const tarArgs = [
"--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
"-C",
musicRoot,
"-c", // create archive
...relFiles,
];
return Bun.spawn(["tar", ...tarArgs], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
if (exitCode !== 0 && exitCode !== null) {
console.error("ffmpeg failed with status code", exitCode);
console.error("tar failed with status code", exitCode);
console.error(await streamToString(subprocess.stderr as ReadableStream));
}
},
});
return cmd;
}
Mwebclient/src/App.tsx
@@ -8,9 +8,10 @@ import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } fr
import { LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getSrc } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
//TODO: playlist management
//TODO: implement playlist item download server side
//TODO: codec choice as enum
//TODO: remember scroll position for parent directories
//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
//TODO: better touch controls?
@@ -36,12 +37,14 @@ const App: Component = () => {
buffered: 0,
});
const [showOptions, setShowOptions] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [options, setOptions] = createSignal<AppOptions>({
bitrate: 128000,
enableTranscoding: true,
maxTranscodes: 4,
maxDownloads: 1,
rememberDir: false,
format: MediaFormat["opus/ogg"],
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
@@ -75,15 +78,11 @@ const App: Component = () => {
let audioElement!: HTMLAudioElement;
let playRequested = false; //solves errors about aborting playback
// Play a track from the playlist
async function playTrack(track: PlaylistItem) {
setPlayerState({
...playerState(),
currentTrack: track,
isPlaying: true,
currentTime: 0,
});
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
@@ -92,14 +91,24 @@ const App: Component = () => {
audioElement.removeAttribute("src");
audioElement.srcObject = null;
audioElement.src = await getSrc(track.path, options());
audioElement.play().catch((e) => {
setPlayerState({
...playerState(),
currentTrack: track,
isPlaying: true,
currentTime: 0,
});
try {
await audioElement.play();
} catch (e) {
setPlayerState({
...playerState(),
isPlaying: false,
currentTime: 0,
});
return toast("Playing track failed", "error", e);
});
toast("Playing track failed", "error", e);
} finally {
playRequested = false;
}
}
// Play next track
@@ -140,6 +149,7 @@ const App: Component = () => {
// Toggle play/pause
function togglePlay() {
if (playerState().currentTrack === null) return;
const newIsPlaying = !playerState().isPlaying;
setPlayerState({ ...playerState(), isPlaying: newIsPlaying });
@@ -219,6 +229,10 @@ const App: Component = () => {
window.addEventListener("hashchange", () => {
setCurrentDir(decodeURI(window.location.hash.slice(1)));
});
document.addEventListener("keydown", (e) => {
if (e.key === " ") togglePlay();
});
});
createEffect(() => {
@@ -238,7 +252,7 @@ const App: Component = () => {
>
{showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
</button>
<button title="Manage playlists">
<button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
<PlaylistSVG />
</button>
<button
@@ -301,6 +315,8 @@ const App: Component = () => {
options={options}
setOptions={setOptions}
/>
<PlaylistManager showPlaylistManager={showPlaylistManager} setShowPlaylistManager={setShowPlaylistManager} />
</div>
);
};
Mwebclient/src/components/OptionsModal.css
@@ -1,31 +1,4 @@
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
width: 400px;
max-width: 90%;
padding: 0;
::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
}
.option-content {
display: flex;
gap: 0.5rem;
flex-direction: column;
background-color: var(--color-a);
padding: 1.5rem;
.option-title {
font-size: 1.2rem;
margin-bottom: 1rem;
font-weight: bold;
}
& > div {
display: flex;
justify-content: space-between;
Mwebclient/src/components/OptionsModal.tsx
@@ -1,8 +1,10 @@
import { type Accessor, createEffect, createSignal, type Setter, on } from "solid-js";
import type { AppOptions } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { toast } from "../utils";
import { MediaFormat } from "music-server-shared/types";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -15,7 +17,6 @@ export default function OptionsModal(props: OptionsModalProps) {
const [newOptions, setNewOptions] = createSignal<AppOptions>(props.options());
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
let toastElement!: HTMLDivElement;
createEffect(
on(props.showOptions, () => {
@@ -49,9 +50,8 @@ export default function OptionsModal(props: OptionsModalProps) {
return (
<dialog ref={dialogElement} onClose={handleClose}>
<div ref={toastElement} />
<form class="option-content" ref={formElement}>
<div class="option-title">Player Options</div>
<div class="title">Player Options</div>
<div>
<label for="remember-dir-checkbox">Remember last directory:</label>
<input
@@ -92,6 +92,25 @@ export default function OptionsModal(props: OptionsModalProps) {
<option value="320000">320 kbps</option>
</select>
</div>
<div>
<label for="codec-select">Format:</label>
<select
id="codec-select"
value={newOptions()?.format}
onChange={(e) =>
setNewOptions({
...newOptions(),
format: e.target.value as MediaFormat,
})
}
disabled={!newOptions()?.enableTranscoding}
>
<option>{MediaFormat["opus/ogg"]}</option>
<option>{MediaFormat["opus/webm"]}</option>
<option>{MediaFormat.mp3}</option>
<option>{MediaFormat.vorbis}</option>
</select>
</div>
<div>
<label for="max-downloads-input">Max concurrent downloads:</label>
<input
Mwebclient/src/components/Playlist.tsx
@@ -1,9 +1,8 @@
import { type Accessor, For, type Setter, createSignal, onMount, onCleanup, createEffect } from "solid-js";
import "./Playlist.css";
import type { PlayerState, PlaylistItem } from "../types";
import { formatTime, joinPath, toast } from "../utils";
import { formatTime, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
import { createTar } from "../tar";
import Toastify from "toastify-js";
interface PlaylistProps {
@@ -153,42 +152,46 @@ export default function Playlist(props: PlaylistProps) {
document.body.removeChild(a);
}
async function browserDownloadAll() {
const playlist = props.playlist();
let progressToast = undefined;
try {
const playlist = props.playlist();
const containerElement = document.createElement("div");
containerElement.style = "display:flex;flex-direction:column;";
containerElement.textContent = "Download playlist...";
const progressElement = document.createElement("progress");
progressElement.value = 0;
progressElement.max = playlist.length;
containerElement.appendChild(progressElement);
const containerElement = document.createElement("div");
containerElement.style = "display:flex;flex-direction:column;";
containerElement.textContent = "Download playlist...";
const progressElement = document.createElement("progress");
progressElement.value = 0;
progressElement.max = playlist.length;
containerElement.appendChild(progressElement);
const progressToast = Toastify({
node: containerElement,
duration: -1,
close: false,
});
progressToast = Toastify({
node: containerElement,
duration: -1,
close: false,
});
progressToast.showToast();
try {
const contentMap = new Map<string, Uint8Array>();
for (const item of playlist) {
const response = await fetch(`/download/${item.path}`);
if (!response.ok) throw new Error(`Download of ${item.name} was not successful`);
contentMap.set(item.name, new Uint8Array(await response.arrayBuffer()));
progressElement.value += 1;
}
const tarFile = createTar(contentMap);
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([tarFile]));
a.download = "playlist.tar";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
progressToast.showToast();
const filePaths = playlist.map((item) => {
return item.path;
});
const form = document.createElement("form");
form.method = "POST";
form.action = "/download-playlist";
const input = document.createElement("input");
input.type = "hidden";
input.name = "files";
//needed to support stream download with complex body in request
input.value = btoa(encodeURIComponent(JSON.stringify(filePaths)));
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
} catch (e) {
toast("Playlist items download failed", "error", e);
} finally {
progressToast.hideToast();
progressToast?.hideToast();
}
}
Mwebclient/src/offline.ts
@@ -177,7 +177,7 @@ export async function getSrc(path: string, options: AppOptions) {
if (dbResult) return URL.createObjectURL(new Blob([dbResult.data]));
if (options.enableTranscoding) {
return `${joinPath("/transcode", path)}?bitrate=${options.bitrate}`;
return `${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`;
}
return joinPath("/download", path);
}
Mwebclient/src/style.css
@@ -29,7 +29,8 @@ select {
}
button,
select {
select,
option {
display: flex;
place-items: center;
padding: 0.25rem 0.5rem;
@@ -103,3 +104,9 @@ html {
.spacer {
flex-grow: 1;
}
.title {
font-size: 1.2rem;
margin-bottom: 1rem;
font-weight: bold;
}
Dwebclient/src/tar.ts-160
@@ -1,160 +0,0 @@
function pad(value: string, length: number, padWith = "\0"): string {
if (value.length > length) return value.slice(0, length);
return value + padWith.repeat(length - value.length);
}
function truncateFilename(filename: string, maxBytes = 100): string {
const encoder = new TextEncoder();
// Identify extension (last '.' not at position 0)
const dotIdx = filename.lastIndexOf(".");
let base: string;
let ext: string;
if (dotIdx > 0 && dotIdx < filename.length - 1) {
base = filename.slice(0, dotIdx);
ext = filename.slice(dotIdx); // includes dot
} else {
base = filename;
ext = "";
}
// Whole filename fits? Done.
if (encoder.encode(filename).length <= maxBytes) {
return filename;
}
const ellipsis = "...";
const tail = ellipsis + ext;
const tailBytes = encoder.encode(tail).length;
// Figure out how many bytes are left for the base
const baseMaxBytes = maxBytes - tailBytes;
if (baseMaxBytes <= 0) {
// Only enough space for truncated tail
let res = "";
let count = 0;
for (const ch of tail) {
const chBytes = encoder.encode(ch).length;
if (count + chBytes > maxBytes) break;
res += ch;
count += chBytes;
}
return res;
}
// Take base from left, as much as fits in baseMaxBytes
let outBase = "";
let totalBytes = 0;
for (const ch of Array.from(base)) {
const chBytes = encoder.encode(ch).length;
if (totalBytes + chBytes > baseMaxBytes) {
break;
}
outBase += ch;
totalBytes += chBytes;
}
return outBase + ellipsis + ext;
}
function octalString(n: number, length: number): string {
// Reserve 1 for NUL (tar standard)
const fieldLen = length - 1;
let s = n.toString(8);
if (s.length > fieldLen) s = "0".repeat(fieldLen);
s = s.padStart(fieldLen, "0");
return `${s}\0`;
}
export function createTar(files: Map<string, Uint8Array>): Uint8Array {
const blocks: Uint8Array[] = [];
files.forEach((content, filename) => {
const truncatedName = truncateFilename(filename);
// --- HEADER ---
const header = new Uint8Array(512);
let idx = 0;
// name (100)
const nameBytes = new TextEncoder().encode(pad(truncatedName, 100));
header.set(nameBytes, idx);
idx += 100;
// mode (8)
header.set(new TextEncoder().encode(octalString(0o644, 8)), idx);
idx += 8;
// uid (8)
header.set(new TextEncoder().encode(octalString(0, 8)), idx);
idx += 8;
// gid (8)
header.set(new TextEncoder().encode(octalString(0, 8)), idx);
idx += 8;
// size (12)
header.set(new TextEncoder().encode(octalString(content.length, 12)), idx);
idx += 12;
// mtime (12)
header.set(new TextEncoder().encode(octalString(Math.floor(Date.now() / 1000), 12)), idx);
idx += 12;
// chksum (8) - space-filled for now
header.set(new TextEncoder().encode(" "), idx);
idx += 8;
// typeflag (1) - '0' for regular file
header[idx++] = "0".charCodeAt(0);
// linkname (100)
idx += 100;
// magic (6)
header.set(new TextEncoder().encode("ustar\0"), idx);
idx += 6;
// version (2)
header.set(new TextEncoder().encode("00"), idx);
idx += 2;
// uname (32)
idx += 32;
// gname (32)
idx += 32;
// devmajor (8)
idx += 8;
// devminor (8)
idx += 8;
// prefix (155)
idx += 155;
// padding (12)
idx += 12;
// Compute checksum: set chksum field to spaces, then sum all bytes of header
let chksum = 0;
for (let i = 0; i < 512; i++) {
// chksum is at 148..155, treated as spaces (0x20)
if (i >= 148 && i < 156) {
chksum += 0x20;
} else {
chksum += header[i];
}
}
const chksumStr = pad(octalString(chksum, 7), 8, " "); // 6 digits, null, space
header.set(new TextEncoder().encode(chksumStr), 148);
blocks.push(header);
// --- CONTENT ---
blocks.push(content);
// pad to 512 bytes
if (content.length % 512 !== 0) {
blocks.push(new Uint8Array(512 - (content.length % 512)));
}
});
// Tar ends with two 512-byte zero blocks
blocks.push(new Uint8Array(512));
blocks.push(new Uint8Array(512));
// Flatten
const total = blocks.reduce((a, b) => a + b.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const b of blocks) {
out.set(b, offset);
offset += b.length;
}
return out;
}
Mwebclient/src/types.ts
@@ -1,4 +1,4 @@
import type { AudioFile } from "music-server-shared/types";
import type { AudioFile, MediaFormat } from "music-server-shared/types";
export interface PlaylistItem extends AudioFile {
id: string;
@@ -15,8 +15,9 @@ export interface PlayerState {
export interface AppOptions {
bitrate: number;
format: MediaFormat;
enableTranscoding: boolean;
maxTranscodes: number;
maxDownloads: number;
rememberDir: boolean;
}
}