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:
co-authored by
Quick104
Claude Fable 5
parent
b3722dac58
commit
95779c47a9
@@ -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"]}>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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"]}>
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user