diff --git a/web/src/lib/mediaFormat.ts b/web/src/lib/mediaFormat.ts index e2c4fa5b..bf8c963b 100644 --- a/web/src/lib/mediaFormat.ts +++ b/web/src/lib/mediaFormat.ts @@ -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`; +} diff --git a/web/src/pages/ItemDetail/EpisodeContent.test.tsx b/web/src/pages/ItemDetail/EpisodeContent.test.tsx index 6bf4cc22..c0b73dd8 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.test.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.test.tsx @@ -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 | null = null; let capturedDetailHeroProps: Record | null = null; + let capturedMetadataBadgesProps: Record | null = null; + let capturedQualityBadgesProps: Record | null = null; return { capturedActionBarProps: { @@ -26,6 +29,22 @@ const mocks = vi.hoisted(() => { capturedDetailHeroProps = value; }, }, + capturedMetadataBadgesProps: { + get value() { + return capturedMetadataBadgesProps; + }, + set value(value: Record | null) { + capturedMetadataBadgesProps = value; + }, + }, + capturedQualityBadgesProps: { + get value() { + return capturedQualityBadgesProps; + }, + set value(value: Record | 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) => { + default: ( + props: { context?: ReactNode; actions?: ReactNode; metadata?: ReactNode } & Record< + string, + unknown + >, + ) => { mocks.capturedDetailHeroProps.value = props; return (
{props.context} + {props.metadata} {props.actions}
); @@ -109,11 +134,17 @@ vi.mock("./DetailHero", () => ({ })); vi.mock("./components/MetadataBadges", () => ({ - default: () =>
, + default: (props: Record) => { + mocks.capturedMetadataBadgesProps.value = props; + return
; + }, })); vi.mock("./components/QualityBadges", () => ({ - default: () =>
, + default: (props: Record) => { + mocks.capturedQualityBadgesProps.value = props; + return
; + }, })); vi.mock("./components/ScoreRow", () => ({ @@ -157,6 +188,23 @@ vi.mock("./components/EpisodeCarousel", () => ({ ), })); +function makeFileVersion(overrides: Partial = {}): 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" } { @@ -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( + + + , + ); + + 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( diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index 87289998..83a2c113 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -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={
- + {item.air_date && ( {(() => { @@ -298,7 +299,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e })()} )} - +
} scoreRow={ diff --git a/web/src/pages/ItemDetail/MovieContent.test.tsx b/web/src/pages/ItemDetail/MovieContent.test.tsx index 65757fa9..f638c27e 100644 --- a/web/src/pages/ItemDetail/MovieContent.test.tsx +++ b/web/src/pages/ItemDetail/MovieContent.test.tsx @@ -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 | null = null; + let capturedMetadataBadgesProps: Record | null = null; + let capturedQualityBadgesProps: Record | null = null; return { capturedActionBarProps: { @@ -17,6 +20,22 @@ const mocks = vi.hoisted(() => { capturedActionBarProps = value; }, }, + capturedMetadataBadgesProps: { + get value() { + return capturedMetadataBadgesProps; + }, + set value(value: Record | null) { + capturedMetadataBadgesProps = value; + }, + }, + capturedQualityBadgesProps: { + get value() { + return capturedQualityBadgesProps; + }, + set value(value: Record | 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 }) =>
{actions}
, + default: ({ actions, metadata }: { actions?: ReactNode; metadata?: ReactNode }) => ( +
+ {metadata} + {actions} +
+ ), })); vi.mock("./components/MetadataBadges", () => ({ - default: () =>
, + default: (props: Record) => { + mocks.capturedMetadataBadgesProps.value = props; + return
; + }, })); vi.mock("./components/QualityBadges", () => ({ - default: () =>
, + default: (props: Record) => { + mocks.capturedQualityBadgesProps.value = props; + return
; + }, })); vi.mock("./components/ScoreRow", () => ({ @@ -137,6 +167,23 @@ vi.mock("./components/ActionBar", () => ({ }, })); +function makeFileVersion(overrides: Partial = {}): 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"; } { @@ -174,7 +221,7 @@ function makeMovieItem(overrides: Partial = {}): 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 = {}): 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( + + + , + ); + + 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( diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index 34cae8d2..69c83032 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -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 - +
} scoreRow={ diff --git a/web/src/pages/ItemDetail/components/QualityBadges.tsx b/web/src/pages/ItemDetail/components/QualityBadges.tsx index 5ae42a5b..d3472fb7 100644 --- a/web/src/pages/ItemDetail/components/QualityBadges.tsx +++ b/web/src/pages/ItemDetail/components/QualityBadges.tsx @@ -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; diff --git a/web/src/pages/ItemDetail/components/selectedMediaSummary.test.ts b/web/src/pages/ItemDetail/components/selectedMediaSummary.test.ts new file mode 100644 index 00000000..67dde5a8 --- /dev/null +++ b/web/src/pages/ItemDetail/components/selectedMediaSummary.test.ts @@ -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 { + 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 { + 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: "", + }); + }); +}); diff --git a/web/src/pages/ItemDetail/components/selectedMediaSummary.ts b/web/src/pages/ItemDetail/components/selectedMediaSummary.ts new file mode 100644 index 00000000..9d21950d --- /dev/null +++ b/web/src/pages/ItemDetail/components/selectedMediaSummary.ts @@ -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 ?? "", + }; +}