fix(web): update metadata for selected version (#414)

* fix(web): update metadata for selected version

* refactor(web): simplify selected duration lookup

* refactor(web): share the hero runtime formatter via mediaFormat

MovieContent and EpisodeContent carried identical local formatDuration
helpers; move the minutes-based badge formatter into lib/mediaFormat as
formatRuntimeMinutes alongside the other canonical media-spec helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Suspense
2026-07-16 15:08:16 -04:00
committed by GitHub
co-authored by Quick104 Claude Fable 5
parent b3722dac58
commit 95779c47a9
8 changed files with 386 additions and 40 deletions
+8
View File
@@ -108,3 +108,11 @@ export function compactHdrSuffix(value: string | undefined): string | null {
function isPositive(value?: number): value is number {
return Number.isFinite(value) && value != null && value > 0;
}
/** Hero runtime badge label ("2h 43m", "43m"); "" for zero/unknown. */
export function formatRuntimeMinutes(minutes: number): string {
if (minutes <= 0) return "";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
@@ -1,13 +1,16 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { ItemDetail, Season } from "@/api/types";
import type { FileVersion, ItemDetail, Season } from "@/api/types";
import EpisodeContent from "./EpisodeContent";
const mocks = vi.hoisted(() => {
let capturedActionBarProps: Record<string, unknown> | null = null;
let capturedDetailHeroProps: Record<string, unknown> | null = null;
let capturedMetadataBadgesProps: Record<string, unknown> | null = null;
let capturedQualityBadgesProps: Record<string, unknown> | null = null;
return {
capturedActionBarProps: {
@@ -26,6 +29,22 @@ const mocks = vi.hoisted(() => {
capturedDetailHeroProps = value;
},
},
capturedMetadataBadgesProps: {
get value() {
return capturedMetadataBadgesProps;
},
set value(value: Record<string, unknown> | null) {
capturedMetadataBadgesProps = value;
},
},
capturedQualityBadgesProps: {
get value() {
return capturedQualityBadgesProps;
},
set value(value: Record<string, unknown> | null) {
capturedQualityBadgesProps = value;
},
},
useSeasonDetail: vi.fn(),
useSeasonEpisodes: vi.fn(),
useAuth: vi.fn(),
@@ -97,11 +116,17 @@ vi.mock("@/components/DownloadVersionPicker", () => ({
}));
vi.mock("./DetailHero", () => ({
default: (props: { context?: ReactNode; actions?: ReactNode } & Record<string, unknown>) => {
default: (
props: { context?: ReactNode; actions?: ReactNode; metadata?: ReactNode } & Record<
string,
unknown
>,
) => {
mocks.capturedDetailHeroProps.value = props;
return (
<div>
{props.context}
{props.metadata}
{props.actions}
</div>
);
@@ -109,11 +134,17 @@ vi.mock("./DetailHero", () => ({
}));
vi.mock("./components/MetadataBadges", () => ({
default: () => <div />,
default: (props: Record<string, unknown>) => {
mocks.capturedMetadataBadgesProps.value = props;
return <div />;
},
}));
vi.mock("./components/QualityBadges", () => ({
default: () => <div />,
default: (props: Record<string, unknown>) => {
mocks.capturedQualityBadgesProps.value = props;
return <div />;
},
}));
vi.mock("./components/ScoreRow", () => ({
@@ -157,6 +188,23 @@ vi.mock("./components/EpisodeCarousel", () => ({
),
}));
function makeFileVersion(overrides: Partial<FileVersion> = {}): FileVersion {
return {
file_id: overrides.file_id ?? 1,
resolution: overrides.resolution ?? "1080p",
codec_video: overrides.codec_video ?? "h264",
codec_audio: overrides.codec_audio ?? "aac",
hdr: overrides.hdr ?? false,
container: overrides.container ?? "mkv",
file_size: overrides.file_size ?? 0,
duration: overrides.duration ?? 2520,
bitrate: overrides.bitrate ?? 0,
edition_raw: overrides.edition_raw,
edition_key: overrides.edition_key,
audio_tracks: overrides.audio_tracks,
};
}
function makeEpisodeItem(
overrides: Partial<ItemDetail & { type: "episode" }> = {},
): ItemDetail & { type: "episode" } {
@@ -193,7 +241,7 @@ function makeEpisodeItem(
season_number: 1,
episode_number: 1,
air_date: "2024-01-01",
versions: [{ file_id: 1 } as ItemDetail["versions"][number]],
versions: [makeFileVersion()],
subtitles: [],
intro: null,
credits: null,
@@ -226,6 +274,8 @@ describe("EpisodeContent", () => {
beforeEach(() => {
mocks.capturedActionBarProps.value = null;
mocks.capturedDetailHeroProps.value = null;
mocks.capturedMetadataBadgesProps.value = null;
mocks.capturedQualityBadgesProps.value = null;
mocks.useAuth.mockReturnValue({ user: null });
mocks.useCurrentProfile.mockReturnValue({ profile: null });
mocks.useOnViewTranslation.mockReturnValue({ translating: false, onTranslate: undefined });
@@ -250,6 +300,58 @@ describe("EpisodeContent", () => {
mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } });
});
it("updates hero metadata when the selected episode version changes", () => {
const hdrVersion = makeFileVersion({
file_id: 1,
resolution: "2160p",
codec_video: "hevc",
codec_audio: "eac3",
hdr: true,
duration: 2520,
});
const sdrVersion = makeFileVersion({
file_id: 2,
resolution: "1080p",
codec_video: "h264",
codec_audio: "aac",
hdr: false,
duration: 2700,
});
render(
<MemoryRouter initialEntries={["/item/episode-1"]}>
<EpisodeContent item={makeEpisodeItem({ versions: [hdrVersion, sdrVersion] })} />
</MemoryRouter>,
);
expect(mocks.capturedMetadataBadgesProps.value).toMatchObject({ duration: "42m" });
expect(mocks.capturedQualityBadgesProps.value).toEqual({
summary: {
durationMinutes: 42,
resolution: "2160p",
videoRangeLabel: "HDR",
audioLabel: "EAC3",
},
});
act(() => {
const selectVersion = mocks.capturedActionBarProps.value?.onSelectVersion as
| ((version: FileVersion) => void)
| undefined;
selectVersion?.(sdrVersion);
});
expect(mocks.capturedMetadataBadgesProps.value).toMatchObject({ duration: "45m" });
expect(mocks.capturedQualityBadgesProps.value).toEqual({
summary: {
durationMinutes: 45,
resolution: "1080p",
videoRangeLabel: "",
audioLabel: "AAC",
},
});
});
it("links the season breadcrumb segment to the resolved season page", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/item/episode-1"]}>
+10 -9
View File
@@ -32,6 +32,7 @@ import MediaInfoDialog from "./components/MediaInfoDialog";
import SubtitleSearchDialog from "./components/SubtitleSearchDialog";
import { sortByResolution } from "./components/VersionFlyout";
import { selectDefaultPlaybackVariantVersion } from "./components/versionRankingUtils";
import { resolveSelectedMediaSummary } from "./components/selectedMediaSummary";
import { EpisodeCarouselSkeleton } from "./components/SectionSkeletons";
import {
getSeasonDisplayTitle,
@@ -44,13 +45,7 @@ import {
canCurateMetadata as canCurateMetadataForUser,
canEditMarkers as canEditMarkersForUser,
} from "@/lib/permissions";
function formatDuration(minutes: number): string {
if (minutes <= 0) return "";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
import { formatRuntimeMinutes } from "@/lib/mediaFormat";
export default function EpisodeContent({ item }: { item: ItemDetail & { type: "episode" } }) {
const { translating: overviewTranslating, onTranslate: onTranslateOverview } =
@@ -102,6 +97,10 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
: null) ?? defaultSelectedVersion,
[defaultSelectedVersion, manualSelectedFileId, sortedVersions],
);
const selectedMediaSummary = useMemo(
() => resolveSelectedMediaSummary(selectedVersion, item.playback_variants, item.runtime ?? 0),
[item.playback_variants, item.runtime, selectedVersion],
);
const openMediaInfo = useCallback(
(fileId?: number) => {
setMediaInfoFileId(fileId ?? selectedVersion?.file_id ?? null);
@@ -283,7 +282,9 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
logoUrl={item.logo_url}
metadata={
<div className="flex flex-wrap items-center gap-2">
<MetadataBadges duration={formatDuration(item.runtime ?? 0) || undefined} />
<MetadataBadges
duration={formatRuntimeMinutes(selectedMediaSummary.durationMinutes) || undefined}
/>
{item.air_date && (
<span className="metadata-badge">
{(() => {
@@ -298,7 +299,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
})()}
</span>
)}
<QualityBadges versions={item.versions ?? []} />
<QualityBadges summary={selectedMediaSummary} />
</div>
}
scoreRow={
+108 -5
View File
@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ItemDetail } from "@/api/types";
import type { FileVersion, ItemDetail } from "@/api/types";
import MovieContent from "./MovieContent";
const mocks = vi.hoisted(() => {
let capturedActionBarProps: Record<string, unknown> | null = null;
let capturedMetadataBadgesProps: Record<string, unknown> | null = null;
let capturedQualityBadgesProps: Record<string, unknown> | null = null;
return {
capturedActionBarProps: {
@@ -17,6 +20,22 @@ const mocks = vi.hoisted(() => {
capturedActionBarProps = value;
},
},
capturedMetadataBadgesProps: {
get value() {
return capturedMetadataBadgesProps;
},
set value(value: Record<string, unknown> | null) {
capturedMetadataBadgesProps = value;
},
},
capturedQualityBadgesProps: {
get value() {
return capturedQualityBadgesProps;
},
set value(value: Record<string, unknown> | null) {
capturedQualityBadgesProps = value;
},
},
useIsFavorite: vi.fn(),
useToggleFavorite: vi.fn(),
useIsInWatchlist: vi.fn(),
@@ -111,15 +130,26 @@ vi.mock("@/components/RecommendationGrid", () => ({
}));
vi.mock("./DetailHero", () => ({
default: ({ actions }: { actions?: ReactNode }) => <div>{actions}</div>,
default: ({ actions, metadata }: { actions?: ReactNode; metadata?: ReactNode }) => (
<div>
{metadata}
{actions}
</div>
),
}));
vi.mock("./components/MetadataBadges", () => ({
default: () => <div />,
default: (props: Record<string, unknown>) => {
mocks.capturedMetadataBadgesProps.value = props;
return <div />;
},
}));
vi.mock("./components/QualityBadges", () => ({
default: () => <div />,
default: (props: Record<string, unknown>) => {
mocks.capturedQualityBadgesProps.value = props;
return <div />;
},
}));
vi.mock("./components/ScoreRow", () => ({
@@ -137,6 +167,23 @@ vi.mock("./components/ActionBar", () => ({
},
}));
function makeFileVersion(overrides: Partial<FileVersion> = {}): FileVersion {
return {
file_id: overrides.file_id ?? 1,
resolution: overrides.resolution ?? "1080p",
codec_video: overrides.codec_video ?? "h264",
codec_audio: overrides.codec_audio ?? "aac",
hdr: overrides.hdr ?? false,
container: overrides.container ?? "mkv",
file_size: overrides.file_size ?? 0,
duration: overrides.duration ?? 7200,
bitrate: overrides.bitrate ?? 0,
edition_raw: overrides.edition_raw,
edition_key: overrides.edition_key,
audio_tracks: overrides.audio_tracks,
};
}
function makeMovieItem(overrides: Partial<ItemDetail & { type: "movie" }> = {}): ItemDetail & {
type: "movie";
} {
@@ -174,7 +221,7 @@ function makeMovieItem(overrides: Partial<ItemDetail & { type: "movie" }> = {}):
season_number: null,
episode_number: null,
air_date: null,
versions: [{ file_id: 1 }] as ItemDetail["versions"],
versions: [makeFileVersion()],
subtitles: [],
intro: null,
credits: null,
@@ -186,6 +233,8 @@ function makeMovieItem(overrides: Partial<ItemDetail & { type: "movie" }> = {}):
describe("MovieContent", () => {
beforeEach(() => {
mocks.capturedActionBarProps.value = null;
mocks.capturedMetadataBadgesProps.value = null;
mocks.capturedQualityBadgesProps.value = null;
mocks.useIsFavorite.mockReturnValue({ data: false });
mocks.useToggleFavorite.mockReturnValue({ mutate: vi.fn() });
mocks.useIsInWatchlist.mockReturnValue({ data: false });
@@ -200,6 +249,60 @@ describe("MovieContent", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
});
it("updates hero metadata when the selected version changes", () => {
const standard = makeFileVersion({
file_id: 1,
resolution: "2160p",
codec_video: "hevc",
codec_audio: "eac3",
hdr: true,
duration: 9780,
});
const directorsCut = makeFileVersion({
file_id: 2,
resolution: "1080p",
codec_video: "h264",
codec_audio: "dts",
hdr: false,
duration: 11760,
edition_raw: "Director's Cut",
edition_key: "directors_cut",
});
render(
<MemoryRouter initialEntries={["/item/movie-1"]}>
<MovieContent item={makeMovieItem({ runtime: 163, versions: [standard, directorsCut] })} />
</MemoryRouter>,
);
expect(mocks.capturedMetadataBadgesProps.value).toMatchObject({ duration: "2h 43m" });
expect(mocks.capturedQualityBadgesProps.value).toEqual({
summary: {
durationMinutes: 163,
resolution: "2160p",
videoRangeLabel: "HDR",
audioLabel: "EAC3",
},
});
act(() => {
const selectVersion = mocks.capturedActionBarProps.value?.onSelectVersion as
| ((version: FileVersion) => void)
| undefined;
selectVersion?.(directorsCut);
});
expect(mocks.capturedMetadataBadgesProps.value).toMatchObject({ duration: "3h 16m" });
expect(mocks.capturedQualityBadgesProps.value).toEqual({
summary: {
durationMinutes: 196,
resolution: "1080p",
videoRangeLabel: "",
audioLabel: "DTS",
},
});
});
it("passes restartHref when the movie is partially watched", () => {
renderToStaticMarkup(
<MemoryRouter initialEntries={["/item/movie-1"]}>
+8 -9
View File
@@ -34,6 +34,7 @@ import MediaInfoDialog from "./components/MediaInfoDialog";
import SubtitleSearchDialog from "./components/SubtitleSearchDialog";
import { sortByResolution } from "./components/VersionFlyout";
import { selectDefaultPlaybackVariantVersion } from "./components/versionRankingUtils";
import { resolveSelectedMediaSummary } from "./components/selectedMediaSummary";
import { RecommendationGridSkeleton } from "./components/SectionSkeletons";
import { resolveLeafPrimaryAction } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
@@ -41,13 +42,7 @@ import {
canCurateMetadata as canCurateMetadataForUser,
canEditMarkers as canEditMarkersForUser,
} from "@/lib/permissions";
function formatDuration(minutes: number): string {
if (minutes <= 0) return "";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
import { formatRuntimeMinutes } from "@/lib/mediaFormat";
export default function MovieContent({ item }: { item: ItemDetail & { type: "movie" } }) {
const { translating: overviewTranslating, onTranslate: onTranslateOverview } =
@@ -107,6 +102,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
: null) ?? defaultSelectedVersion,
[defaultSelectedVersion, manualSelectedFileId, sortedVersions],
);
const selectedMediaSummary = useMemo(
() => resolveSelectedMediaSummary(selectedVersion, item.playback_variants, item.runtime ?? 0),
[item.playback_variants, item.runtime, selectedVersion],
);
const openMediaInfo = useCallback(
(fileId?: number) => {
setMediaInfoFileId(fileId ?? selectedVersion?.file_id ?? null);
@@ -236,9 +235,9 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
<MetadataBadges
year={year || undefined}
contentRating={item.content_rating || undefined}
duration={formatDuration(item.runtime ?? 0) || undefined}
duration={formatRuntimeMinutes(selectedMediaSummary.durationMinutes) || undefined}
/>
<QualityBadges versions={item.versions} />
<QualityBadges summary={selectedMediaSummary} />
</div>
}
scoreRow={
@@ -1,20 +1,14 @@
import type { FileVersion } from "@/api/types";
import { bestVideoRangeLabel } from "@/lib/videoRange";
import { pickBestAttributes } from "./versionRankingUtils";
import type { SelectedMediaSummary } from "./selectedMediaSummary";
interface QualityBadgesProps {
versions: FileVersion[];
summary: SelectedMediaSummary;
}
export default function QualityBadges({ versions }: QualityBadgesProps) {
const best = pickBestAttributes(versions);
if (!best) return null;
export default function QualityBadges({ summary }: QualityBadgesProps) {
const badges: string[] = [];
if (best.resolution) badges.push(best.resolution);
const rangeLabel = bestVideoRangeLabel(versions);
if (rangeLabel) badges.push(rangeLabel);
if (best.audioLabel) badges.push(best.audioLabel);
if (summary.resolution) badges.push(summary.resolution);
if (summary.videoRangeLabel) badges.push(summary.videoRangeLabel);
if (summary.audioLabel) badges.push(summary.audioLabel);
if (badges.length === 0) return null;
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { FileVersion, PlaybackVariant } from "@/api/types";
import { resolveSelectedMediaSummary } from "./selectedMediaSummary";
function makeVersion(overrides: Partial<FileVersion> = {}): FileVersion {
return {
file_id: overrides.file_id ?? 1,
resolution: overrides.resolution ?? "1080p",
codec_video: overrides.codec_video ?? "h264",
codec_audio: overrides.codec_audio ?? "aac",
hdr: overrides.hdr ?? false,
container: overrides.container ?? "mkv",
file_size: overrides.file_size ?? 0,
duration: overrides.duration ?? 7200,
bitrate: overrides.bitrate ?? 0,
audio_tracks: overrides.audio_tracks,
video_tracks: overrides.video_tracks,
};
}
function makeVariant(
versionsByPart: FileVersion[][],
overrides: Partial<PlaybackVariant> = {},
): PlaybackVariant {
return {
variant_id: overrides.variant_id ?? "variant-1",
part_count: overrides.part_count ?? versionsByPart.length,
total_duration: overrides.total_duration,
parts: versionsByPart.map((versions, index) => ({
part_index: index + 1,
versions,
})),
};
}
describe("resolveSelectedMediaSummary", () => {
it("uses the selected file for duration and technical attributes", () => {
const version = makeVersion({
resolution: "1080p",
codec_audio: "dts",
hdr: false,
duration: 11760,
});
expect(resolveSelectedMediaSummary(version, undefined, 163)).toEqual({
durationMinutes: 196,
resolution: "1080p",
videoRangeLabel: "",
audioLabel: "DTS",
});
});
it("derives the video range label from the selected file's tracks", () => {
const dolbyVision = makeVersion({
hdr: true,
video_tracks: [{ dolby_vision: "Profile 8.1", video_range_type: "DOVIWithHDR10" }],
});
expect(resolveSelectedMediaSummary(dolbyVision, undefined, 0).videoRangeLabel).toBe("DV HDR10");
expect(
resolveSelectedMediaSummary(makeVersion({ hdr: true }), undefined, 0).videoRangeLabel,
).toBe("HDR");
});
it("only considers audio tracks from the selected file", () => {
const version = makeVersion({
codec_audio: "aac",
audio_tracks: [
{ language: "en", codec: "aac" },
{ language: "en", codec: "truehd" },
],
});
expect(resolveSelectedMediaSummary(version, undefined, 0).audioLabel).toBe("TrueHD");
});
it("uses the playback variant total for multipart editions", () => {
const firstPart = makeVersion({ file_id: 1, duration: 3600 });
const secondPart = makeVersion({ file_id: 2, duration: 4200 });
const variant = makeVariant([[firstPart], [secondPart]], { total_duration: 7800 });
expect(resolveSelectedMediaSummary(firstPart, [variant], 0).durationMinutes).toBe(130);
});
it("does not replace a single-part selected file duration with the variant maximum", () => {
const selected = makeVersion({ file_id: 1, duration: 6000 });
const longerAlternative = makeVersion({ file_id: 2, duration: 7200 });
const variant = makeVariant([[selected, longerAlternative]], { total_duration: 7200 });
expect(resolveSelectedMediaSummary(selected, [variant], 0).durationMinutes).toBe(100);
});
it("falls back to item runtime when no selected file duration is available", () => {
expect(resolveSelectedMediaSummary(null, undefined, 42)).toEqual({
durationMinutes: 42,
resolution: "",
videoRangeLabel: "",
audioLabel: "",
});
});
});
@@ -0,0 +1,38 @@
import type { FileVersion, PlaybackVariant } from "@/api/types";
import { videoRangeLabel } from "@/lib/videoRange";
import { pickBestAttributes } from "./versionRankingUtils";
export interface SelectedMediaSummary {
durationMinutes: number;
resolution: string;
videoRangeLabel: string;
audioLabel: string;
}
export function resolveSelectedMediaSummary(
selectedVersion: FileVersion | null,
playbackVariants: PlaybackVariant[] | undefined,
fallbackRuntimeMinutes: number,
): SelectedMediaSummary {
const quality = selectedVersion ? pickBestAttributes([selectedVersion]) : null;
const selectedVariant = selectedVersion
? playbackVariants?.find((variant) =>
(variant.parts ?? []).some((part) =>
(part.versions ?? []).some((version) => version.file_id === selectedVersion.file_id),
),
)
: undefined;
const isMultipart =
(selectedVariant?.part_count ?? 0) > 1 || (selectedVariant?.parts?.length ?? 0) > 1;
const variantDuration = selectedVariant?.total_duration ?? 0;
const durationSeconds =
isMultipart && variantDuration > 0 ? variantDuration : (selectedVersion?.duration ?? 0);
return {
durationMinutes:
durationSeconds > 0 ? Math.round(durationSeconds / 60) : fallbackRuntimeMinutes,
resolution: quality?.resolution ?? "",
videoRangeLabel: selectedVersion ? videoRangeLabel(selectedVersion) : "",
audioLabel: quality?.audioLabel ?? "",
};
}