small refactoring
Mserver/src/ffmpeg.ts
@@ -1,5 +1,5 @@
import { AudioCodec, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createRFC6381String, ladderMaxWidth, type VideoStreamInfo } from "music-server-shared/utils";
import { createRFC6381String, ladderMaxWidth, videoStreamInfo } from "music-server-shared/utils";
const KEYFRAME_INTERVAL_SECONDS = 5;
@@ -122,17 +122,8 @@ export async function convertWithFFmpeg(
languages: string,
metadata: Metadata,
) {
const videoStreamInfo: VideoStreamInfo | undefined =
metadata.videoWidth && metadata.videoHeight && metadata.frameRate
? {
sourceWidth: metadata.videoWidth,
sourceHeight: metadata.videoHeight,
frameRate: metadata.frameRate,
interlaced: metadata.interlaced === true,
bitrate: videoBitrate,
}
: undefined;
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec, videoStreamInfo);
const video = videoStreamInfo(metadata, videoBitrate);
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec, video);
const audioEncoder = lookup(AUDIO_ENCODERS, requestedAudioCodec, "audio codec");
const hasVideo = requestedVideoCodec !== VideoCodec.none;
Mserver/src/index.ts
@@ -66,6 +66,12 @@ async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string
type FileHandlerContext = Context<{ params: { "*": string } }>;
//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
function clampToSource(requested: number | undefined, source: number | undefined): number | undefined {
return requested && source ? Math.min(requested, source) : requested;
}
function transcodeFailed(set: FileHandlerContext["set"], exitCode: number | null, stderr: string) {
set.status = "Internal Server Error";
//the first lines are the root cause; what follows is each thread unwinding and reporting the same
@@ -180,14 +186,8 @@ const app = setup
const { filePath, info: fileScan } = resolved;
const probe = await probeFile(filePath);
//don't use higher bitrate than what the file has, use requested bitrate if unknown
const audioBitrate =
query.audioBitrate && probe.audioBitrate
? Math.min(query.audioBitrate, probe.audioBitrate)
: query.audioBitrate;
const videoBitrate =
probe.videoBitrate && query.videoBitrate
? Math.min(query.videoBitrate, probe.videoBitrate)
: query.videoBitrate;
const audioBitrate = clampToSource(query.audioBitrate, probe.audioBitrate);
const videoBitrate = clampToSource(query.videoBitrate, probe.videoBitrate);
if (!matchesType(fileScan.mimeType, mediaTypes)) {
set.status = "Temporary Redirect";
Mwebclient/src/App.tsx
@@ -6,7 +6,7 @@ import {
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import { createRFC6381String, decodePath, encodePath, videoStreamInfo } from "music-server-shared/utils";
import type { Component } from "solid-js";
import { batch, createEffect, createResource, createSignal, on, onMount, Show } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
@@ -292,34 +292,15 @@ const App: Component = () => {
let mediaSource: MediaSource | null = null;
let mediaSourceAbortController: AbortController | null = null;
async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
let mimeType: string;
if (isVideo) {
const metadata = playerState.currentTrack?.metadata;
const videoStreamInfo =
metadata?.videoWidth && metadata.videoHeight && metadata.frameRate
? {
sourceWidth: metadata.videoWidth,
sourceHeight: metadata.videoHeight,
frameRate: metadata.frameRate,
interlaced: metadata.interlaced === true,
bitrate: metadata.videoBitrate
? Math.min(options.videoSettings.videoBitrate, metadata.videoBitrate)
: options.videoSettings.videoBitrate,
}
: undefined;
mimeType = createRFC6381String(
options.videoSettings.container,
options.videoSettings.videoCodec,
options.videoSettings.audioCodec,
videoStreamInfo,
);
} else {
mimeType = createRFC6381String(
options.audioSettings.container,
VideoCodec.none,
options.audioSettings.audioCodec,
);
}
const settings = isVideo ? options.videoSettings : options.audioSettings;
const mimeType = createRFC6381String(
settings.container,
isVideo ? options.videoSettings.videoCodec : VideoCodec.none,
settings.audioCodec,
isVideo
? videoStreamInfo(playerState.currentTrack?.metadata, options.videoSettings.videoBitrate)
: undefined,
);
if (!MediaSource.isTypeSupported(mimeType)) return null;
mediaSource = new MediaSource();
Mwebclient/src/capabilities.ts
@@ -51,11 +51,12 @@ const CONTAINER_VIDEO_CODECS: Record<MediaContainer, VideoCodec[]> = {
};
export function containerAudioCodecs(container: MediaContainer): AudioCodec[] {
return CONTAINER_AUDIO_CODECS[container] ?? [];
return CONTAINER_AUDIO_CODECS[container];
}
//"none" is always an option: every container can carry audio alone
export function containerVideoCodecs(container: MediaContainer): VideoCodec[] {
return [...(CONTAINER_VIDEO_CODECS[container] ?? []), VideoCodec.none];
return [...CONTAINER_VIDEO_CODECS[container], VideoCodec.none];
}
//actual values don't seem to matter, besides for a possible "smoothnes" verdict
@@ -87,9 +88,10 @@ function progressivelyPlayable(mimeType: string): boolean {
const decodingCache = new Map<string, { supported: boolean; smooth: boolean }>();
const pending = new Map<string, Promise<void>>();
function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration) {
const existing = pending.get(key);
if (existing) return existing;
//the same key really does come up twice: progressive and buffered are both type "file", so initCapabilities
//enumerates every one of these configurations a second time
function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration): void {
if (pending.has(key)) return;
const probe = navigator.mediaCapabilities
.decodingInfo(configuration)
.then((info) => {
@@ -104,7 +106,6 @@ function startDecodingProbe(key: string, configuration: MediaDecodingConfigurati
pending.delete(key);
});
pending.set(key, probe);
return probe;
}
export function checkSupport(combination: Combination): Support {
@@ -161,7 +162,7 @@ export function checkSupport(combination: Combination): Support {
const key = `${configuration.type}|${configuration.audio?.contentType}|${configuration.video?.contentType}|${video?.bitrate}`;
const cached = decodingCache.get(key);
if (!cached) {
void startDecodingProbe(key, configuration);
startDecodingProbe(key, configuration);
return { supported: true };
}
if (!cached.supported) return { supported: false, reason: `the browser's decoders reject ${mimeType}` };
@@ -183,13 +184,13 @@ function checkContainer(combination: Combination, videoCodecs: VideoCodec[]): Su
return firstFailure ?? { supported: false, reason: `${combination.container} offers no usable codecs` };
}
export function checkVideoContainerSupport(combination: Combination): Support {
return checkContainer(combination, containerVideoCodecs(combination.container));
}
//an audio-only profile must not be told mp4 works because mp4 video works, only because mp4 audio does
export function checkAudioContainerSupport(combination: Combination): Support {
return checkContainer(combination, [VideoCodec.none]);
//an audio-only profile must not be told mp4 works because mp4 video works, only because mp4 audio does -
//and an absent videoCodec is exactly what makes a profile audio-only
export function checkContainerSupport(combination: Combination): Support {
return checkContainer(
combination,
combination.videoCodec === undefined ? [VideoCodec.none] : containerVideoCodecs(combination.container),
);
}
function usableAudioCodecs(combination: Combination): AudioCodec[] {
@@ -205,8 +206,9 @@ function usableVideoCodecs(combination: Combination): VideoCodec[] {
}
function usableContainers(combination: Combination): MediaContainer[] {
const check = combination.videoCodec === undefined ? checkAudioContainerSupport : checkVideoContainerSupport;
return Object.values(MediaContainer).filter((container) => check({ ...combination, container }).supported);
return Object.values(MediaContainer).filter(
(container) => checkContainerSupport({ ...combination, container }).supported,
);
}
//used to decide whether the video section is worth showing at all - it is not when nothing but "none" would
@@ -256,7 +258,8 @@ export function playableSettings<T extends Combination>(settings: T): T {
}
export async function initCapabilities(): Promise<void> {
for (const streamingMode of Object.values(StreamingMode)) {
//buffered and progressive have the same support ("file"), so only one check needed
for (const streamingMode of [StreamingMode.mse, StreamingMode.progressive]) {
for (const container of Object.values(MediaContainer)) {
for (const audioCodec of containerAudioCodecs(container)) {
for (const videoCodec of containerVideoCodecs(container)) {
Mwebclient/src/components/OptionsModal.tsx
@@ -1,4 +1,4 @@
import { type Accessor, batch, createEffect, createSignal, For, Match, on, type Setter, Show, Switch } from "solid-js";
import { type Accessor, createEffect, createSignal, For, Match, on, type Setter, Show, Switch } from "solid-js";
import { type AppOptions, AUDIO_BITRATES, FileDisplay, StreamingMode, VIDEO_BITRATES } from "../types";
import "./OptionsModal.css";
import "./Dialog.css";
@@ -7,9 +7,8 @@ import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "mu
import { audioCodecHasBitrate } from "music-server-shared/utils";
import { createStore } from "solid-js/store";
import {
checkAudioContainerSupport,
checkContainerSupport,
checkSupport,
checkVideoContainerSupport,
type Combination,
containerAudioCodecs,
containerVideoCodecs,
@@ -57,21 +56,11 @@ export default function OptionsModal(props: OptionsModalProps) {
let dialogElement!: HTMLDialogElement;
let formElement!: HTMLFormElement;
const videoSupport = () => checkSupport(newOptions.videoSettings);
const audioSupport = () => checkSupport(newOptions.audioSettings);
//keep the selection playable, so the greyed-out entries in the lists are only ever ones the user could
//pick and never the one they are already on. writing a value a store already holds notifies nobody, so
//once the selection is playable this re-runs, finds the same values and settles
//pick and never the one they are already on. the store setter merges key by key and skips values it
//already holds, so a settled selection writes nothing, notifies nobody and this re-run is the last
function coerceProfile(profile: Profile) {
const playable = playableSettings<Combination>(newOptions[profile]);
batch(() => {
setNewOptions(profile, "streamingMode", playable.streamingMode);
setNewOptions(profile, "container", playable.container);
setNewOptions(profile, "audioCodec", playable.audioCodec);
//only videoSettings has a video codec, which is exactly when playableSettings returns one
if (playable.videoCodec !== undefined) setNewOptions("videoSettings", "videoCodec", playable.videoCodec);
});
setNewOptions(profile, playableSettings<Combination>(newOptions[profile]));
}
createEffect(() => coerceProfile("videoSettings"));
createEffect(() => coerceProfile("audioSettings"));
@@ -355,7 +344,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<label for="container-select">Container:</label>
<select
id="container-select"
title={videoSupport().reason}
title={checkSupport(newOptions.videoSettings).reason}
value={newOptions.videoSettings.container}
onChange={(e) => setNewOptions("videoSettings", "container", e.target.value as MediaContainer)}
>
@@ -363,7 +352,7 @@ export default function OptionsModal(props: OptionsModalProps) {
{(format) => (
<SupportedOption
value={format}
support={checkVideoContainerSupport({ ...newOptions.videoSettings, container: format })}
support={checkContainerSupport({ ...newOptions.videoSettings, container: format })}
/>
)}
</For>
@@ -471,7 +460,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<label for="music-container-select">Container:</label>
<select
id="music-container-select"
title={audioSupport().reason}
title={checkSupport(newOptions.audioSettings).reason}
value={newOptions.audioSettings.container}
onChange={(e) => setNewOptions("audioSettings", "container", e.target.value as MediaContainer)}
>
@@ -479,7 +468,7 @@ export default function OptionsModal(props: OptionsModalProps) {
{(format) => (
<SupportedOption
value={format}
support={checkAudioContainerSupport({ ...newOptions.audioSettings, container: format })}
support={checkContainerSupport({ ...newOptions.audioSettings, container: format })}
/>
)}
</For>