diff --git a/web/src/components/player/series-player-chrome.tsx b/web/src/components/player/series-player-chrome.tsx index 2c9ebbc..b838b67 100644 --- a/web/src/components/player/series-player-chrome.tsx +++ b/web/src/components/player/series-player-chrome.tsx @@ -6,7 +6,7 @@ import { usePlayerControlsVisible } from "@/components/player/use-player-control import { episodesForSeason, findNextEpisode, - uniqueSeasons, + seasonNumbersForMenu, type SeriesEpisodeOption, } from "@/lib/series-nav"; import { SkipIntroAction } from "@/components/player/skip-intro-action"; @@ -19,6 +19,8 @@ type SeriesPlayerChromeProps = { currentEpisode: number; showName: string; episodes: SeriesEpisodeOption[]; + /** Season numbers from show catalog (preferred over inferring from loaded episodes). */ + catalogSeasons?: number[]; seasonsLoading?: boolean; onSelectEpisode: (path: string) => void; onUploadSubtitle?: (file: File) => Promise; @@ -48,6 +50,7 @@ export function SeriesPlayerChrome({ currentEpisode, showName, episodes, + catalogSeasons, seasonsLoading, onSelectEpisode, onUploadSubtitle, @@ -62,7 +65,10 @@ export function SeriesPlayerChrome({ const fileRef = useRef(null); const [dismissedKey, setDismissedKey] = useState(null); - const seasons = useMemo(() => uniqueSeasons(episodes), [episodes]); + const seasons = useMemo( + () => seasonNumbersForMenu(catalogSeasons, episodes), + [catalogSeasons, episodes], + ); const seasonEpisodes = useMemo( () => episodesForSeason(episodes, currentSeason), [episodes, currentSeason], diff --git a/web/src/components/player/videojs-player.tsx b/web/src/components/player/videojs-player.tsx index 3f0f9c9..c208075 100644 --- a/web/src/components/player/videojs-player.tsx +++ b/web/src/components/player/videojs-player.tsx @@ -37,6 +37,7 @@ type SeriesChrome = { episode: number; showName: string; episodes: SeriesEpisodeOption[]; + catalogSeasons?: number[]; seasonsLoading?: boolean; onSelectEpisode: (path: string) => void; }; @@ -190,6 +191,7 @@ export function VideoJSPlayer({ currentEpisode={seriesChrome.episode} showName={seriesChrome.showName} episodes={seriesChrome.episodes} + catalogSeasons={seriesChrome.catalogSeasons} seasonsLoading={seriesChrome.seasonsLoading} onSelectEpisode={seriesChrome.onSelectEpisode} onUploadSubtitle={onUploadSubtitle} diff --git a/web/src/lib/series-nav.ts b/web/src/lib/series-nav.ts index a655ef7..1049c20 100644 --- a/web/src/lib/series-nav.ts +++ b/web/src/lib/series-nav.ts @@ -11,6 +11,16 @@ export type SeriesNavResult = { seriesComplete: boolean; }; +/** Normalize media paths for equality (route params vs catalog JSON). */ +export function normalizeMediaPath(path: string): string { + const trimmed = path.replace(/^\/+/, "").replaceAll("\\", "/"); + try { + return decodeURIComponent(trimmed); + } catch { + return trimmed; + } +} + /** Flat list of episodes sorted by season then episode number. */ export function sortEpisodes(episodes: SeriesEpisodeOption[]): SeriesEpisodeOption[] { return [...episodes].sort((a, b) => { @@ -26,7 +36,8 @@ export function findNextEpisode( currentPath: string, ): SeriesNavResult { const sorted = sortEpisodes(episodes); - const index = sorted.findIndex((ep) => ep.path === currentPath); + const needle = normalizeMediaPath(currentPath); + const index = sorted.findIndex((ep) => normalizeMediaPath(ep.path) === needle); if (index < 0) { return { next: null, seasonComplete: false, seriesComplete: false }; } @@ -49,3 +60,15 @@ export function episodesForSeason( export function uniqueSeasons(episodes: SeriesEpisodeOption[]): number[] { return [...new Set(episodes.map((ep) => ep.season))].sort((a, b) => a - b); } + +/** Prefer catalog season headers; fall back to seasons inferred from loaded episodes. */ +export function seasonNumbersForMenu( + catalogSeasons: number[] | undefined, + episodes: SeriesEpisodeOption[], +): number[] { + if (catalogSeasons && catalogSeasons.length > 0) { + return [...new Set(catalogSeasons)].sort((a, b) => a - b); + } + + return uniqueSeasons(episodes); +} diff --git a/web/src/pages/player/index.tsx b/web/src/pages/player/index.tsx index 8e9ad35..c7d306f 100644 --- a/web/src/pages/player/index.tsx +++ b/web/src/pages/player/index.tsx @@ -173,34 +173,53 @@ export default function PlayerPage() { }, }); - const seasons = showQuery.data?.seasons ?? []; + const catalogSeasonNumbers = useMemo(() => { + const list = showQuery.data?.seasons ?? []; + return list.map((season) => season.season ?? 0); + }, [showQuery.data?.seasons]); const episodesQuery = useQuery({ queryKey: [ "player-series-episodes", series?.librarySlug, series?.showKey, - seasons.map((s) => s.season).join(","), + catalogSeasonNumbers.join(","), ], - enabled: Boolean(series?.librarySlug && series?.showKey && seasons.length > 0), + enabled: Boolean(series?.librarySlug && series?.showKey && catalogSeasonNumbers.length > 0), queryFn: async () => { - const pages = await Promise.all( - seasons.map(async (season) => { + const current = series!.season; + const ordered = [...catalogSeasonNumbers].sort((a, b) => { + if (a === current) { + return -1; + } + if (b === current) { + return 1; + } + return a - b; + }); + + // allSettled: one slow/failing season must not wipe the whole menu (Melrose = 7×~32). + const settled = await Promise.allSettled( + ordered.map(async (season) => { const response = await getApiCatalogByLibrarySlugShowsByShowKeySeasonsBySeasonEpisodes({ path: { librarySlug: series!.librarySlug, showKey: series!.showKey, - season: season.season ?? 0, + season, }, }); if (response.error || !response.data) { - throw new Error("season episodes failed"); + throw new Error(`season ${season} episodes failed`); } return response.data.episodes ?? []; }), ); + const options: SeriesEpisodeOption[] = []; - for (const page of pages) { - for (const ep of page) { + for (const result of settled) { + if (result.status !== "fulfilled") { + continue; + } + for (const ep of result.value) { if (!ep.path) { continue; } @@ -242,10 +261,12 @@ export default function PlayerPage() { episode: series.episode, showName: series.showName, episodes: episodesQuery.data ?? [], + catalogSeasons: catalogSeasonNumbers, seasonsLoading: showQuery.isLoading || episodesQuery.isLoading, onSelectEpisode: selectEpisode, }; }, [ + catalogSeasonNumbers, episodesQuery.data, episodesQuery.isLoading, mediaPath,