* refactor(web): consolidate media-spec formatters into lib/mediaFormat
Media-spec formatting was duplicated with divergent behavior across six
modules: versionFormatUtils (GB/"" fallback), playback-info (GiB/"—"
fallback, CODEC_LABELS), overlay tech.ts (prettyResolution,
compactHdrSuffix), versionRankingUtils (mapAudioLabel), plus private
formatBitrate copies in ItemCard and useTranscodeQuality.
One canonical module now owns these helpers; the deliberate per-surface
display differences (fallback text, GB vs GiB labels) are parameters
instead of copies. All call sites migrated, including two cross-boundary
imports (adminActivityPresentation reaching into @/player/playback-info,
player components reaching into page-level utils). The quality-menu Mbps
label in useTranscodeQuality stays local as a distinct display policy,
renamed formatQualityBitrate. Also removes the unused TrackSection/
TrackRow components and renames versionFormatUtils.tsx to .ts.
Behavior-preserving: playback-info.test.ts and VersionFlyout.test.ts are
unmodified and assert exact rendered strings ("7.1 GiB", "640 kbps",
"Dolby Vision Profile 8.1 (HDR10)").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): add Media Info dialog with detailed per-file technical specs
Adds a curator/admin-gated "Media Info" dialog to movie and episode
detail pages showing full technical specs per file: per-stream bitrates,
Dolby Vision profile with base-layer compatibility and EL flags, HDR10+,
chroma subsampling derived from pixel format, color primaries/transfer/
space, bit depth, codec profile/level (H.264, HEVC, and AV1 level
decoding), audio layouts/sample rates, and subtitle stream details.
Frontend-only: the item-detail response already serialized all of this;
VersionVideoTrack in api/types.ts was just missing the DV/HDR10+/
video_range_type fields the backend sends. Entry points: a "Media Info"
item in the detail-page overflow menu (opens on the selected version)
and a per-row info button in Media locations (opens on that file). All
versions render as accordion sections with the target expanded.
Spec rows are built by pure section builders in mediaSpecSections.ts
with fixture tests covering SDR/HDR10/DV 7/DV 8.1/HDR10+/multi-track/
external-subtitle cases; the dialog is a thin renderer over them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): recover DV compatibility detail from video_range_type
Review follow-ups on the Media Info dialog's dynamic-range labels:
- When ffprobe omits dv_bl_signal_compatibility_id, the scanner still
derives video_range_type (DOVIWithHDR10/HLG/SDR) from color_transfer.
formatDolbyVisionLabel now falls back to that enum for the
compatibility detail instead of showing a bare "Dolby Vision
Profile 8"; explicit DV side-data fields still win when present.
- formatVideoRangeLabel checks the video_range_type mapping before the
standalone HDR10+ shortcut, so DOVIWithHDR10Plus tracks without a raw
dolby_vision string keep their Dolby Vision hint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
316 lines
8.9 KiB
TypeScript
316 lines
8.9 KiB
TypeScript
import type { AdminSession } from "@/api/types";
|
|
import { formatCodecLabel } from "@/lib/mediaFormat";
|
|
|
|
export function formatDecisionLabel(decision?: string): string {
|
|
switch (decision) {
|
|
case "direct":
|
|
return "Direct";
|
|
case "copy":
|
|
return "Copy";
|
|
case "remux":
|
|
return "Remux";
|
|
case "hls":
|
|
return "HLS";
|
|
case "transcode":
|
|
return "Transcode";
|
|
default:
|
|
return "Unknown";
|
|
}
|
|
}
|
|
|
|
export function normalizeContainerDecision(playMethod?: string): string {
|
|
switch (playMethod?.trim()) {
|
|
case "direct":
|
|
return "direct";
|
|
case "remux":
|
|
return "remux";
|
|
case "transcode":
|
|
case "hls":
|
|
return "hls";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function normalizeStreamDecision(decision?: string): string {
|
|
switch (decision?.trim()) {
|
|
case "direct":
|
|
return "direct";
|
|
case "copy":
|
|
case "remux":
|
|
return "copy";
|
|
case "transcode":
|
|
return "transcode";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function formatPlaybackDecisionSummary(session: AdminSession): string {
|
|
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
|
|
const audioDecision = normalizeStreamDecision(
|
|
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
|
);
|
|
|
|
if (videoDecision && videoDecision === audioDecision) {
|
|
return videoDecision;
|
|
}
|
|
if (videoDecision === "transcode" || audioDecision === "transcode") {
|
|
return "transcode";
|
|
}
|
|
if (videoDecision === "copy" || audioDecision === "copy") {
|
|
return "copy";
|
|
}
|
|
return videoDecision || audioDecision || session.play_method || "";
|
|
}
|
|
|
|
export function formatTranscodeModeSummary(session: AdminSession): string | null {
|
|
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
|
|
const audioDecision = normalizeStreamDecision(
|
|
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
|
);
|
|
if (videoDecision !== "transcode" && audioDecision !== "transcode") {
|
|
return null;
|
|
}
|
|
if (videoDecision !== "transcode") {
|
|
return "Audio SW";
|
|
}
|
|
|
|
const hwAccel = session.transcode_hw_accel?.trim().toLowerCase();
|
|
switch (hwAccel) {
|
|
case "qsv":
|
|
return "HW QSV";
|
|
case "vaapi":
|
|
return "HW VAAPI";
|
|
case "none":
|
|
return "SW";
|
|
case "auto":
|
|
return "HW/SW pending";
|
|
case "":
|
|
case undefined:
|
|
return "HW/SW unknown";
|
|
default:
|
|
return `HW ${hwAccel.toUpperCase()}`;
|
|
}
|
|
}
|
|
|
|
export function formatSessionBitrate(kbps?: number | null): string | null {
|
|
if (!kbps || kbps <= 0) {
|
|
return null;
|
|
}
|
|
if (kbps >= 1000) {
|
|
return `${(kbps / 1000).toFixed(1)} Mbps`;
|
|
}
|
|
return `${Math.round(kbps)} kbps`;
|
|
}
|
|
|
|
export function getSessionClientLabel(session: AdminSession): string {
|
|
const explicitLabel = session.client_label?.trim();
|
|
if (explicitLabel) {
|
|
return explicitLabel;
|
|
}
|
|
|
|
const clientName = session.client_name?.trim();
|
|
const clientVersion = session.client_version?.trim();
|
|
if (clientName && clientVersion) {
|
|
return `${clientName} ${clientVersion}`;
|
|
}
|
|
return clientName || "";
|
|
}
|
|
|
|
export function formatSourceContainerSummary(session: AdminSession): string {
|
|
return formatContainer(session.source_container) || "Unknown source";
|
|
}
|
|
|
|
export function formatDeliveredContainerSummary(session: AdminSession): string {
|
|
switch (normalizeContainerDecision(session.play_method)) {
|
|
case "direct":
|
|
return formatSourceContainerSummary(session);
|
|
case "remux":
|
|
return "Remux";
|
|
case "hls":
|
|
return "HLS";
|
|
default:
|
|
return formatSourceContainerSummary(session);
|
|
}
|
|
}
|
|
|
|
export function formatContainerDetail(session: AdminSession): string {
|
|
const source = formatSourceContainerSummary(session);
|
|
switch (normalizeContainerDecision(session.play_method)) {
|
|
case "direct":
|
|
return "Original container";
|
|
case "remux":
|
|
return `${source} → Remux`;
|
|
case "hls":
|
|
return `${source} → HLS`;
|
|
default:
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
export function formatVideoSummary(session: AdminSession): string {
|
|
return (
|
|
[formatCodec(session.source_video_codec), session.source_video_resolution?.trim()]
|
|
.filter(Boolean)
|
|
.join(" · ") || "Unknown source"
|
|
);
|
|
}
|
|
|
|
export function formatDeliveredVideoSummary(session: AdminSession): string {
|
|
const decision = session.video_decision || session.play_method;
|
|
if (decision !== "transcode") {
|
|
return formatVideoSummary(session);
|
|
}
|
|
|
|
return (
|
|
[formatCodec(session.target_video_codec), session.target_resolution?.trim()]
|
|
.filter(Boolean)
|
|
.join(" · ") || "Transcoding"
|
|
);
|
|
}
|
|
|
|
export function formatVideoDetail(session: AdminSession): string {
|
|
const decision = normalizeStreamDecision(session.video_decision || session.play_method);
|
|
const requestedSource = formatRequestedVideoSource(session);
|
|
const target = [formatCodec(session.target_video_codec), session.target_resolution?.trim()]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
|
|
if (hasRequestedSourceSwitch(session) && requestedSource) {
|
|
const parts = [`Auto-switched from ${requestedSource}`];
|
|
if (target) {
|
|
parts.push(`Output → ${target}`);
|
|
} else if (decision === "transcode") {
|
|
parts.push("Transcoding");
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
if (decision === "transcode") {
|
|
return target ? `Output → ${target}` : "Transcoding";
|
|
}
|
|
if (decision === "copy") {
|
|
return "Video stream copied";
|
|
}
|
|
if (decision === "direct") {
|
|
return "No video conversion";
|
|
}
|
|
return "—";
|
|
}
|
|
|
|
export function formatAudioSummary(session: AdminSession): string {
|
|
const lead = session.source_audio_title?.trim() || session.source_audio_language?.trim();
|
|
const format = [
|
|
formatCodec(session.source_audio_codec),
|
|
formatChannelLayout(session.source_audio_channels),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return [lead, format].filter(Boolean).join(" · ") || "Unknown source";
|
|
}
|
|
|
|
export function formatDeliveredAudioSummary(session: AdminSession): string {
|
|
const decision =
|
|
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method);
|
|
if (decision !== "transcode") {
|
|
return formatAudioSummary(session);
|
|
}
|
|
|
|
return (
|
|
[
|
|
formatCodec(session.target_audio_codec || "aac"),
|
|
formatChannelLayout(session.source_audio_channels),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ") || "Audio transcode"
|
|
);
|
|
}
|
|
|
|
export function formatAudioDetail(session: AdminSession): string {
|
|
const decision = normalizeStreamDecision(
|
|
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
|
);
|
|
if (decision === "transcode") {
|
|
const target = [
|
|
formatCodec(session.target_audio_codec || "aac"),
|
|
formatChannelLayout(session.source_audio_channels),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return target ? `→ ${target}` : "Audio transcode";
|
|
}
|
|
if (decision === "copy") {
|
|
return "Audio stream copied";
|
|
}
|
|
if (decision === "direct") {
|
|
return "No audio conversion";
|
|
}
|
|
return "—";
|
|
}
|
|
|
|
function hasRequestedSourceSwitch(session: AdminSession): boolean {
|
|
return (
|
|
session.requested_media_file_id > 0 &&
|
|
session.media_file_id > 0 &&
|
|
session.requested_media_file_id !== session.media_file_id
|
|
);
|
|
}
|
|
|
|
function formatRequestedVideoSource(session: AdminSession): string | null {
|
|
const resolution = session.requested_video_resolution?.trim();
|
|
const codec = formatCodec(session.requested_video_codec);
|
|
const value = [codec, resolution].filter(Boolean).join(" · ");
|
|
return value || null;
|
|
}
|
|
|
|
function formatCodec(codec?: string): string | null {
|
|
const trimmed = codec?.trim();
|
|
return trimmed ? formatCodecLabel(trimmed) : null;
|
|
}
|
|
|
|
function formatContainer(container?: string): string | null {
|
|
const trimmed = container?.trim();
|
|
return trimmed ? trimmed.toUpperCase() : null;
|
|
}
|
|
|
|
export function getPlaybackSessionTitle(session: AdminSession): string {
|
|
if (session.series_name && session.season_number != null && session.episode_number != null) {
|
|
return session.episode_name || `S${session.season_number}E${session.episode_number}`;
|
|
}
|
|
return session.media_title || `File #${session.media_file_id}`;
|
|
}
|
|
|
|
export function getPlaybackSessionSubtitle(session: AdminSession): string | null {
|
|
if (session.series_name && session.season_number != null && session.episode_number != null) {
|
|
const episode = `S${session.season_number}E${session.episode_number}`;
|
|
return session.series_name ? `${episode} · ${session.series_name}` : episode;
|
|
}
|
|
if (session.media_type === "movie") {
|
|
return "Movie";
|
|
}
|
|
if (session.media_type === "series") {
|
|
return "Series";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatChannelLayout(channels?: number | null): string | null {
|
|
if (!channels || channels <= 0) {
|
|
return null;
|
|
}
|
|
if (channels === 1) {
|
|
return "1.0";
|
|
}
|
|
if (channels === 2) {
|
|
return "2.0";
|
|
}
|
|
if (channels === 6) {
|
|
return "5.1";
|
|
}
|
|
if (channels === 8) {
|
|
return "7.1";
|
|
}
|
|
return `${channels}ch`;
|
|
}
|