temp

AuthorKonata <konata@posteo.jp>
Date
Commit6c2fc45429a03db34728f27c88930116289614f9
Parentbcac5c6
10 files changed, 326 insertions(+), 97 deletions(-)
▾Mserver/package.json
@@ -19,10 +19,11 @@
"concurrently": "^9.1.2"
},
"dependencies": {
"@elysiajs/static": "1.2.0",
"@elysiajs/static": "^1.3.0",
"command-exists": "^1.2.9",
"elysia": "1.2.25",
"elysia": "^1.3.1",
"file-type": "^20.0.1",
"mediainfo.js": "^0.3.5",
"music-metadata": "^11.2.0",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1",
▾Mserver/src/index.ts
@@ -27,10 +27,10 @@ import {
import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { MediaFormat } from "music-server-shared/types";
import { decodePath } from "music-server-shared/utils";
import { stringifyMap } from "music-server-shared/mapconversion";
import { randomUUIDv7 } from "bun";
import { AudioCodec, MediaContainer, VideoCodec } from "music-server-shared/types";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
@@ -163,9 +163,13 @@ const app = setup
return "Forbidden file type";
}
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
const bitrate =
query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
//don't use higher bitrate than what the file has, use requested bitrate if unknown
const audioBitrate = probe.audioBitrate
? Math.min(query.audioBitrate, probe.audioBitrate)
: query.audioBitrate;
const videoBitrate = probe.videoBitrate
? Math.min(query.videoBitrate, probe.videoBitrate)
: query.videoBitrate;
if (!matchesType(fileScan.mimeType, mediaTypes)) {
set.status = "Temporary Redirect";
set.headers.Location = `/download/${params["*"]}`;
@@ -173,8 +177,11 @@ const app = setup
}
const { cmd, mimeType } = convertWithFFmpeg(
filePath,
bitrate,
query.format ?? MediaFormat["opus/ogg"],
audioBitrate,
videoBitrate,
query.container,
query.audioCodec,
query.videoCodec,
query.seekTo,
);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
@@ -196,8 +203,11 @@ const app = setup
},
{
query: t.Object({
bitrate: t.Number(),
format: t.Enum(MediaFormat),
audioCodec: t.Enum(AudioCodec),
audioBitrate: t.Number(),
videoCodec: t.Enum(VideoCodec),
videoBitrate: t.Number(),
container: t.Enum(MediaContainer),
disableChunkedTranscoding: t.Optional(t.Boolean()),
seekTo: t.Optional(t.Number()),
}),
▾Mserver/src/shared.ts
@@ -1,9 +1,10 @@
import path from "node:path";
import type { ProbeData } from "./utils";
import commandExists from "command-exists";
import type { Metadata } from "music-server-shared/types";
export const coverRegex = process.env.COVER_REGEX === undefined ? /(cover)|(folder).(png|jpg)$/i : "";
export const musicRoot = process.env.MUSIC_ROOT || "";
export const mediaInfoDisabled = !!process.env.DISABLE_MEDIAINFO || false;
if (musicRoot === "") {
console.error("Please define the environment variable MUSIC_ROOT");
process.exit(1);
@@ -52,7 +53,7 @@ export const mediaTypes = ["audio", "video"];
export const allowedTypes = [...mediaTypes, "image"];
export const fileTypeCache = new Map<string, string>();
export const probeCache = new Map<string, ProbeData>();
export const probeCache = new Map<string, Metadata>();
export const generatedPlaylistIds = new Map<string, string[]>();
export class PathInfo {
▾Mserver/src/utils.ts
@@ -1,12 +1,19 @@
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import { type FileListingWithStatus, MediaFormat, type Metadata } from "music-server-shared/types";
import {
AudioCodec,
type FileListingWithStatus,
MediaContainer,
type Metadata,
VideoCodec,
} from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import {
coverRegex,
excludeExtension,
fileTypeCache,
mediaInfoDisabled,
mediaTypes,
musicRoot,
PathInfo,
@@ -16,6 +23,7 @@ import {
import { parseFile } from "music-metadata";
import tmp from "tmp";
import { readFileSync, writeFileSync } from "node:fs";
import MediaInfo from "mediainfo.js";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
@@ -26,6 +34,8 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
const mediaInfo = await MediaInfo();
export async function findCover(targetPath: string): Promise<CoverResult> {
try {
if ((await stat(targetPath)).isFile()) {
@@ -66,35 +76,40 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
}
export interface ProbeData extends Metadata {
bitrate?: number;
}
export async function probeFile(filePath: string): Promise<ProbeData> {
export async function probeFile(filePath: string): Promise<Metadata> {
const cached = probeCache.get(filePath);
if (cached) return cached;
const parsed = await parseFile(filePath, { duration: true, skipCovers: false });
const probeData = {
let probeData: Metadata = {
duration: parsed.format.duration || 0,
bitrate: parsed.format.bitrate,
title: parsed.common.title,
artist: parsed.common.artist,
album: parsed.common.album,
genre: parsed.common.genre,
trackNumber: parsed.common.track.no || undefined,
// embeddedCover: parsed.common.picture
// ? `data:${parsed.common.picture[0].format};base64,${Buffer.from(parsed.common.picture[0].data).toString("base64")}`
// : undefined,
};
if (!mediaInfoDisabled) {
const file = Bun.file(filePath);
const fileSize = (await file.stat()).size;
const chunkReader = async (size: number, offset: number) => {
return await file.slice(offset, offset + size).bytes();
};
const info = await mediaInfo.analyzeData(fileSize, chunkReader);
probeData = {
...probeData,
audioCodec: info.media?.track.find((track) => track["@type"] === "Audio")?.Format,
audioBitrate: info.media?.track.find((track) => track["@type"] === "Audio")?.BitRate,
videoCodec: info.media?.track.find((track) => track["@type"] === "Video")?.Format,
videoBitrate: info.media?.track.find((track) => track["@type"] === "Video")?.BitRate,
container: info.media?.track.find((track) => track["@type"] === "General")?.Format,
};
}
probeCache.set(filePath, probeData);
return probeData;
}
function probeDataToMetadata(probeData: ProbeData): Metadata {
const { bitrate, ...metadata } = { ...probeData };
return metadata;
}
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
try {
if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
@@ -156,7 +171,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
} else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
try {
const probe = await probeFile(itemPath);
files.set(fileName, { metadata: probeDataToMetadata(probe) });
files.set(fileName, { metadata: probe });
} catch (probeError) {
console.error("probing error:", probeError);
}
@@ -220,52 +235,101 @@ function secondsToFfmpeg(seconds: number): string {
return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`;
}
export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat, seekTo: number | undefined) {
let codec = undefined;
let container = undefined;
export function convertWithFFmpeg(
filePath: string,
audioBitrate: number,
videoBitrate: number,
container: MediaContainer,
requestedAudioCodec: AudioCodec,
requestedVideoCodec: VideoCodec,
seekTo: number | undefined,
) {
let mimeType = undefined;
switch (format) {
case MediaFormat["opus/ogg"]: {
codec = "libopus";
container = "ogg";
mimeType = 'audio/ogg; codecs="opus"';
let ffmpegContainer = undefined;
switch (container) {
case MediaContainer.webm: {
ffmpegContainer = "webm";
mimeType = "video/webm";
break;
}
case MediaFormat["opus/webm"]: {
codec = "libopus";
container = "webm";
mimeType = 'audio/webm; codecs="opus"';
case MediaContainer.mp3: {
ffmpegContainer = "mp3";
mimeType = "audio/mpeg";
break;
}
case MediaFormat.mp3: {
codec = "libmp3lame";
container = "mp3";
mimeType = "audio/mpeg";
case MediaContainer.ogg: {
ffmpegContainer = "ogg";
mimeType = "audio/ogg";
break;
}
default: {
throw new Error(`Unsupported container: ${container}`);
}
}
let audioCodec = undefined;
switch (requestedAudioCodec) {
case AudioCodec.none: {
break;
}
case MediaFormat["vorbis/ogg"]: {
codec = "libvorbis";
container = "ogg";
mimeType = 'audio/ogg; codecs="vorbis"';
case AudioCodec.mp3: {
audioCodec = "libmp3lame";
break;
}
case MediaFormat["vorbis/webm"]: {
codec = "libvorbis";
container = "webm";
mimeType = 'audio/webm; codecs="vorbis"';
case AudioCodec.opus: {
audioCodec = "libopus";
break;
}
case AudioCodec.vorbis: {
audioCodec = "libvorbis";
break;
}
default: {
throw new Error(`Unsupported format: ${format}`);
throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`);
}
}
let videoCodec = undefined;
switch (requestedVideoCodec) {
case VideoCodec.none: {
break;
}
case VideoCodec.av1: {
videoCodec = "librav1e"; //TODO: support hardware encoding
break;
}
case VideoCodec.theora: {
videoCodec = "libtheora";
break;
}
case VideoCodec.vp8: {
videoCodec = "libvpx";
break;
}
case VideoCodec.vp9: {
videoCodec = "libvpx-vp9";
break;
}
default: {
throw new Error(`Unsupported video codec: ${requestedVideoCodec}`);
}
}
mimeType += `; codecs="${[requestedVideoCodec, requestedAudioCodec]
.filter((codec) => {
codec !== "none";
})
.join(", ")}`; //TODO: add RFC6381 compliance
const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
const cmd = Bun.spawn(
["ffmpeg", "-i", filePath]
.concat(seekParams)
.concat(["-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"]),
//.concat(videoCodec ? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] : ["-vn"]) //TODO
.concat(videoCodec ? ["-c:v", videoCodec, "-b:v", "1000000"] : ["-vn"])
.concat(audioCodec ? ["-c:a", audioCodec, "-b:a", audioBitrate.toString()] : ["-an"])
.concat(["-f", ffmpegContainer, "-"]),
{
stdin: "ignore",
stdout: "pipe",
▾Mshared/types.ts
@@ -4,7 +4,12 @@ export interface Metadata {
title?: string;
artist?: string;
album?: string;
genre?: string[]
genre?: string[];
audioCodec?: string;
audioBitrate?: number;
videoCodec?: string;
videoBitrate?: number;
container?: string;
}
export interface MediaFile {
@@ -27,10 +32,30 @@ export interface DirectoryWithStatus {
export type FileListing = Map<string, Directory | MediaFile>;
export type FileListingWithStatus = Map<string, DirectoryWithStatus | MediaFile>;
export enum MediaFormat {
"opus/ogg" = "opus/ogg",
"opus/webm" = "opus/webm",
export enum MediaContainer {
mp3 = "mp3",
"vorbis/ogg" = "vorbis/ogg",
"vorbis/webm" = "vorbis/webm",
}
webm = "webm",
ogg = "ogg",
}
export enum VideoCodec {
none = "none",
av1 = "av1",
vp8 = "vp8",
vp9 = "vp9",
theora = "theora",
}
export enum AudioCodec {
none = "none",
mp3 = "mp3",
opus = "opus",
vorbis = "vorbis",
}
export enum VideoEncodingSetting {
veryfast = "veryfast",
fast = "fast",
balanced = "balanced",
quality = "quality",
}
▾Mwebclient/src/App.tsx
@@ -16,7 +16,14 @@ import {
import { formatFilename, LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getCover, getSrc, listFiles } from "./offline";
import { type Directory, type MediaFile, MediaFormat } from "music-server-shared/types";
import {
AudioCodec,
MediaContainer,
type Directory,
type MediaFile,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore, reconcile } from "solid-js/store";
@@ -24,7 +31,6 @@ import { decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
import { SignIn } from "./components/SignIn";
//TODO: add bitrate+format to metadata
//TODO: more file display options
//TODO: keep synced-aborted in downloadmanager
//TODO: bypass music-metadata if it fails
@@ -57,8 +63,12 @@ const App: Component = () => {
//default settings
enableTranscoding: true,
disableChunkedTranscoding: false,
bitrate: 128000,
format: MediaFormat["opus/webm"],
audioBitrate: 128000, //128kbit
videoBitrate: 1500000, //1.5mbit
container: MediaContainer.webm,
audioCodec: AudioCodec.opus,
videoCodec: VideoCodec.vp9,
videoEncodingSettings: VideoEncodingSetting.fast,
maxTranscodes: 4,
maxDownloads: 2,
transcodeCovers: false,
@@ -360,7 +370,7 @@ const App: Component = () => {
function updateTime() {
setPlayerState("currentTime", audioElement.currentTime);
//also call updateProgress here, because some browsers send the "progress" events too slow
updateProgress()
updateProgress();
}
function updateProgress() {
setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered)));
▾Mwebclient/src/components/OptionsModal.tsx
@@ -1,11 +1,11 @@
import { type Accessor, createEffect, type Setter, on, createSignal, Switch, Match } from "solid-js";
import { type Accessor, createEffect, type Setter, on, createSignal, Switch, Match, For } from "solid-js";
import { FileDisplay, type AppOptions } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { toast } from "../utils";
import { MediaFormat } from "music-server-shared/types";
import { createStore } from "solid-js/store";
import { MediaContainer, VideoCodec, AudioCodec, VideoEncodingSetting } from "music-server-shared/types";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -14,6 +14,43 @@ interface OptionsModalProps {
setOptions: (options: AppOptions) => void;
}
function supportedVideoCodecs(container: MediaContainer) {
let codecs: VideoCodec[] = [];
switch (container) {
case MediaContainer.mp3:
break;
case MediaContainer.webm:
codecs = [VideoCodec.av1, VideoCodec.vp8, VideoCodec.vp9];
break;
case MediaContainer.ogg:
codecs = [VideoCodec.theora];
break;
default: {
throw new Error(`Unsupported container: ${container}`);
}
}
return codecs.concat([VideoCodec.none]);
}
function supportedAudioCodecs(container: MediaContainer) {
let codecs: AudioCodec[] = [];
switch (container) {
case MediaContainer.mp3:
codecs = [AudioCodec.mp3];
break;
case MediaContainer.webm:
codecs = [AudioCodec.opus, AudioCodec.vorbis];
break;
case MediaContainer.ogg:
codecs = [AudioCodec.opus, AudioCodec.vorbis];
break;
default: {
throw new Error(`Unsupported container: ${container}`);
}
}
return codecs.concat([AudioCodec.none]);
}
export default function OptionsModal(props: OptionsModalProps) {
//clone, otherwise it shares the reference
const [newOptions, setNewOptions] = createStore<AppOptions>({ ...props.options });
@@ -109,11 +146,11 @@ export default function OptionsModal(props: OptionsModalProps) {
/>
</div>
<div>
<label for="bitrate-select">Bitrate:</label>
<label for="audio-bitrate-select">Audio bitrate:</label>
<select
id="bitrate-select"
value={newOptions.bitrate}
onChange={(e) => setNewOptions("bitrate", Number.parseInt(e.target.value))}
id="audio-bitrate-select"
value={newOptions.audioBitrate}
onChange={(e) => setNewOptions("audioBitrate", Number.parseInt(e.target.value))}
disabled={!newOptions.enableTranscoding}
>
<option value="32000">32 kbps</option>
@@ -126,18 +163,74 @@ export default function OptionsModal(props: OptionsModalProps) {
</select>
</div>
<div>
<label for="codec-select">Format:</label>
<label for="video-bitrate-select">Video bitrate:</label>
<select
id="video-bitrate-select"
value={newOptions.videoBitrate}
onChange={(e) => setNewOptions("videoBitrate", Number.parseInt(e.target.value))}
disabled={!newOptions.enableTranscoding}
>
<option value="0">0 kbps</option>
</select>
</div>
<div>
<label for="container-select">Container:</label>
<select
id="container-select"
value={newOptions.container}
onChange={(e) => setNewOptions("container", e.target.value as MediaContainer)}
disabled={!newOptions.enableTranscoding}
>
<For each={Object.values(MediaContainer)}>
{(format) => {
return <option value={format}>{format}</option>;
}}
</For>
</select>
</div>
<div>
<label for="audio-codec-select">Audio codec:</label>
<select
id="audio-codec-select"
value={newOptions.audioCodec}
onChange={(e) => setNewOptions("audioCodec", e.target.value as AudioCodec)}
disabled={!newOptions.enableTranscoding}
>
<For each={supportedAudioCodecs(newOptions.container)}>
{(codec) => {
return <option value={codec}>{codec}</option>;
}}
</For>
</select>
</div>
<div>
<label for="video-codec-select">Audio codec:</label>
<select
id="video-codec-select"
value={newOptions.videoCodec}
onChange={(e) => setNewOptions("videoCodec", e.target.value as VideoCodec)}
disabled={!newOptions.enableTranscoding}
>
<For each={supportedVideoCodecs(newOptions.container)}>
{(codec) => {
return <option value={codec}>{codec}</option>;
}}
</For>
</select>
</div>
<div>
<label for="video-settings-select">Video encoding settings:</label>
<select
id="codec-select"
value={newOptions.format}
onChange={(e) => setNewOptions("format", e.target.value as MediaFormat)}
id="video-settings-select"
value={newOptions.videoEncodingSettings}
onChange={(e) => setNewOptions("videoEncodingSettings", e.target.value as VideoEncodingSetting)}
disabled={!newOptions.enableTranscoding}
>
<option>{MediaFormat["opus/ogg"]}</option>
<option>{MediaFormat["opus/webm"]}</option>
<option>{MediaFormat.mp3}</option>
<option>{MediaFormat["vorbis/ogg"]}</option>
<option>{MediaFormat["vorbis/webm"]}</option>
<For each={Object.values(VideoEncodingSetting)}>
{(setting) => {
return <option value={setting}>{setting}</option>;
}}
</For>
</select>
</div>
<div>
@@ -163,15 +256,6 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("maxDownloads", e.target.valueAsNumber)}
/>
</div>
<div>
<label for="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Match>
<Match when={optionsPage() === 2}>
<div>
@@ -290,6 +374,15 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("duplicateWarning", e.target.checked)}
/>
</div>
<div>
<label for="transcode-covers-checkbox">Transcode covers to avif:</label>
<input
id="transcode-covers-checkbox"
type="checkbox"
checked={newOptions.transcodeCovers}
onChange={(e) => setNewOptions("transcodeCovers", e.target.checked)}
/>
</div>
</Match>
</Switch>
<div class="option-buttons">
▾Mwebclient/src/components/PlayerControls.tsx
@@ -160,6 +160,11 @@ export default function PlayerControls(props: PlayerControlsProps) {
);
}
function bitrateDisplay(bitrate: number | undefined, divisor: number) {
if (!bitrate) return undefined;
return bitrate / divisor;
}
return (
<>
<dialog ref={metadataDialogRef} onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}>
@@ -180,6 +185,19 @@ export default function PlayerControls(props: PlayerControlsProps) {
{MetadataEntry("Artist", props.playerState.currentTrack?.metadata.artist)}
{MetadataEntry("Genre", props.playerState.currentTrack?.metadata.genre?.join(", "))}
{MetadataEntry("Duration", props.playerState.currentTrack?.metadata.duration.toFixed(1), " seconds")}
{MetadataEntry("Container format", props.playerState.currentTrack?.metadata.container)}
{MetadataEntry("Audio codec", props.playerState.currentTrack?.metadata.audioCodec)}
{MetadataEntry(
"Audio bitrate",
bitrateDisplay(props.playerState.currentTrack?.metadata.audioBitrate, 1000),
" kbps",
)}
{MetadataEntry("Video codec", props.playerState.currentTrack?.metadata.videoCodec)}
{MetadataEntry(
"Video bitrate",
bitrateDisplay(props.playerState.currentTrack?.metadata.videoBitrate, 1000 * 1000),
" mbps",
)}
</div>
</div>
</dialog>
▾Mwebclient/src/offline.ts
@@ -368,9 +368,12 @@ export async function getSrc(path: string, options: AppOptions, offline: boolean
if (options.enableTranscoding) {
return (
`${joinPath("/transcode", encodePath(path))}` +
`?bitrate=${options.bitrate}` +
`&format=${options.format}` +
`&disableChunkedTranscoding=${options.disableChunkedTranscoding}` +
`?disableChunkedTranscoding=${options.disableChunkedTranscoding}` +
`&container=${options.container}` +
`&audioCodec=${options.audioCodec}` +
`&audioBitrate=${options.audioBitrate}` +
`&videoCodec=${options.videoCodec}` +
`&videoBitrate=${options.videoBitrate}` +
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}`
);
}
▾Mwebclient/src/types.ts
@@ -1,4 +1,4 @@
import type { MediaFile, MediaFormat } from "music-server-shared/types";
import type { AudioCodec, MediaContainer, MediaFile, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
export interface PlaylistItem extends MediaFile {
id: string; //used for determining currently playing item
@@ -29,8 +29,12 @@ export enum FileDisplay {
export interface AppOptions {
enableTranscoding: boolean;
disableChunkedTranscoding: boolean;
bitrate: number;
format: MediaFormat;
audioBitrate: number;
videoBitrate: number;
container: MediaContainer;
audioCodec: AudioCodec;
videoCodec: VideoCodec;
videoEncodingSettings: VideoEncodingSetting;
maxTranscodes: number;
maxDownloads: number;
transcodeCovers: boolean;