version upgrade

AuthorKonata <konata@posteo.jp>
Date
Commit1ddf7e7f7d0246b5951cc560cfe8149d2561d665
Parent4f688a9
27 files changed, 266 insertions(+), 261 deletions(-)
▾MContainerfile
@@ -1,4 +1,4 @@
FROM docker.io/oven/bun:1.2.16-alpine AS compile
FROM docker.io/oven/bun:1.3.14-alpine AS compile
WORKDIR /app
COPY . .
▾Mbiome.json
@@ -1,21 +1,28 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"files": {
"ignore": ["**/*.d.ts"]
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"organizeImports": {
"enabled": true
"files": {
"includes": ["**", "!**/*.d.ts"]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
"lineWidth": 120
},
"linter": {
"rules": {
"a11y": {
"all": false
"a11y": "off",
"complexity": {
"noImportantStyles": "off"
},
"correctness": {
"useJsxKeyInIterable": "off"
},
"style": {
"noDescendingSpecificity": "off"
}
}
},
▾Mserver/package.json
@@ -12,22 +12,20 @@
"compile": "bun run build:frontend && bun build src/index.ts --compile --production --outfile dist/server"
},
"devDependencies": {
"@biomejs/biome": "^2.5.4",
"@types/command-exists": "^1.2.3",
"@types/range-parser": "^1.2.7",
"@types/tmp": "^0.2.6",
"bun-types": "latest",
"concurrently": "^9.1.2"
"concurrently": "^10.0.3"
},
"dependencies": {
/*elysiajs bug from 1.3.0: headers get appended instead of overwritten*/
"elysia": "1.2.25",
"@elysiajs/static": "1.2.0",
"elysia": "^1.4.29",
"@elysiajs/static": "^1.4.10",
"command-exists": "^1.2.9",
"file-type": "^20.0.1",
"music-metadata": "^11.2.0",
"file-type": "^22.0.1",
"music-metadata": "^11.14.0",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1",
"tmp": "^0.2.3"
"tmp": "^0.2.7"
},
"module": "src/index.js"
}
▾Mserver/src/ffmpeg.ts
@@ -39,7 +39,7 @@ export async function convertWithFFmpeg(
) {
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
let audioCodec = undefined;
let audioCodec: string;
switch (requestedAudioCodec) {
case AudioCodec.aac:
audioCodec = "aac";
@@ -61,7 +61,7 @@ export async function convertWithFFmpeg(
}
}
let maxWidth = undefined;
let maxWidth: number | undefined;
if (videoBitrate <= 210000) {
maxWidth = 860;
} else if (videoBitrate < 420000) {
@@ -74,7 +74,7 @@ export async function convertWithFFmpeg(
maxWidth = 3840;
}
let containerSettings = undefined;
let containerSettings: string[] | undefined;
if (container === MediaContainer.mp4) {
containerSettings = [
"-avoid_negative_ts",
@@ -85,8 +85,8 @@ export async function convertWithFFmpeg(
}
const videoEncodingSettings: string[] = [];
let videoCodec = undefined;
let extraVideoSettings = undefined;
let videoCodec: string | undefined;
let extraVideoSettings: string[] | undefined;
//only convert video if transcoding is requesting so
if (requestedVideoCodec !== VideoCodec.none) {
extraVideoSettings = [
▾Mserver/src/index.ts
@@ -1,9 +1,19 @@
import { stat } from "node:fs/promises";
import path, { basename } from "node:path";
import staticPlugin from "@elysiajs/static";
import { randomUUIDv7 } from "bun";
import { Elysia, type InferHandler, t } from "elysia";
import { stringifyMap } from "music-server-shared/mapconversion";
import {
AudioCodec,
type IsVideoResponse,
MediaContainer,
type Metadata,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import { decodePath } from "music-server-shared/utils";
import { convertWithFFmpeg } from "./ffmpeg";
import {
ServerError,
allowedTypes,
args,
authTokens,
@@ -13,24 +23,11 @@ import {
musicRoot,
password,
probeCache,
ServerError,
username,
} from "./shared";
import { findCover, getPathInfo, listFiles, matchesType, packWithTar, probeFile, toAvif } from "./utils";
import { randomUUIDv7 } from "bun";
import { stringifyMap } from "music-server-shared/mapconversion";
import {
AudioCodec,
type IsVideoResponse,
MediaContainer,
type Metadata,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import { decodePath } from "music-server-shared/utils";
import parseRange from "range-parser";
import { convertWithFFmpeg } from "./ffmpeg";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
//TODO: better ffmpeg errors
@@ -42,7 +39,7 @@ const setup = new Elysia({ serve: { idleTimeout: 255 } });
const paramType = t.ArrayString();
type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
const downloadHandler: DownloadHandler = async ({ params, set }) => {
const filePath = path.join(musicRoot, decodePath(params["*"]));
const fileScan = await getPathInfo(filePath);
if (!fileScan || fileScan instanceof ServerError) {
@@ -57,7 +54,8 @@ const downloadHandler: DownloadHandler = async ({ headers, params, set, request
set.status = "OK";
if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
set.headers["content-type"] = fileScan.mimeType;
return new Response(Bun.file(filePath));
//returning the BunFile directly lets elysia serve range requests natively (Accept-Ranges/206/416)
return Bun.file(filePath);
};
const app = setup
@@ -87,10 +85,10 @@ const app = setup
)
.guard(
{
cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
beforeHandle({ cookie: { authToken }, set }) {
if (username && password && (!authToken.value || !authTokens.has(authToken.value))) {
set.status = 401;
set.headers.location;
return "Unauthorized";
}
},
▾Mserver/src/utils.ts
@@ -7,17 +7,22 @@ import { parseFile } from "music-metadata";
import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
import tmp from "tmp";
import {
PathInfo,
ServerError,
coverRegex,
excludeExtension,
fileTypeCache,
mediaTypes,
musicRoot,
PathInfo,
probeCache,
ServerError,
scanConcurrency,
} from "./shared";
//fs errors are untyped, so pull the errno code out of the unknown catch value
function errorCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
const results = await Promise.all(promises);
@@ -25,7 +30,10 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
return arr[index];
}
type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
type CoverResult =
| { info: PathInfo; path: string }
| { info: PathInfo; content: Uint8Array<ArrayBuffer> }
| { info: ServerError };
//the mediainfo CLI JSON output emits every value as a string; broken text encodings arrive
//as objects carrying an "@dt" attribute instead (detected in probeFile)
@@ -72,7 +80,10 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
const parsed = await parseFile(targetPath, { duration: false, skipCovers: false });
if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath));
return { info: new PathInfo(parsed.common.picture[0].format), content: parsed.common.picture[0].data };
return {
info: new PathInfo(parsed.common.picture[0].format),
content: parsed.common.picture[0].data as Uint8Array<ArrayBuffer>,
};
}
} catch (e) {
return { info: new ServerError(StatusMap["Internal Server Error"], `error getting cover ${e}`) };
@@ -88,9 +99,8 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
if (!coverFile && isBelow(musicRoot, above)) {
return findCover(above);
}
// biome-ignore lint/suspicious/noExplicitAny:
} catch (error: any) {
if (error?.code !== "ENOENT") {
} catch (error) {
if (errorCode(error) !== "ENOENT") {
console.error("error getting cover: readdirSync failed:", error);
return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") };
}
@@ -211,12 +221,11 @@ export async function getPathInfo(filePath: string): Promise<PathInfo | ServerEr
}
}
return new PathInfo(mimeType);
// biome-ignore lint/suspicious/noExplicitAny:
} catch (error: any) {
if (error?.code === "ENOENT") {
} catch (error) {
if (errorCode(error) === "ENOENT") {
return new ServerError(StatusMap["Not Found"], "File not found");
}
console.error("Error scanning file:", filePath, error.message);
console.error("Error scanning file:", filePath, error);
return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
}
}
@@ -263,12 +272,11 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
}),
);
return files;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} catch (error: any) {
if (error?.code === "ENOENT") {
} catch (error) {
if (errorCode(error) === "ENOENT") {
return new ServerError(StatusMap["Not Found"], "Directory not found");
}
console.error("Error listing path:", subPath, error.message);
console.error("Error listing path:", subPath, error);
return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
}
}
@@ -329,10 +337,10 @@ export function packWithTar(files: string[]) {
//copy to a temp file and then use avifenc to convert
export async function toAvif(
data: Uint8Array,
data: Uint8Array<ArrayBuffer>,
mimeType: string,
setContentType: (mimeType: string) => void,
): Promise<Uint8Array> {
): Promise<Uint8Array<ArrayBuffer>> {
const tmpFile = tmp.fileSync({
postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/png") ? ".png" : undefined,
});
@@ -352,8 +360,8 @@ export async function toAvif(
});
await cmd.exited;
setContentType("image/avif");
return readFileSync(tmpFile2.fd);
} catch (e) {
return readFileSync(tmpFile2.fd) as Uint8Array<ArrayBuffer>;
} catch {
setContentType(mimeType);
return data;
} finally {
▾Mserver/tsconfig.json
@@ -1,103 +1,103 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Language and Environment */
"target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* Modules */
"module": "ES2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": ["bun-types"] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
"useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */,
"strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */,
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"noImplicitThis": true /* Enable error reporting when 'this' is given the type 'any'. */,
"useUnknownInCatchVariables": true /* Default catch clause variables as 'unknown' instead of 'any'. */,
"alwaysStrict": true /* Ensure 'use strict' is always emitted. */,
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
"noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */,
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
▾Mshared/mapconversion.ts
@@ -87,4 +87,4 @@ export function stringifyMap(value: unknown): string {
export function parseMap(jsonString: string): unknown {
return reviveFromSerializable(JSON.parse(jsonString));
}
}
▾Mshared/package.json
@@ -1 +1 @@
{}
{}
▾Mwebclient/package.json
@@ -8,17 +8,17 @@
},
"devDependencies": {
"bun-types": "latest",
"typescript": "^5.7.2",
"vite": "^6.0.0",
"vite-plugin-solid": "^2.11.6",
"vite-plugin-pwa": "^1.0.0",
"@types/toastify-js": "^1.12.3"
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vite-plugin-solid": "^2.11.13",
"vite-plugin-pwa": "^1.3.0",
"@types/toastify-js": "^1.12.4"
},
"dependencies": {
"dexie": "^4.0.11",
"lightningcss": "^1.29.3",
"dexie": "^4.4.4",
"lightningcss": "^1.32.0",
"music-server-shared": "file:../shared",
"solid-js": "^1.9.5",
"solid-js": "^1.9.14",
"toastify-js": "^1.12.0"
}
}
▾Mwebclient/src/App.tsx
@@ -7,8 +7,8 @@ import {
VideoEncodingSetting,
} from "music-server-shared/types";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import { batch, createEffect, createResource, createSignal, on, onMount } from "solid-js";
import type { Component } from "solid-js";
import { batch, createEffect, createResource, createSignal, on, onMount } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import { BUILD_HASH } from "./build-info";
import DownloadManager from "./components/DownloadManager";
@@ -17,6 +17,7 @@ import OptionsModal from "./components/OptionsModal";
import PlayerControls from "./components/PlayerControls";
import Playlist from "./components/Playlist";
import PlaylistManager from "./components/PlaylistManager";
import { SignIn } from "./components/SignIn";
import {
ClosedFolderSVG,
CogSVG,
@@ -26,10 +27,9 @@ import {
OpenFolderSVG,
PlaylistSVG,
} from "./components/SVG";
import { SignIn } from "./components/SignIn";
import { getCover, getSrc, listFiles } from "./offline";
import { type AppOptions, FileDisplay, type PlayerState, type PlaylistItem } from "./types";
import { LocalStorageValues, formatFilename, toast } from "./utils";
import { formatFilename, LocalStorageValues, 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
@@ -238,7 +238,7 @@ const App: Component = () => {
let mediaSource: MediaSource | null = null;
let mediaSourceAbortController: AbortController | null = null;
async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
let mimeType = undefined;
let mimeType: string;
if (isVideo) {
mimeType = createRFC6381String(
options.videoSettings.container,
@@ -304,7 +304,7 @@ const App: Component = () => {
return true;
}
return false;
} catch (e) {
} catch (_e) {
cleanup();
resolve(undefined);
}
▾Mwebclient/src/components/DownloadButton.tsx
@@ -1,12 +1,12 @@
import "./DownloadButton.css";
import type { MediaFile, Directory } from "music-server-shared/types";
import type { Directory, MediaFile } from "music-server-shared/types";
import { type Accessor, createMemo, type ResourceActions } from "solid-js";
import type { FlatFileListing } from "../App";
import { deleteFile, download, getDownloadStatus } from "../offline";
import { createMemo, type Accessor, type ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
import { formatBytes, joinPath } from "../utils";
import { DeleteSVG, RefreshSVG, SyncSVG } from "./SVG";
import { formatBytes } from "../utils";
import { LoadingSpinner } from "./LoadingScreen";
import type { FlatFileListing } from "../App";
import { DeleteSVG, RefreshSVG, SyncSVG } from "./SVG";
interface DownloadButtonProps {
fullPath: string;
▾Mwebclient/src/components/DownloadManager.css
@@ -2,7 +2,7 @@
display: flex;
gap: 1.5rem;
@media (max-width: 750px) {
gap: 1.0rem; /*allows the title to fit with the buttons at min width*/
gap: 1rem; /*allows the title to fit with the buttons at min width*/
}
& > span {
min-width: 0;
▾Mwebclient/src/components/DownloadManager.tsx
@@ -1,10 +1,10 @@
import { batch, createEffect, createMemo, createSignal, For, Show, type Accessor, type Setter } from "solid-js";
import { type Accessor, batch, createEffect, createMemo, createSignal, For, type Setter, Show } from "solid-js";
import "./DownloadManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { type DownloadStatus, downloadStatusMap, type Progress, setDownloadStatusMap } from "../offline";
import { formatBytes } from "../utils";
import { ClearSVG, RefreshSVG, StopSVG } from "./SVG";
import { type DownloadStatus, downloadStatusMap, setDownloadStatusMap, type Progress } from "../offline";
interface DownloadManagerProps {
showDownloadManager: Accessor<boolean>;
▾Mwebclient/src/components/DraggableList.tsx
@@ -1,4 +1,4 @@
import { type Accessor, type Setter, createSignal, onMount, onCleanup, type JSX, For, createMemo } from "solid-js";
import { type Accessor, createMemo, createSignal, For, type JSX, onCleanup, onMount, type Setter } from "solid-js";
type DropPosition = "before" | "after" | null;
@@ -57,7 +57,7 @@ export function DraggableList<T>(props: DraggableListProps<T>) {
scheduleUpdate(lastClientY);
}
//@ts-ignore
//@ts-expect-error
scrollInterval = setInterval(step, 10);
}
▾Mwebclient/src/components/FileBrowser.tsx
@@ -1,4 +1,5 @@
import "./FileBrowser.css";
import type { Directory, FileListing, MediaFile } from "music-server-shared/types";
import {
type Accessor,
createEffect,
@@ -12,15 +13,14 @@ import {
type Setter,
Show,
} from "solid-js";
import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
import { createStore, reconcile } from "solid-js/store";
import type { FlatFileListing } from "../App";
import { clearDirectoryCache, getCover, listFiles, storedFilesLive } from "../offline";
import type { AppOptions, PlaylistItem } from "../types";
import { formatFilename, generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
import { clearDirectoryCache, getCover, listFiles, storedFilesLive } from "../offline";
import { DownloadButton } from "./DownloadButton";
import { LoadingScreen } from "./LoadingScreen";
import type { FlatFileListing } from "../App";
import { createStore, reconcile } from "solid-js/store";
import { ClosedFolderSVG, DownSVG, FilterSVG, MusicSVG, RefreshSVG, UpSVG } from "./SVG";
interface FileBrowserProps {
breadcrumbs: Accessor<string[]>;
@@ -383,41 +383,39 @@ export default function FileBrowser(props: FileBrowserProps) {
{({ fullPath, displayName, file }) => {
const isDirectory = !("metadata" in file);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => {
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, file as MediaFile);
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => {
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, file as MediaFile);
}}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{displayName}</span>
<div class="download-button-wrapper">
{isDirectory && !props.isOffline() && downloadedChildren[fullPath]}
<DownloadButton
fullPath={fullPath}
item={file}
fetchFiles={props.fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
/>
</div>
<button
title="Add to Playlist"
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(fullPath, true, true);
} else {
playFiles(fullPath, false, true, file);
}
}}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{displayName}</span>
<div class="download-button-wrapper">
{isDirectory && !props.isOffline() && downloadedChildren[fullPath]}
<DownloadButton
fullPath={fullPath}
item={file}
fetchFiles={props.fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
/>
</div>
<button
title="Add to Playlist"
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(fullPath, true, true);
} else {
playFiles(fullPath, false, true, file);
}
}}
>
Add
</button>
</div>
</>
Add
</button>
</div>
);
}}
</For>
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,11 +1,11 @@
import { type Accessor, createEffect, type Setter, on, createSignal, Switch, Match, For } from "solid-js";
import { FileDisplay, type AppOptions } from "../types";
import { type Accessor, createEffect, createSignal, For, Match, on, type Setter, Switch } from "solid-js";
import { type AppOptions, FileDisplay } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { toast } from "../utils";
import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createStore } from "solid-js/store";
import { MediaContainer, VideoCodec, AudioCodec, VideoEncodingSetting } from "music-server-shared/types";
import { toast } from "../utils";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -110,7 +110,7 @@ export default function OptionsModal(props: OptionsModalProps) {
async function deleteServerCache() {
const response = await fetch("/reset-cache", { method: "POST", credentials: "same-origin" }).catch(() => {});
if (!response || response.status !== 200) {
if (response?.status !== 200) {
toast("Clearing server cache failed", "error");
} else {
toast("Server cache cleared", "info");
@@ -261,7 +261,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="filebrowser-display-select"
value={newOptions.filebrowserDisplay}
onChange={(e) => setNewOptions("filebrowserDisplay", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("filebrowserDisplay", Number.parseInt(e.target.value, 10))}
>
<option value={FileDisplay.FILENAME}>Filename</option>
<option value={FileDisplay.TITLE}>Media title</option>
@@ -273,7 +273,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="playlist-display-select"
value={newOptions.playlistDisplay}
onChange={(e) => setNewOptions("playlistDisplay", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("playlistDisplay", Number.parseInt(e.target.value, 10))}
>
<option value={FileDisplay.FILENAME}>Filename</option>
<option value={FileDisplay.TITLE}>Media title</option>
@@ -285,7 +285,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="controls-display-select"
value={newOptions.controlsDisplay}
onChange={(e) => setNewOptions("controlsDisplay", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("controlsDisplay", Number.parseInt(e.target.value, 10))}
>
<option value={FileDisplay.FILENAME}>Filename</option>
<option value={FileDisplay.TITLE}>Media title</option>
@@ -298,7 +298,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="font-resolution-select"
value={newOptions.fontResolution}
onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("fontResolution", Number.parseInt(e.target.value, 10))}
>
<option value={10}>10px</option>
<option value={12}>12px</option>
@@ -408,7 +408,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="video-bitrate-select"
value={newOptions.videoSettings.videoBitrate}
onChange={(e) => setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("videoSettings", "videoBitrate", Number.parseInt(e.target.value, 10))}
>
<option value="210000">210 kbps</option>
<option value="420000">420 kbps</option>
@@ -450,7 +450,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="audio-bitrate-select"
value={newOptions.videoSettings.audioBitrate}
onChange={(e) => setNewOptions("videoSettings", "audioBitrate", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("videoSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
@@ -499,7 +499,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<select
id="audio-bitrate-select"
value={newOptions.audioSettings.audioBitrate}
onChange={(e) => setNewOptions("audioSettings", "audioBitrate", Number.parseInt(e.target.value))}
onChange={(e) => setNewOptions("audioSettings", "audioBitrate", Number.parseInt(e.target.value, 10))}
>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
▾Mwebclient/src/components/PlayerControls.tsx
@@ -1,6 +1,9 @@
import "./PlayerControls.css";
import { type Accessor, createEffect, createSignal, For, onMount, type Setter, Show } from "solid-js";
import type { SetStoreFunction } from "solid-js/store";
import type { AppOptions, PlayerState } from "../types";
import { type Accessor, createEffect, createSignal, For, onCleanup, onMount, type Setter, Show } from "solid-js";
import { formatFilename, formatTime, LocalStorageValues } from "../utils";
import { LoadingSpinner } from "./LoadingScreen";
import {
ClearSVG,
DownSVG,
@@ -15,9 +18,6 @@ import {
UpSVG,
VolumeSVG,
} from "./SVG";
import { formatFilename, formatTime, LocalStorageValues } from "../utils";
import type { SetStoreFunction } from "solid-js/store";
import { LoadingSpinner } from "./LoadingScreen";
interface PlayerControlsProps {
playerState: PlayerState;
▾Mwebclient/src/components/Playlist.css
@@ -110,6 +110,3 @@
flex-grow: 1;
min-height: 2rem;
}
▾Mwebclient/src/components/Playlist.tsx
@@ -1,24 +1,24 @@
import {
createSelector,
type Accessor,
type Setter,
createSignal,
type ResourceActions,
batch,
createEffect,
createResource,
createSelector,
createSignal,
on,
onMount,
type ResourceActions,
type Setter,
Show,
on,
batch,
createResource,
} from "solid-js";
import "./Playlist.css";
import type { PlayerState, PlaylistItem, AppOptions } from "../types";
import type { FlatFileListing } from "../App";
import { download, getCover, savePlaylist, storedFilesLive } from "../offline";
import type { AppOptions, PlayerState, PlaylistItem } from "../types";
import { formatFilename, formatTime, LocalStorageValues, toast } from "../utils";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
import { savePlaylist, download, storedFilesLive, getCover } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FlatFileListing } from "../App";
import { LoadingScreen } from "./LoadingScreen";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
interface PlaylistProps {
playlist: Accessor<PlaylistItem[]>;
@@ -38,7 +38,7 @@ export default function Playlist(props: PlaylistProps) {
const [showActionButtons, setShowActionButtons] = createSignal(true);
//TODO: add a way to refetch cover
const [cover, fetchCover] = createResource<string | undefined, { filePath?: string; offline: boolean }>(
const [cover] = createResource<string | undefined, { filePath?: string; offline: boolean }>(
() => ({ filePath: props.playerState.currentTrack?.path, offline: props.isOffline() }),
async (args, info) => {
if (info.value) URL.revokeObjectURL(info.value as string);
@@ -141,7 +141,6 @@ export default function Playlist(props: PlaylistProps) {
setSyncedCount(playlist.filter((item) => stored.has(item.path)).length);
}),
);
let videoElement!: HTMLVideoElement;
onMount(() => {
const savedPlaylistButtons = localStorage.getItem(LocalStorageValues.playlistButtons);
if (savedPlaylistButtons !== null) setShowActionButtons(savedPlaylistButtons === "true");
▾Mwebclient/src/components/PlaylistManager.tsx
@@ -1,12 +1,12 @@
import { createEffect, createResource, createSignal, For, Show, Signal, type Accessor, type Setter } from "solid-js";
import { type Accessor, createEffect, createResource, createSignal, For, type Setter, Show } from "solid-js";
import "./PlaylistManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
import type { AppOptions, Playlist, PlaylistItem } from "../types";
import { formatFilename, formatTime, generateUUID, toast } from "../utils";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
import { LoadingScreen } from "./LoadingScreen";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
interface PlaylistManagerProps {
showPlaylistManager: Accessor<boolean>;
▾Mwebclient/src/components/SignIn.tsx
@@ -1,8 +1,8 @@
import { type Accessor, createEffect, type Setter } from "solid-js";
import { ClearSVG } from "./SVG";
import "./SignIn.css";
import { LoadingSpinner } from "./LoadingScreen";
import { toast } from "../utils";
import { LoadingSpinner } from "./LoadingScreen";
interface SignInProps {
showSignIn: Accessor<boolean>;
▾Mwebclient/src/index.tsx
@@ -96,6 +96,6 @@ if (version && version !== currentVersion) {
})
.catch((e) => {
root.innerHTML = e.toString();
console.error(e)
console.error(e);
});
}
▾Mwebclient/src/offline.ts
@@ -9,7 +9,7 @@ import type {
Metadata,
} from "music-server-shared/types";
import { encodePath } from "music-server-shared/utils";
import { type ResourceActions, batch, from, untrack } from "solid-js";
import { batch, from, type ResourceActions, untrack } from "solid-js";
import { createStore } from "solid-js/store";
import type { FlatFileListing } from "./App";
import { AsyncSemaphore } from "./semaphore";
@@ -229,7 +229,7 @@ async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileLis
toast(`Directory not found: ${dir}`, "error");
return;
}
if (!response || !response.ok) {
if (!response?.ok) {
toast(
`Fetching files for ${dir === "" ? "root" : dir} failed${response ? `: ${response.statusText}` : ""}`,
"error",
@@ -242,7 +242,7 @@ async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileLis
function listOfflineFiles(dir: string): FileListing {
const node = getDirInTree(dir, false, true);
if (!node || !node.files) return new Map();
if (!node?.files) return new Map();
return node.files;
}
▾Mwebclient/src/style.css
@@ -157,4 +157,4 @@ input {
max-height: 100%;
max-width: 100%;
}
}
}
▾Mwebclient/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
▾Mwebclient/vite.watch.config.ts
@@ -9,11 +9,11 @@ export default defineConfig({
build: {
sourcemap: true,
watch: {
chokidar: {
watcher: {
usePolling: true,
interval: 1000,
ignored: ["**/build-info.ts"], // Ignore changes to this file
pollInterval: 1000,
},
exclude: ["**/build-info.ts"], // Ignore changes to this file
},
},
});