missed files for last commit, better range support, add buildhash, smaller line-height
Mserver/src/index.ts
@@ -1,5 +1,5 @@
import staticPlugin from "@elysiajs/static";
import { Elysia, ElysiaFile, t } from "elysia";
import { Elysia, ElysiaFile, type InferHandler, t } from "elysia";
import path from "node:path";
import { parseArgs } from "node:util";
import { musicRoot, fileTypeCache, ServerError, allowedTypes, probeCache, mediaTypes } from "./shared";
@@ -16,7 +16,7 @@ import {
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { createReadStream } from "node:fs";
import { createReadStream, ReadStream } from "node:fs";
import { MediaFormat } from "music-server-shared/types";
//TODO: transcoding cache?
@@ -39,53 +39,67 @@ if (!values.serve) {
process.exit(1);
}
const app = new Elysia()
.use(staticPlugin({ assets: values.serve, prefix: "/" }))
.post("/reset-cache", () => {
fileTypeCache.clear();
probeCache.clear();
})
.get("/download/*", async ({ headers, params, set }) => {
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 setup = new Elysia();
set.status = "OK";
if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
set.headers["Content-Type"] = fileScan.mimeType;
const paramType = t.ArrayString();
type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
if (!headers.range) {
//elysias automatically sets Accept-Ranges here, adding it manually causes it to duplicate
set.status = "OK";
return Bun.file(filePath);
}
const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
if (request.method !== "GET" && request.method !== "HEAD") {
set.status = "Not Found";
return "NOT_FOUND";
}
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";
}
set.headers["Accept-Ranges"] = "bytes";
// Range support, elysia advertises ranges, but doesn't actually fulfill them, which breaks e.g. safari
const statResult = await stat(filePath);
const fileSize = statResult.size;
const rangeData = parseRange(fileSize, headers.range, { combine: true });
if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
set.status = "Range Not Satisfiable";
return "Range Not Satisfiable";
}
set.status = "OK";
if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
set.headers["content-type"] = fileScan.mimeType;
const statResult = await stat(filePath);
const fileSize = statResult.size;
if (request.method === "HEAD") {
set.headers["accept-ranges"] = "bytes";
set.headers["content-length"] = fileSize;
return;
}
if (!headers.range) {
set.status = "OK";
return Bun.file(filePath);
}
const { start, end } = rangeData[0];
const chunkSize = end - start + 1;
const fileStream = createReadStream(filePath, { start, end });
const rangeData = parseRange(fileSize, headers.range, { combine: true });
if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
set.status = "Range Not Satisfiable";
return "Range Not Satisfiable";
}
const { start, end } = rangeData[0];
set.status = "Partial Content";
set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
// set.headers["Content-Length"] = end-start+1; //currently broken in bun/elysiajs, not present in final response
//Bun.file().slice() should work, but currently only returns the chunk size as the total range, which confuses browsers
//using a stream here causes "Transfer-Encoding: chunked" to be set, but seems fine in practice
return createReadStream(filePath, { start, end });
};
set.status = "Partial Content";
set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
set.headers["Content-Length"] = chunkSize; //currently broken in bun/elysiajs, not present in final response
return fileStream;
const app = setup
.use(staticPlugin({ assets: values.serve, prefix: "/" }))
.post("/reset-cache", () => {
fileTypeCache.clear();
probeCache.clear();
})
.get("/download/*", downloadHandler)
.head("/download/*", downloadHandler)
.get(
"/transcode/*",
async ({ request, query, set, params }) => {
@@ -144,7 +158,6 @@ const app = new Elysia()
.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"`;
Mserver/src/utils.ts
@@ -121,7 +121,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Di
const probe = await probeFile(itemPath);
duration = probe.duration || 0;
} catch (probeError) {
console.error("ffprobe error:", probeError);
console.error("probing error:", probeError);
}
files.push({ name: file, duration: duration });
}
@@ -213,7 +213,6 @@ export function convertWithFFmpeg(filePath: string, bitrate: number, format: Med
}
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) {
Mwebclient/package.json
@@ -13,6 +13,7 @@
"@types/toastify-js": "^1.12.3"
},
"dependencies": {
"bun-types": "latest",
"lightningcss": "^1.29.3",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.5",
Mwebclient/src/App.tsx
@@ -10,8 +10,11 @@ import Playlist from "./components/Playlist";
import { getSrc } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
//TODO: Rework FileListing to be a Map, update offline structures as well, paths as string[]
//TODO: search
//TODO: version display
//TODO: remember scroll position for parent directories
//TODO: better touch controls?
//TODO: Add documentation to functions
@@ -19,9 +22,9 @@ import PlaylistManager from "./components/PlaylistManager";
//TODO: button to show metadata
//TODO: show cover of playing item
//TODO: delete confirmation?
//TODO: nicer errors when song can't be played because server is down
//TODO: setting export/import
//TODO: video support
//TODO: virtual rendering for playlist
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
@@ -95,7 +98,6 @@ const App: Component = () => {
playRequested = true;
try {
const src = await getSrc(track.path, options(), isOffline());
console.log(src);
if (!src) return;
if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
@@ -272,7 +274,10 @@ const App: Component = () => {
return (
<div class="app-container">
<div class="header">
<div class="title">Pico Pixel Player</div>
<div>
<div class="title">Pico Pixel Player</div>
<sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
</div>
<div class="spacer" />
<button
title="Toggle File Browser"
Mwebclient/src/components/DownloadButton.tsx
@@ -15,7 +15,7 @@ interface DownloadButtonProps {
options: Accessor<AppOptions>;
}
function formatBytes(bytes: number): string {
function formatProgress(bytes: number): string {
if (bytes === 0) return "Queued...";
const k = 1024;
@@ -40,7 +40,7 @@ export function DownloadButton(props: DownloadButtonProps) {
class="download-item"
onClick={(event) => {
event.stopPropagation();
if (!props.isOffline()) download(props.item, props.currentDir(), props.options(), props.fetchFiles);
if (!props.isOffline()) download(props.item, props.currentDir(), props.currentDir, props.options(), props.fetchFiles);
else deleteFile(props.item, props.currentDir(), props.fetchFiles);
}}
>
@@ -77,7 +77,7 @@ export function DownloadButton(props: DownloadButtonProps) {
);
}
if ("bytes" in progress) return <span>{formatBytes(progress.bytes)}</span>;
if ("bytes" in progress) return <span>{formatProgress(progress.bytes)}</span>;
console.log(progress);
console.error("Unexpected", progress);
})()}
Mwebclient/src/components/DraggableList.tsx
@@ -55,6 +55,8 @@ export function DraggableList<T>(props: DraggableListProps<T>) {
scheduleUpdate(lastClientY);
}
//@ts-ignore
scrollInterval = setInterval(step, 10);
}
@@ -107,7 +109,6 @@ export function DraggableList<T>(props: DraggableListProps<T>) {
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(dragElement, dragElement.offsetWidth / 20, dragElement.offsetHeight / 2);
console.log(e.dataTransfer);
}
function handleTouchStart(item: T, index: number, e: TouchEvent) {
Mwebclient/src/components/FileBrowser.css
@@ -111,11 +111,20 @@
@media (max-width: 600px) {
.file-browser {
z-index: 1;
position: absolute;
width: 80%;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.8);
}
.file-browser-overlay {
z-index: 1;
position: absolute;
left: 80%;
height: 100%;
width: 20%;
}
.hidden {
display: none;
}
Mwebclient/src/components/FileBrowser.tsx
@@ -147,110 +147,120 @@ export default function FileBrowser(props: FileBrowserProps) {
});
return (
<div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
<div class="breadcrumb">
<For each={props.breadcrumbs()}>
{(crumb, index) => (
<>
<span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
{index() < props.breadcrumbs().length - 1 && "/"}
</>
)}
</For>
</div>
<>
<div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
<div class="breadcrumb">
<For each={props.breadcrumbs()}>
{(crumb, index) => (
<>
<span onClick={() => handleBreadcrumbClick(index())}>{decodeURIComponent(crumb)}</span>
{index() < props.breadcrumbs().length - 1 && "/"}
</>
)}
</For>
</div>
<div class="browser-actions">
<div>
<button title="Replace playlist with all files in current directory" onClick={() => playFiles(false, false)}>
Play Directory
</button>
<div class="browser-actions">
<div>
<button
title="Replace playlist with all files in current directory"
onClick={() => playFiles(false, false)}
>
Play Directory
</button>
<button
title="Replace playlist with all files in current directory and all subdirectories"
onClick={() => playFiles(true, false)}
>
Play All
</button>
<button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
Queue Directory
</button>
<button
title="Add all files in current directory and all subdirectories to playlist"
onClick={() => playFiles(true, true)}
>
Queue All
</button>
</div>
<div class="spacer" />
<Show when={!props.isOffline()}>
<button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
<RefreshSVG />
</button>
</Show>
<button
title="Replace playlist with all files in current directory and all subdirectories"
onClick={() => playFiles(true, false)}
>
Play All
</button>
<button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
Queue Directory
</button>
<button
title="Add all files in current directory and all subdirectories to playlist"
onClick={() => playFiles(true, true)}
>
Queue All
</button>
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
textContent={props.isOffline() ? "Go online" : "Go offline"}
onclick={() => props.setOffline(!props.isOffline())}
/>
</div>
<div class="spacer" />
<Show when={!props.isOffline()}>
<button class="refresh-button" title="Refresh current directory, bypassing cache" onClick={reloadDirectory}>
<RefreshSVG />
</button>
</Show>
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
class="offline-button"
style={{ background: props.isOffline() ? "lightgreen" : "indianred" }}
textContent={props.isOffline() ? "Go online" : "Go offline"}
onclick={() => props.setOffline(!props.isOffline())}
/>
</div>
<div class="file-list-wrapper">
<Show when={files.loading}>
<LoadingScreen />
</Show>
<div class="file-list-wrapper">
<Show when={files.loading}>
<LoadingScreen />
</Show>
<div class="file-list">
<For each={files.latest}>
{(item) => {
const isDirectory = "files" in item;
const fullPath = joinPath(props.currentDir(), item.name);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => (isDirectory && props.setCurrentDir(fullPath)) || playFile(item as AudioFile)}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{item.name}</span>
<div class="spacer" />
<DownloadButton
item={item}
fetchFiles={fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
/>
<button
title="Add to Playlist"
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(true, true, fullPath);
} else {
addToPlaylist(item as AudioFile, props.currentDir());
}
}}
<div class="file-list">
<For each={files.latest}>
{(item) => {
const isDirectory = "files" in item;
const fullPath = joinPath(props.currentDir(), item.name);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => (isDirectory && props.setCurrentDir(fullPath)) || playFile(item as AudioFile)}
>
Add
</button>
</div>
</>
);
}}
</For>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{item.name}</span>
<div class="spacer" />
<DownloadButton
item={item}
fetchFiles={fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
/>
<button
title="Add to Playlist"
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(true, true, fullPath);
} else {
addToPlaylist(item as AudioFile, props.currentDir());
}
}}
>
Add
</button>
</div>
</>
);
}}
</For>
</div>
</div>
<Show when={cover.loading || cover()}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
</Suspense>
</div>
</Show>
<Show when={loadingActions() > 0}>
<LoadingScreen text="Loading files from subdirectories, this might take a while.." />
</Show>
</div>
<Show when={cover.loading || cover()}>
<div class="cover">
<Suspense fallback={<LoadingScreen />}>
<img src={cover()} alt="" />
</Suspense>
</div>
</Show>
<Show when={loadingActions() > 0}>
<LoadingScreen text="Loading files from subdirectories, this might take a while.." />
</Show>
</div>
<div
class="file-browser-overlay"
style={{ display: props.showFileBrowser() ? "block" : "none" }}
onClick={() => props.setShowFileBrowser(false)}
/>
</>
);
}
Mwebclient/src/components/Playlist.css
@@ -19,6 +19,8 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
contain: strict;
content-visibility: auto;
& > .playlist-item {
padding: 0.5rem;
Mwebclient/src/components/Playlist.tsx
@@ -1,4 +1,4 @@
import type { Accessor, Setter } from "solid-js";
import { createSelector, type Accessor, type Setter } from "solid-js";
import "./Playlist.css";
import type { PlayerState, PlaylistItem } from "../types";
import { formatTime, toast } from "../utils";
@@ -104,7 +104,7 @@ export default function Playlist(props: PlaylistProps) {
<SaveSVG />
</button>
<button
title="Download all files in the playlist in their original format as a tar file. WARNING: Uses a lot of memory during downlad"
title="Download all files in the playlist in their original format as a tar file"
onClick={browserDownloadAll}
>
<DownloadSVG />
@@ -116,13 +116,16 @@ export default function Playlist(props: PlaylistProps) {
<DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist}>
{(item, _, { dragHandlers, isDragging, dropPosition }) => {
const isActive = () => props.playerState().currentTrack?.id === item.id;
const isActive = createSelector(
() => props.playerState().currentTrack?.id,
(itemId, currentId) => currentId === itemId,
);
return (
<div
classList={{
"playlist-item": true,
"draggable-item": true,
active: isActive(),
active: isActive(item.id),
"drop-target-top-border": dropPosition() === "before",
"drop-target-bottom-border": dropPosition() === "after",
}}
Awebclient/src/generate-build-hash.sh
@@ -0,0 +1,4 @@
#!/bin/bash
UUID=$(hexdump -vn16 -e'4/4 "%08X" 1 "\n"' /dev/urandom | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
echo "export const BUILD_HASH = '$UUID';" > src/build-info.ts
Mwebclient/src/offline.ts
@@ -1,7 +1,7 @@
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import type { AppOptions, Playlist, PlaylistItem } from "./types";
import { joinPath, toast } from "./utils";
import { createSignal, type ResourceActions, type Signal } from "solid-js";
import { type Accessor, createSignal, type ResourceActions, type Signal } from "solid-js";
import { AsyncSemaphore } from "./semaphore";
interface Progress {
@@ -231,7 +231,6 @@ export async function deleteFile(
fetchFiles: ResourceActions<FileListing>,
listing?: FileListing,
) {
console.log(item);
const fullPath = joinPath(basePath, item.name);
const signal = signalMap.get(fullPath);
if (!signal) return;
@@ -250,13 +249,13 @@ export async function deleteFile(
await db.delete(FileMetadataStore, fullPath);
signalMap.get(fullPath)?.[1](undefined);
fetchFiles.refetch();
console.log("here");
}
const downloadSemaphore = new AsyncSemaphore(1);
async function downloadFile(
path: string,
currentDir: Accessor<string>,
progressSignal: Signal<SignalMapEntry>,
options: AppOptions,
duration: number,
@@ -315,7 +314,16 @@ async function downloadFile(
} as StoredFileMetadata);
updateOfflineFileTree(path, duration);
setProgress("CachedFile");
fetchFiles.refetch();
const basePath = path.split("/").slice(0, -1).join("/");
const name = path.split("/").toReversed()[0];
if (currentDir() === basePath) {
fetchFiles.mutate((prev) => {
if (prev.findIndex((f) => f.name === name) !== -1) {
return prev;
}
return [...prev, { name, duration }].sort((a, b) => a.name.localeCompare(b.name));
});
}
} catch (e) {
toast(`Downloading ${path} failed`, "error", e);
setProgress(undefined);
@@ -325,6 +333,7 @@ async function downloadFile(
export async function download(
item: AudioFile | Directory,
basePath: string,
currentDir: Accessor<string>,
options: AppOptions,
fetchFiles: ResourceActions<FileListing>,
) {
@@ -336,14 +345,14 @@ export async function download(
}
const [, setProgress] = signal;
if ("duration" in item) {
await downloadFile(fullPath, signal, options, item.duration, fetchFiles);
await downloadFile(fullPath, currentDir, signal, options, item.duration, fetchFiles);
} else {
setProgress("DirectoryProgress");
await getCover(fullPath, false);
try {
const promises: Promise<unknown>[] = [];
for (const childItem of await listFiles(fullPath, false, false)) {
promises.push(download(childItem, fullPath, options, fetchFiles));
promises.push(download(childItem, fullPath, currentDir, options, fetchFiles));
}
await Promise.allSettled(promises);
} finally {
Mwebclient/src/style.css
@@ -17,6 +17,7 @@ button,
select {
font-family: ark-latin, sans-serif;
font-size: 16px;
line-height: 1.5rem;
}
:root {
@@ -44,6 +45,15 @@ option {
}
}
select:disabled {
background-color: var(--color-d);
color: oklch(0.50 0 0);
&:hover {
background-color: var(--color-d);
}
}
button {
cursor: pointer;
&:disabled {
Mwebclient/tsconfig.json
@@ -7,7 +7,7 @@
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"types": ["vite/client", "bun-types"],
"noEmit": true,
"isolatedModules": true,
Rwebclient/vite.common.config.ts← webclient/vite.manifest.config.ts
@@ -1,3 +1,4 @@
import { execSync } from "node:child_process";
import type { VitePWAOptions } from "vite-plugin-pwa";
export const pwaOptions: Partial<VitePWAOptions> = {
@@ -61,3 +62,17 @@ export const pwaOptions: Partial<VitePWAOptions> = {
display_override: ["standalone", "minimal-ui", "fullscreen"],
},
};
export const hashPlugin = {
name: "pre-build-step",
buildStart() {
console.log("Running pre-build step...");
try {
// Example: Run a shell command before the build starts
execSync('echo "Running pre-build script" && sh src/generate-build-hash.sh', { stdio: "inherit" });
} catch (error) {
console.error("Pre-build step failed:", error);
process.exit(1);
}
},
};
Mwebclient/vite.compile.config.ts
@@ -1,13 +1,13 @@
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import solidPlugin from "vite-plugin-solid";
import { pwaOptions } from "./vite.manifest.config";
import { hashPlugin, pwaOptions } from "./vite.common.config";
export default defineConfig({
css: {
transformer: "lightningcss",
},
plugins: [solidPlugin(), VitePWA(pwaOptions)],
plugins: [solidPlugin(), VitePWA(pwaOptions), hashPlugin],
build: {
cssMinify: "lightningcss",
},
Mwebclient/vite.watch.config.ts
@@ -1,10 +1,10 @@
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import solidPlugin from "vite-plugin-solid";
import { pwaOptions } from "./vite.manifest.config";
import { hashPlugin, pwaOptions } from "./vite.common.config";
export default defineConfig({
plugins: [solidPlugin(), VitePWA(pwaOptions)],
plugins: [solidPlugin(), VitePWA(pwaOptions), hashPlugin],
build: {
sourcemap: true,
@@ -12,6 +12,7 @@ export default defineConfig({
chokidar: {
usePolling: true,
interval: 1000,
ignored: ["**/build-info.ts"], // Ignore changes to this file
},
},
},