diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index 66bea76f..84b44d12 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -345,10 +345,36 @@ type MediaDetailReturn = ? TMDBShowData : never; +export async function getSeasonDetails( + id: string, + season: number, +): Promise< + Array<{ + id: number; + name: string; + episode_number: number; + overview: string; + still_path: string | null; + air_date: string; + season_number: number; + }> +> { + const seasonData = await get(`/tv/${id}/season/${season}`); + return seasonData.episodes.map((episode) => ({ + id: episode.id, + name: episode.name, + episode_number: episode.episode_number, + overview: episode.overview, + still_path: episode.still_path, + air_date: episode.air_date, + season_number: season, + })); +} + export async function getMediaDetails< T extends TMDBContentTypes, TReturn = MediaDetailReturn, ->(id: string, type: T): Promise { +>(id: string, type: T, fetchEpisodes: boolean = true): Promise { if (type === TMDBContentTypes.MOVIE) { return get(`/movie/${id}`, { append_to_response: "external_ids,credits,release_dates", @@ -359,6 +385,13 @@ export async function getMediaDetails< append_to_response: "external_ids,credits,content_ratings", }); + if (!fetchEpisodes) { + return { + ...showData, + episodes: [], + } as TReturn; + } + // Fetch episodes for each season const showDetails = showData as TMDBShowData; const allEpisodesBySeason = new Array(showDetails.seasons.length); @@ -375,18 +408,8 @@ export async function getMediaDetails< const item = seasonsQueue.shift(); if (!item) break; const { season, index } = item; - const seasonData = await get( - `/tv/${id}/season/${season.season_number}`, - ); - allEpisodesBySeason[index] = seasonData.episodes.map((episode) => ({ - id: episode.id, - name: episode.name, - episode_number: episode.episode_number, - overview: episode.overview, - still_path: episode.still_path, - air_date: episode.air_date, - season_number: season.season_number, - })); + const episodes = await getSeasonDetails(id, season.season_number); + allEpisodesBySeason[index] = episodes; } }, ); diff --git a/src/components/overlays/detailsModal/components/carousels/EpisodeCarousel.tsx b/src/components/overlays/detailsModal/components/carousels/EpisodeCarousel.tsx index 2c493182..c5814187 100644 --- a/src/components/overlays/detailsModal/components/carousels/EpisodeCarousel.tsx +++ b/src/components/overlays/detailsModal/components/carousels/EpisodeCarousel.tsx @@ -9,7 +9,12 @@ import { Icon, Icons } from "@/components/Icon"; import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { hasAired } from "@/components/player/utils/aired"; import { useBookmarkStore } from "@/stores/bookmarks"; -import { getProgressPercentage, useProgressStore } from "@/stores/progress"; +// eslint-disable-next-line import/no-cycle +import { + ProgressEpisodeItem, + getProgressPercentage, + useProgressStore, +} from "@/stores/progress"; import { EpisodeCarouselProps } from "../../types"; @@ -256,16 +261,15 @@ export function EpisodeCarousel({ const watchedStats = useMemo(() => { if (!mediaId || !totalEpisodes) return { watched: 0, percentage: 0 }; + const item = progress[mediaId.toString()]; + if (!item?.episodes) return { watched: 0, percentage: 0 }; + let watchedCount = 0; - episodes.forEach((episode) => { - const episodeProgress = - progress[mediaId.toString()]?.episodes?.[episode.id]; - const percentage = episodeProgress - ? getProgressPercentage( - episodeProgress.progress.watched, - episodeProgress.progress.duration, - ) - : 0; + Object.values(item.episodes).forEach((episode) => { + const percentage = getProgressPercentage( + episode.progress.watched, + episode.progress.duration, + ); if (percentage > 90) { watchedCount += 1; } @@ -274,7 +278,7 @@ export function EpisodeCarousel({ const percentage = Math.round((watchedCount / totalEpisodes) * 100); return { watched: watchedCount, percentage }; - }, [episodes, progress, mediaId, totalEpisodes]); + }, [progress, mediaId, totalEpisodes]); // Load favorite episodes when favorites is selected useEffect(() => { diff --git a/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx b/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx index d80d0d80..7a82f1cc 100644 --- a/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx +++ b/src/components/overlays/detailsModal/components/layout/DetailsContent.tsx @@ -2,6 +2,7 @@ import { t } from "i18next"; import { useEffect, useMemo, useRef, useState } from "react"; import { useCopyToClipboard } from "react-use"; +import { getSeasonDetails } from "@/backend/metadata/tmdb"; import { getNetworkContent } from "@/backend/metadata/traktApi"; import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; import { Icon, Icons } from "@/components/Icon"; @@ -33,6 +34,12 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) { const [showTrailer, setShowTrailer] = useState(false); const [showCollection, setShowCollection] = useState(false); const [selectedSeason, setSelectedSeason] = useState(1); + const [fetchedSeasons, setFetchedSeasons] = useState>( + {}, + ); + const [loadingSeasons, setLoadingSeasons] = useState>( + {}, + ); const [, copyToClipboard] = useCopyToClipboard(); const [hasCopiedShare, setHasCopiedShare] = useState(false); const [logoHeight, setLogoHeight] = useState(0); @@ -69,6 +76,54 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) { } }, [showProgress]); + // Fetch episodes for selected season + useEffect(() => { + const fetchSeason = async (seasonNumber: number) => { + if ( + !data.id || + seasonNumber === -1 || + fetchedSeasons[seasonNumber] || + loadingSeasons[seasonNumber] + ) + return; + + setLoadingSeasons((prev) => ({ ...prev, [seasonNumber]: true })); + try { + const episodes = await getSeasonDetails( + data.id.toString(), + seasonNumber, + ); + setFetchedSeasons((prev) => ({ ...prev, [seasonNumber]: episodes })); + } catch (err) { + console.error("Failed to fetch season details:", err); + } finally { + setLoadingSeasons((prev) => ({ ...prev, [seasonNumber]: false })); + } + }; + + if (data.type === "show") { + if (selectedSeason !== -1) { + fetchSeason(selectedSeason); + } else if (data.seasonData?.seasons) { + // Fetch all seasons for favorites + data.seasonData.seasons.forEach((season) => { + fetchSeason(season.season_number); + }); + } + } + }, [ + data.id, + data.type, + selectedSeason, + fetchedSeasons, + loadingSeasons, + data.seasonData, + ]); + + const allEpisodes = useMemo(() => { + return Object.values(fetchedSeasons).flat(); + }, [fetchedSeasons]); + // Add effect to measure logo height useEffect(() => { if (logoRef.current) { @@ -403,7 +458,7 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) { {/* Episodes Carousel for TV Shows */} {data.type === "show" && data.seasonData && !minimal && (