multi-user/multi-root

AuthorKonata <konata@posteo.jp>
Date
Commit98a1a66c9b982db68dff3a3f50bf99b9cd679316
Parent77ad6d6
10 files changed, 319 insertions(+), 144 deletions(-)
▾M.gitignore
@@ -2,5 +2,5 @@
node_modules
bun.lock
dist
.env
build-info.ts
config.json
▾MREADME.md
@@ -4,12 +4,13 @@ Pico Pixel Player is a lightweight, self-hostable media player with an [ElysiaJS
## Features
- **Lightweight**: Minimal dependencies, fast performance
- **Easy Setup**: Configure with just a `.env` file
- **Easy Setup**: One JSON config file for users and libraries
- **Responsive UI**: Works seamlessly on desktop and mobile
- **Offline Capable**: Progressive Web App (PWA) support and local caching of files
- **Transcoding**: Optionally transcode files at a selected bitrate and format to your client
- **Video playback**: Responsive inline video, theater/fullscreen modes, chapters, and text subtitles
- **Directory Listing**: Browse files in their original directory structure
- **Multiple Users and Libraries**: Several logins, each seeing only the libraries assigned to it
## Screenshot
![screenshot](webclient/public/assets/screenshot-wide.png)
@@ -20,12 +21,27 @@ Pico Pixel Player is a lightweight, self-hostable media player with an [ElysiaJS
git clone <repository-url>
cd pico-pixel-player
```
2. Write/adjust these settings in a file called `.env`:
2. Generate a password hash for each user. The password is read from stdin, so it never lands in
your shell history or in `ps`:
```sh
HOST_PORT=1234
MUSIC_ROOT=/path/to/your/music
echo -n 'your-password' | bun run --cwd server src/index.ts --hash-password
```
3. Start the service:
3. Write a `config.json` next to `compose.yaml`. The keys of `roots` are the names shown as
top-level folders in the UI; the values are the paths *inside the container*, so they must match
the mounts in `compose.yaml`:
```json
{
"roots": {
"music": "/mnt/music",
"videos": "/mnt/videos"
},
"users": [
{ "name": "alice", "passwordHash": "$argon2id$...", "roots": ["music", "videos"] },
{ "name": "bob", "passwordHash": "$argon2id$...", "roots": ["music"] }
]
}
```
4. Start the service:
```sh
docker-compose up # or podman-compose up
```
@@ -43,11 +59,16 @@ 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.
- `EXCLUDE_EXTENSION`: comma-separated list of file extensions to ignore completely, e.g. `txt,log,nfo`. If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
- `SCAN_CONCURRENCY`: maximum number of parallel `mediainfo` processes while scanning uncached folders. Defaults to `8`; invalid or non-positive values fall back to the default.
- `AUTH`: Same format as the string used for basic auth (username and password joined by a colon `:` and encoded to base64). If set, the server APIs return 401 unless the user is signed in with the given login details. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
- `CONFIG`: path to the JSON file holding the roots and users. Defaults to `./config.json`. The server refuses to start without a valid one. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
- Passwords are stored as argon2 hashes produced by `--hash-password`, which reads the password from
stdin. Auth tokens are kept in memory, so a server restart signs everyone out.
- Roots are virtual top-level folders: a path is `<root name>/<path inside that root>`, and requesting
a root a user does not have returns 403. Root directories must exist at
startup, and symlinks that leave their root are skipped when listing and refused when read.
## Non-Goals
- **Metadata-Based Views**: No support for filtering by genre, artist, etc. Use other projects for this
- **Multiple users**: Right now everything besides the actual file hosting is stored on the server. When syncing is implemented, it will only be single storage. Permission systems are also not planned. If distinct permissions/syncs are absolutely required, hosting multiple instances is an option
- **Server-side user state**: Playlists, synced files and options live in the browser, so they are per-device rather than per-account and do not follow a user to another machine. Finer-grained permissions (read-only, per-folder) are not planned.
## FAQ
- What do the "Streaming mode" options do?
▾Mcompose.yaml
@@ -3,8 +3,11 @@ services:
build:
context: .
ports:
- ${HOST_PORT}:3000
- "3000:3000"
environment:
- "MUSIC_ROOT=/mnt"
- "CONFIG=/app/config.json"
volumes:
- "${MUSIC_ROOT}:/mnt"
- "./config.json:/app/config.json:ro"
# one mount per root, matching the paths in config.json
# - "/host/path/to/music:/mnt/music"
# - "/host/path/to/videos:/mnt/videos"
▾Mserver/src/index.ts
@@ -1,12 +1,12 @@
import { realpath } from "node:fs/promises";
import path, { basename } from "node:path";
import staticPlugin from "@elysiajs/static";
import { randomUUIDv7 } from "bun";
import { type Context, Elysia, StatusMap, t } from "elysia";
import {
AudioCodec,
type IsVideoResponse,
type FileListingWithStatus,
MediaContainer,
type Metadata,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
@@ -15,17 +15,17 @@ import { convertSubtitleWithFFmpeg, convertWithFFmpeg } from "./ffmpeg";
import {
allowedTypes,
args,
authTokens,
authenticate,
deleteAuthToken,
fileTypeCache,
generatedPlaylistIds,
isValidAuthToken,
issueAuthToken,
mediaTypes,
musicRoot,
type PathInfo,
password,
probeCache,
ServerError,
username,
type User,
userForToken,
videoExtrasCache,
} from "./shared";
import {
@@ -48,32 +48,48 @@ import {
//increase timeout to not abort when listing huge folders
const setup = new Elysia({ serve: { idleTimeout: 255 } });
function resolveInRoot(encodedPath: string): string | ServerError {
const filePath = path.join(musicRoot, decodePath(encodedPath));
if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
return filePath;
interface Resolved {
filePath: string;
//the root the path belongs to, needed as the boundary for anything walking upwards
rootDir: string;
}
//for paths that arrive already decoded (playlist entries), where resolveInRoot's decodePath would
//double-decode - a legitimate "%" in a filename would throw in decodeURIComponent
function resolveRelativeInRoot(relPath: string): string | ServerError {
const filePath = path.join(musicRoot, relPath);
if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
return filePath;
//the first path segment is the virtual root name; the rest is relative to that root's directory
async function resolveRelativeInRoot(user: User, relPath: string): Promise<Resolved | ServerError> {
const [rootName, ...rest] = relPath.split("/");
const rootDir = user.rootDirs[rootName];
if (!rootDir) return new ServerError(StatusMap.Forbidden, "Unknown root");
const filePath = path.join(rootDir, ...rest);
if (!isBelow(rootDir, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the root");
//isBelow is lexical, so it cannot see a symlink inside the root that points out of it.
//rootDir is already a realpath (resolved at config load), so only the target needs resolving
const realPath = await realpath(filePath).catch(() => undefined);
if (realPath === undefined) return new ServerError(StatusMap["Not Found"], "File not found");
if (!isBelow(rootDir, realPath)) return new ServerError(StatusMap.Forbidden, "Path outside the root");
return { filePath: realPath, rootDir };
}
async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> {
const filePath = resolveInRoot(encodedPath);
if (filePath instanceof ServerError) return filePath;
const info = await getPathInfo(filePath);
function resolveInRoot(user: User, encodedPath: string): Promise<Resolved | ServerError> {
//playlist entries arrive already decoded and go through resolveRelativeInRoot instead, because
//decoding twice would throw in decodeURIComponent on a legitimate "%" in a filename
return resolveRelativeInRoot(user, decodePath(encodedPath));
}
async function resolveMediaFile(
user: User,
encodedPath: string,
): Promise<(Resolved & { info: PathInfo }) | ServerError> {
const resolved = await resolveInRoot(user, encodedPath);
if (resolved instanceof ServerError) return resolved;
const info = await getPathInfo(resolved.filePath);
//undefined means it is a directory rather than a file
if (!info || info instanceof ServerError)
return info ?? new ServerError(StatusMap["Not Found"], "Path is a directory, not a file");
if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type");
return { filePath, info };
return { ...resolved, info };
}
type FileHandlerContext = Context<{ params: { "*": string } }>;
type FileHandlerContext = Context<{ params: { "*": string } }> & { user: User };
//re-encoding above the source bitrate only costs bandwidth, it cannot add back detail. an unknown source
//bitrate leaves the request as it is - there is nothing to compare against
@@ -112,8 +128,8 @@ function consumeTranscodeCancellation(id: string | undefined): boolean {
return id !== undefined && cancelledTranscodes.delete(id);
}
const downloadHandler = async ({ params, set }: FileHandlerContext) => {
const resolved = await resolveMediaFile(params["*"]);
const downloadHandler = async ({ params, set, user }: FileHandlerContext) => {
const resolved = await resolveMediaFile(user, params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
@@ -152,16 +168,17 @@ const app = setup
})
.post(
"/login",
({ body, set }) => {
async ({ body, set }) => {
const separator = body.indexOf(":");
const givenUser = separator === -1 ? body : body.slice(0, separator);
const givenPassword = separator === -1 ? "" : body.slice(separator + 1);
if (givenUser === username && givenPassword === password) {
const user = await authenticate(givenUser, givenPassword);
if (user) {
set.status = 200;
const millisInYear = 365 * 24 * 60 * 60 * 1000;
const endDate = new Date(Date.now() + millisInYear);
const token = randomUUIDv7();
authTokens.set(token, endDate);
issueAuthToken(token, endDate, user);
set.headers["set-cookie"] =
`authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
return "Logged in successfully";
@@ -174,17 +191,15 @@ const app = setup
.get(
"/auth/status",
({ cookie: { authToken } }) => {
const authRequired = !!(username && password);
//when no AUTH is configured every route is open, so treat the user as logged in
const loggedIn = !authRequired || (!!authToken.value && isValidAuthToken(authToken.value));
return { authRequired, loggedIn };
//a configured user is now mandatory, so there is no open mode any more
return { authRequired: true, loggedIn: !!authToken.value && !!userForToken(authToken.value) };
},
{ cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
)
.post(
"/logout",
({ cookie: { authToken }, set }) => {
if (authToken.value) authTokens.delete(authToken.value); //invalidate the token server-side
if (authToken.value) deleteAuthToken(authToken.value); //invalidate the token server-side
//the cookie is HttpOnly, so only the server can clear it - expire it in the past
set.headers["set-cookie"] = `authToken=; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
return "Logged out";
@@ -195,7 +210,7 @@ const app = setup
{
cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
beforeHandle({ cookie: { authToken }, set }) {
if (username && password && (!authToken.value || !isValidAuthToken(authToken.value))) {
if (!authToken.value || !userForToken(authToken.value)) {
set.status = 401;
return "Unauthorized";
}
@@ -203,6 +218,8 @@ const app = setup
},
(guarded) =>
guarded
//runs after beforeHandle, so the token is already known to be valid
.resolve(({ cookie: { authToken } }) => ({ user: userForToken(authToken.value as string) as User }))
.post("/reset-cache", () => {
fileTypeCache.clear();
probeCache.clear();
@@ -227,8 +244,8 @@ const app = setup
.head("/download/*", downloadHandler)
.get(
"/transcode/*",
async ({ request, query, set, params }) => {
const resolved = await resolveMediaFile(params["*"]);
async ({ request, query, set, params, user }) => {
const resolved = await resolveMediaFile(user, params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
@@ -352,13 +369,32 @@ const app = setup
)
.get(
"/list/*",
async ({ params, set, query }) => {
const dirPath = resolveInRoot(params["*"]);
if (dirPath instanceof ServerError) {
set.status = dirPath.status;
return dirPath.error;
async ({ params, set, query, user }) => {
const recursive = query.recursive || false;
//the top level is virtual: it lists the user's roots rather than a directory
if (params["*"] === "") {
const listing: FileListingWithStatus = {};
for (const rootName of user.roots) {
if (!recursive) {
listing[rootName] = { files: {}, status: "Unknown" };
continue;
}
const rootListing = await listFiles(user.rootDirs[rootName], user.rootDirs[rootName], true);
if (rootListing instanceof ServerError) {
set.status = rootListing.status;
return rootListing.error;
}
listing[rootName] = { files: rootListing, status: "Scanned" };
}
set.status = "OK";
return listing;
}
const resolved = await resolveInRoot(user, params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
}
const fileList = await listFiles(dirPath, query.recursive || false);
const fileList = await listFiles(resolved.rootDir, resolved.filePath, recursive);
if (fileList instanceof ServerError) {
set.status = fileList.status;
return fileList.error;
@@ -368,17 +404,8 @@ const app = setup
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
.get("/isVideo/*", async ({ params, set }) => {
const dirPath = resolveInRoot(params["*"]);
if (dirPath instanceof ServerError) {
set.status = dirPath.status;
return dirPath.error;
}
const probeData = await probeFile(dirPath).catch(() => ({}) as Metadata);
return { isVideo: probeData.videoCodec !== undefined } as IsVideoResponse;
})
.get("/video-info/*", async ({ params, set }) => {
const resolved = await resolveMediaFile(params["*"]);
.get("/video-info/*", async ({ params, set, user }) => {
const resolved = await resolveMediaFile(user, params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
@@ -391,8 +418,8 @@ const app = setup
})
.get(
"/subtitles/*",
async ({ request, params, query, set }) => {
const resolved = await resolveMediaFile(params["*"]);
async ({ request, params, query, set, user }) => {
const resolved = await resolveMediaFile(user, params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
@@ -446,13 +473,13 @@ const app = setup
)
.get(
"/cover/*",
async ({ params, set, query }) => {
const dirPath = resolveInRoot(params["*"]);
if (dirPath instanceof ServerError) {
set.status = dirPath.status;
return dirPath.error;
async ({ params, set, query, user }) => {
const resolved = await resolveInRoot(user, params["*"]);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
}
const result = await findCover(dirPath);
const result = await findCover(resolved.rootDir, resolved.filePath);
if (!("bytes" in result)) {
set.status = result.info.status;
return result.info.error;
@@ -487,7 +514,7 @@ const app = setup
)
.get(
"/download-playlist/:id",
async ({ set, params }) => {
async ({ set, params, user }) => {
const playlist = generatedPlaylistIds.get(params.id);
if (!playlist) {
set.status = "Not Found";
@@ -495,12 +522,12 @@ const app = setup
}
const resolvedPaths: string[] = [];
for (const entry of playlist) {
const resolved = resolveRelativeInRoot(entry);
const resolved = await resolveRelativeInRoot(user, entry);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
}
resolvedPaths.push(resolved);
resolvedPaths.push(resolved.filePath);
}
set.status = "OK";
if (resolvedPaths.length === 1) {
@@ -510,7 +537,7 @@ const app = setup
}
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
return new Response(packWithTar(playlist).stdout);
return new Response(packWithTar(resolvedPaths).stdout);
},
{ params: t.Object({ id: t.String({ minLength: 1 }) }) },
)
▾Mserver/src/shared.ts
@@ -1,60 +1,153 @@
import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import commandExists from "command-exists";
import type { Metadata, VideoExtras } from "music-server-shared/types";
export const { values: args } = parseArgs({
args: Bun.argv,
options: {
serve: { type: "string" },
"hash-password": { type: "boolean" },
},
strict: true,
allowPositionals: true,
});
if (args["hash-password"]) {
const password = (await Bun.stdin.text()).replace(/\r?\n$/, "");
if (!password) {
console.error("No password on stdin. Use: echo -n 'your-password' | server --hash-password");
process.exit(1);
}
console.log(await Bun.password.hash(password));
process.exit(0);
}
export const coverRegex =
process.env.COVER_REGEX === undefined
? /(cover|folder)\.(png|jpe?g)$/i
: process.env.COVER_REGEX === ""
? ""
: new RegExp(process.env.COVER_REGEX, "i");
export const musicRoot = process.env.MUSIC_ROOT || "";
if (musicRoot === "") {
console.error("Please define the environment variable MUSIC_ROOT");
export interface User {
name: string;
//root names this user may see, in the order they should be listed
roots: string[];
//name -> absolute directory, precomputed so path resolution is a single lookup. null-prototype, so
//a request for "constructor" or "toString" misses instead of finding an inherited property
rootDirs: Record<string, string>;
}
interface RawConfig {
roots?: Record<string, string>;
users?: { name?: string; passwordHash?: string; roots?: string[] }[];
}
function configError(message: string): never {
console.error(`Invalid config: ${message}`);
process.exit(1);
}
export const [username, password] = (() => {
if (!process.env.AUTH) return [undefined, undefined];
const decoded = Buffer.from(process.env.AUTH, "base64").toString();
const separator = decoded.indexOf(":");
if (separator <= 0 || separator === decoded.length - 1) {
console.error("AUTH is set but invalid: expected base64 of 'username:password' with both parts non-empty");
const configPath = process.env.CONFIG || "./config.json";
async function loadConfig(): Promise<{ users: User[]; passwordHashes: Map<string, string> }> {
let raw: RawConfig;
try {
raw = await Bun.file(configPath).json();
} catch (error) {
//a parse error can quote the offending source line, which may be a password hash
console.error(`Failed to read config file ${configPath}: ${error instanceof SyntaxError ? "invalid JSON" : error}`);
process.exit(1);
}
return [decoded.slice(0, separator), decoded.slice(separator + 1)];
})();
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) configError("not a JSON object");
const roots = raw.roots;
if (!roots || typeof roots !== "object" || Object.keys(roots).length === 0) configError("no roots defined");
//assigning these as object keys is a silent no-op, which would drop the root without an error
const reservedNames = ["__proto__", "constructor", "prototype"];
//resolved once here so the request path only has to realpath the target, not the root as well
const rootRealPaths: Record<string, string> = Object.create(null);
for (const [name, dir] of Object.entries(roots)) {
if (!name || name.includes("/")) configError(`root name ${JSON.stringify(name)} is empty or contains a slash`);
if (reservedNames.includes(name)) configError(`root name ${JSON.stringify(name)} is reserved`);
if (typeof dir !== "string" || !path.isAbsolute(dir)) configError(`root ${name} is not an absolute path`);
const resolved = await realpath(dir).catch(() => undefined);
if (resolved === undefined) configError(`root ${name} does not exist: ${dir}`);
if (!(await stat(resolved)).isDirectory()) configError(`root ${name} is not a directory: ${dir}`);
rootRealPaths[name] = resolved;
}
export const authTokens = new Map<string, Date>();
if (!raw.users || raw.users.length === 0) configError("no users defined");
const users: User[] = [];
const passwordHashes = new Map<string, string>();
for (const user of raw.users) {
if (typeof user?.name !== "string" || !user.name) configError("a user has no name, or its name is not a string");
if (passwordHashes.has(user.name)) configError(`duplicate user ${user.name}`);
if (typeof user.passwordHash !== "string" || !user.passwordHash)
configError(`user ${user.name} has no passwordHash, or it is not a string`);
//a malformed hash makes Bun.password.verify throw, which would turn every login into a 500.
//authenticate() catches that too, but failing here tells the operator what is actually wrong
if (!user.passwordHash.startsWith("$"))
configError(`user ${user.name} has a passwordHash that is not a hash - generate it with --hash-password`);
const userRoots = user.roots || [];
if (!Array.isArray(userRoots) || userRoots.length === 0) configError(`user ${user.name} has no roots`);
const rootDirs: Record<string, string> = Object.create(null);
for (const rootName of userRoots) {
const dir = rootRealPaths[rootName];
if (!dir) configError(`user ${user.name} references unknown root ${rootName}`);
rootDirs[rootName] = dir;
}
users.push({ name: user.name, roots: userRoots, rootDirs });
passwordHashes.set(user.name, user.passwordHash);
}
return { users, passwordHashes };
}
const loaded = await loadConfig();
export const users = loaded.users;
const dummyHash = await Bun.password.hash("dummy");
export async function authenticate(name: string, password: string): Promise<User | undefined> {
const hash = loaded.passwordHashes.get(name);
const matches = await Bun.password.verify(password, hash ?? dummyHash).catch(() => false);
return matches && hash ? users.find((user) => user.name === name) : undefined;
}
const authTokens = new Map<string, { expires: Date; user: User }>();
export function issueAuthToken(token: string, expires: Date, user: User): void {
authTokens.set(token, { expires, user });
}
export function deleteAuthToken(token: string): void {
authTokens.delete(token);
}
//checks presence *and* expiry - the daily sweep below only bounds memory, it is not an
//enforcement mechanism, so a token must not stay valid past its end date while awaiting it
export function isValidAuthToken(token: string): boolean {
const endDate = authTokens.get(token);
if (!endDate) return false;
if (endDate.getTime() < Date.now()) {
export function userForToken(token: string): User | undefined {
const session = authTokens.get(token);
if (!session) return undefined;
if (session.expires.getTime() < Date.now()) {
authTokens.delete(token);
return false;
return undefined;
}
return true;
return session.user;
}
//clear outdated tokens once a day
setInterval(
() => {
for (const [token, endDate] of authTokens.entries()) {
if (endDate.getTime() < Date.now()) authTokens.delete(token);
for (const [token, session] of authTokens.entries()) {
if (session.expires.getTime() < Date.now()) authTokens.delete(token);
}
},
1000 * 60 * 60 * 24,
);
if (!path.isAbsolute(musicRoot)) {
console.error(`Music root ${musicRoot} is not absolute`);
process.exit(1);
}
export const excludeExtension =
process.env.EXCLUDE_EXTENSION === undefined
? ["txt", "log", "nfo", "m3u", "htm", "html"]
@@ -85,17 +178,6 @@ if (!commandExists.sync("mediainfo")) {
process.exit(1);
}
export const { values: args } = parseArgs({
args: Bun.argv,
options: {
serve: {
type: "string",
},
},
strict: true,
allowPositionals: true,
});
if (!args.serve) {
console.error("Missing --serve, set it to the directory containing the frontend code");
process.exit(1);
▾Mserver/src/utils.ts
@@ -1,4 +1,4 @@
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { StatusMap } from "elysia";
import { fileTypeFromBlob } from "file-type";
@@ -18,7 +18,6 @@ import {
excludeExtension,
fileTypeCache,
mediaTypes,
musicRoot,
PathInfo,
probeCache,
ServerError,
@@ -61,13 +60,13 @@ function isBitmapSubtitle(format: string | undefined): boolean {
const mediaInfoSemaphore = new AsyncSemaphore(scanConcurrency);
export async function findCover(targetPath: string): Promise<CoverResult> {
export async function findCover(rootDir: string, targetPath: string): Promise<CoverResult> {
try {
if ((await stat(targetPath)).isFile()) {
//duration is not needed for cover extraction and would force a full-file parse for some formats
const parsed = await parseFile(targetPath, { duration: false, skipCovers: false });
const picture = selectCover(parsed.common.picture);
if (!picture) return findCover(path.dirname(targetPath));
if (!picture) return findCover(rootDir, path.dirname(targetPath));
return {
info: new PathInfo(picture.format),
bytes: () => Promise.resolve(picture.data as Uint8Array<ArrayBuffer>),
@@ -84,8 +83,8 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile();
});
const above = path.dirname(targetPath);
if (!coverFile && isBelow(musicRoot, above)) {
return findCover(above);
if (!coverFile && isBelow(rootDir, above)) {
return findCover(rootDir, above);
}
} catch (error) {
if (errorCode(error) !== "ENOENT") {
@@ -364,23 +363,37 @@ export function isBelow(basePath: string, targetPath: string) {
return !relativePath.startsWith("..");
}
export async function listFiles(subPath: string, recursive: boolean): Promise<FileListingWithStatus | ServerError> {
export async function listFiles(
rootDir: string,
subPath: string,
recursive: boolean,
): Promise<FileListingWithStatus | ServerError> {
const files: FileListingWithStatus = {};
try {
//process entries concurrently; the MediaInfo pool already bounds the expensive probing part
await Promise.all(
(await readdir(subPath)).map(async (fileName) => {
(await readdir(subPath, { withFileTypes: true })).map(async (entry) => {
const fileName = entry.name;
const itemPath = path.join(subPath, fileName);
if (!isBelow(musicRoot, itemPath)) {
if (!isBelow(rootDir, itemPath)) {
console.log(`Skipping ${itemPath} as it is outside the base path.`);
return;
}
//isBelow is lexical and cannot see through a symlink, so resolve the ones that exist.
//a symlink staying inside the root is okay, so keep those
if (entry.isSymbolicLink()) {
const realItemPath = await realpath(itemPath).catch(() => undefined);
if (realItemPath === undefined || !isBelow(rootDir, realItemPath)) {
console.log(`Skipping ${itemPath} as it links outside the root.`);
return;
}
}
const pathInfoResult = await getPathInfo(itemPath);
if (!pathInfoResult) {
if (recursive) {
const subListing = await listFiles(itemPath, true);
const subListing = await listFiles(rootDir, itemPath, true);
if (subListing instanceof ServerError) throw subListing;
files[fileName] = { files: subListing, status: "Scanned" };
} else {
@@ -431,22 +444,13 @@ export async function readStream(stream: ReadableStream<Uint8Array>): Promise<Ui
}
}
export function packWithTar(files: string[]) {
// Ensure all files are within musicRoot
const absoluteFiles = files.map((f) => path.join(musicRoot, 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));
//takes absolute paths the caller has already resolved and validated. the transform flattens every
//entry to its basename, so a playlist spanning several roots needs no grouping
export function packWithTar(absoluteFiles: string[]) {
const tarArgs = [
"--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
"-C",
musicRoot,
"-c", // create archive
...relFiles,
...absoluteFiles,
];
return Bun.spawn(["tar", ...tarArgs], {
stdin: "ignore",
▾Mshared/types.ts
@@ -47,10 +47,6 @@ export interface Metadata {
subtitleTracks?: SubtitleTrack[];
}
export interface IsVideoResponse {
isVideo: boolean;
}
export interface MediaFile {
metadata: Metadata;
}
▾Mwebclient/src/App.tsx
@@ -40,7 +40,14 @@ import {
type PlaylistItem,
StreamingMode,
} from "./types";
import { formatFilename, handleUnauthorized, LocalStorageValues, setUnauthorizedHandler, toast } from "./utils";
import {
formatFilename,
handleUnauthorized,
LocalStorageValues,
setForbiddenPathHandler,
setUnauthorizedHandler,
toast,
} from "./utils";
//TODO: bypass music-metadata if it fails, maybe only use for fallback for files with weird encodings
//TODO: more file display options
@@ -102,7 +109,9 @@ const App: Component = () => {
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [showDownloadManager, setShowDownloadManager] = createSignal(false);
const [showSignIn, setShowSignIn] = createSignal(false);
const [authRequired, setAuthRequired] = createSignal(false);
//the server always requires a login, so assume so until /auth/status says otherwise. starting at
//false would hide the sign-in button whenever that first request fails
const [authRequired, setAuthRequired] = createSignal(true);
const [loggedIn, setLoggedIn] = createSignal(false);
const [options, setOptions] = createStore<AppOptions>({
//default settings
@@ -156,8 +165,10 @@ const App: Component = () => {
}
})(),
);
const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
() => ({ dir: currentDir(), offline: isOffline() }),
const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean; loggedIn: boolean }>(
//loggedIn is part of the key so signing in refetches: the listing before it was a 401, which
//leaves an empty browser behind with nothing to trigger a retry
() => ({ dir: currentDir(), offline: isOffline(), loggedIn: loggedIn() }),
async (args) => {
return Object.entries(await listFiles(args.dir, args.offline, false));
},
@@ -225,6 +236,10 @@ const App: Component = () => {
//a 401 from any request means the session is no longer valid
setUnauthorizedHandler(() => setLoggedIn(false));
//a 403 on a listing means the path names a root this user does not have, e.g. a bookmark from
//before roots existed. without this the bad path stays in localStorage and fails on every reload
setForbiddenPathHandler(() => setCurrentDir(""));
//re-check auth whenever we (re)enter online mode
createEffect(() => {
if (!isOffline()) refreshAuthStatus();
▾Mwebclient/src/offline.ts
@@ -16,7 +16,16 @@ import { batch, from, type ResourceActions, untrack } from "solid-js";
import { createStore } from "solid-js/store";
import type { FlatFileListing } from "./App";
import { type AppOptions, type LoadedVideoExtras, type Playlist, StreamingMode } from "./types";
import { basename, dirname, handleUnauthorized, joinPath, parentPaths, toast, unproxy } from "./utils";
import {
basename,
dirname,
handleForbiddenPath,
handleUnauthorized,
joinPath,
parentPaths,
toast,
unproxy,
} from "./utils";
interface StoredSubtitle extends SubtitleTrack {
data: Blob;
@@ -238,6 +247,9 @@ async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileLis
() => {},
);
if (handleUnauthorized(response)) return;
//403 means the path names a root this user does not have - typically a bookmark or a remembered
//path from before roots existed, so it has to reset rather than just report a failure
if (handleForbiddenPath(response)) return;
if (response?.status === 404) {
toast(`Directory not found: ${dir}`, "error");
return;
@@ -329,6 +341,8 @@ export async function getCover(
offline: boolean,
forceRefresh = false,
) {
//the top level lists the roots themselves, so there is no directory to take a cover from
if (dir === "") return undefined;
try {
const cached = coverCache.get(dir);
if (!forceRefresh && cached !== undefined) {
▾Mwebclient/src/utils.ts
@@ -119,6 +119,19 @@ export function handleUnauthorized(response?: Response | { status?: number } | v
return true;
}
//registered by the app root so a listing the user may not see resets navigation to the top level
let onForbiddenPath: (() => void) | undefined;
export function setForbiddenPathHandler(handler: () => void) {
onForbiddenPath = handler;
}
export function handleForbiddenPath(response?: Response | { status?: number } | void): boolean {
if (response?.status !== 403) return false;
onForbiddenPath?.();
toast("That folder is not available to you, returning to the top level", "error");
return true;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "Queued...";