diff --git a/src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx b/src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx index 0dc808eb..9ca92b9b 100644 --- a/src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx +++ b/src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx @@ -1,11 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { getMediaPoster, getRelatedMedia } from "@/backend/metadata/tmdb"; import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; import { MediaCard, MediaCardSkeleton } from "@/components/media/MediaCard"; import { useIsMobile } from "@/hooks/useIsMobile"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; +import { useSimilarMedia } from "@/pages/discover/hooks/useSimilarMedia"; import { useOverlayStack } from "@/stores/interface/overlayStack"; import { MediaItem } from "@/utils/mediaTypes"; @@ -21,51 +21,16 @@ export function SimilarMediaCarousel({ const { t } = useTranslation(); const { isMobile } = useIsMobile(); const { showModal } = useOverlayStack(); - const [similarMedia, setSimilarMedia] = useState([]); - const [isLoading, setIsLoading] = useState(true); const carouselRef = useRef(null); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({ similar: null, }); - useEffect(() => { - const loadSimilarMedia = async () => { - setIsLoading(true); - try { - const results = await getRelatedMedia(mediaId, mediaType, 12); - const mediaItems: MediaItem[] = results.map((result) => { - const isMovie = "title" in result; - return { - id: result.id.toString(), - title: isMovie ? result.title : result.name, - poster: getMediaPoster(result.poster_path) || "/placeholder.png", - type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show", - year: isMovie - ? result.release_date - ? new Date(result.release_date).getFullYear() - : 0 - : result.first_air_date - ? new Date(result.first_air_date).getFullYear() - : 0, - release_date: isMovie - ? result.release_date - ? new Date(result.release_date) - : undefined - : result.first_air_date - ? new Date(result.first_air_date) - : undefined, - }; - }); - setSimilarMedia(mediaItems); - } catch (err) { - console.error("Failed to load similar media:", err); - } finally { - setIsLoading(false); - } - }; - - loadSimilarMedia(); - }, [mediaId, mediaType]); + const { media: similarMedia, isLoading } = useSimilarMedia({ + mediaId, + mediaType, + limit: 12, + }); useEffect(() => { if (carouselRef.current) { @@ -112,19 +77,44 @@ export function SimilarMediaCarousel({ )) : // Show actual media cards when loaded - similarMedia.map((media) => ( -
- -
- ))} + similarMedia.map((media) => { + const isMovie = "title" in media; + const item: MediaItem = { + id: media.id.toString(), + title: isMovie ? media.title : media.name, + poster: media.poster_path + ? `https://image.tmdb.org/t/p/w342${media.poster_path}` + : "/placeholder.png", + type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show", + year: isMovie + ? media.release_date + ? new Date(media.release_date).getFullYear() + : 0 + : media.first_air_date + ? new Date(media.first_air_date).getFullYear() + : 0, + release_date: isMovie + ? media.release_date + ? new Date(media.release_date) + : undefined + : media.first_air_date + ? new Date(media.first_air_date) + : undefined, + }; + return ( +
+ +
+ ); + })}
diff --git a/src/pages/discover/hooks/useDiscoverMedia.ts b/src/pages/discover/hooks/useDiscoverMedia.ts index 1419c3ad..77aa4a44 100644 --- a/src/pages/discover/hooks/useDiscoverMedia.ts +++ b/src/pages/discover/hooks/useDiscoverMedia.ts @@ -1,7 +1,14 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { get } from "@/backend/metadata/tmdb"; +import { getMediaDetails, get } from "@/backend/metadata/tmdb"; +import type { + TMDBMovieData, + TMDBMovieSearchResult, + TMDBShowData, + TMDBShowSearchResult, +} from "@/backend/metadata/types/tmdb"; +import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; import { PROVIDER_TO_TRAKT_MAP, getAppleMovieReleases, @@ -24,8 +31,7 @@ import { } from "@/backend/metadata/traktApi"; import { paginateResults } from "@/backend/metadata/traktFunctions"; import type { TraktListResponse } from "@/backend/metadata/types/trakt"; -import { - EDITOR_PICKS_MOVIES, +import { EDITOR_PICKS_MOVIES, EDITOR_PICKS_TV_SHOWS, MOVIE_PROVIDERS, TV_PROVIDERS, @@ -39,6 +45,7 @@ import type { UseDiscoverMediaProps, UseDiscoverMediaReturn, } from "@/pages/discover/types/discover"; +import { fetchFedSimilarItems } from "../lib/personalRecommendations"; import { conf } from "@/setup/config"; import { useLanguageStore } from "@/stores/language"; import { getTmdbLanguageCode } from "@/utils/language"; @@ -321,6 +328,108 @@ export function useDiscoverMedia({ } }, [mediaType, formattedLanguage, isCarouselView]); + const fetchRecommendationsWithFedSimilar = useCallback( + async (mediaId: string) => { + const isTVShow = mediaType === "tv"; + const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE; + + try { + // Try fed-similar API first + const fedSimilarIds = await fetchFedSimilarItems(mediaId, isTVShow); + + if (fedSimilarIds.length > 0) { + // Fetch full details for fed-similar items + const fedSimilarDetailPromises = fedSimilarIds + .slice(0, isCarouselView ? 20 : 100) + .map((tmdbId) => getMediaDetails(tmdbId, type)); + + const fedSimilarDetails = await Promise.allSettled( + fedSimilarDetailPromises, + ); + + const results: any[] = []; + + for (const result of fedSimilarDetails) { + if (result.status !== "fulfilled" || !result.value) continue; + const item = result.value as TMDBMovieData | TMDBShowData; + + let searchItem: TMDBMovieSearchResult | TMDBShowSearchResult; + if (isTVShow) { + const showItem = item as TMDBShowData; + searchItem = { + adult: showItem.adult ?? false, + backdrop_path: showItem.backdrop_path ?? "", + id: showItem.id, + name: showItem.name, + original_language: showItem.original_language ?? "", + original_name: showItem.original_name ?? "", + overview: showItem.overview ?? "", + poster_path: showItem.poster_path ?? "", + media_type: TMDBContentTypes.TV, + genre_ids: showItem.genres?.map((g) => g.id) ?? [], + popularity: showItem.popularity ?? 0, + first_air_date: showItem.first_air_date ?? "", + vote_average: showItem.vote_average, + vote_count: showItem.vote_count, + origin_country: showItem.origin_country ?? [], + }; + } else { + const movieItem = item as TMDBMovieData; + searchItem = { + adult: movieItem.adult ?? false, + backdrop_path: movieItem.backdrop_path ?? "", + id: movieItem.id, + title: movieItem.title, + original_language: movieItem.original_language ?? "", + original_title: movieItem.original_title ?? "", + overview: movieItem.overview ?? "", + poster_path: movieItem.poster_path ?? "", + media_type: TMDBContentTypes.MOVIE, + genre_ids: movieItem.genres?.map((g) => g.id) ?? [], + popularity: movieItem.popularity ?? 0, + release_date: movieItem.release_date ?? "", + video: movieItem.video ?? false, + vote_average: movieItem.vote_average, + vote_count: movieItem.vote_count, + }; + } + + results.push(searchItem); + } + + // If we have enough results from fed-similar, return them + const minResults = isCarouselView ? 5 : 10; + if (results.length >= minResults) { + console.info( + `Using fed-similar API results (${results.length} items)`, + ); + return { + results: results.map((item) => ({ + ...item, + type: mediaType === "movie" ? "movie" : "show", + })), + hasMore: false, + }; + } + } + + // Fall back to TMDB recommendations + console.info( + "Fed-similar API returned insufficient or no results, falling back to TMDB", + ); + const data = await fetchTMDBMedia(`/${mediaType}/${mediaId}/recommendations`); + return data; + } catch (err) { + console.error("Error fetching fed-similar recommendations:", err); + + // Try TMDB fallback on error + console.info("Attempting TMDB fallback..."); + return await fetchTMDBMedia(`/${mediaType}/${mediaId}/recommendations`); + } + }, + [mediaType, formattedLanguage, isCarouselView, fetchTMDBMedia], + ); + const fetchMedia = useCallback(async () => { // Skip fetching recommendations if no ID is provided if (contentType === "recommendations" && !id) { @@ -454,7 +563,7 @@ export function useDiscoverMedia({ case "recommendations": if (!id) throw new Error("Media ID is required for recommendations"); - data = await fetchTMDBMedia(`/${mediaType}/${id}/recommendations`); + data = await fetchRecommendationsWithFedSimilar(id); setSectionTitle( t("discover.carousel.title.recommended", { title: mediaTitle }), ); @@ -520,6 +629,7 @@ export function useDiscoverMedia({ fetchTMDBMedia, fetchTraktMedia, fetchEditorPicks, + fetchRecommendationsWithFedSimilar, t, page, getTraktProviderFunction, diff --git a/src/pages/discover/hooks/useSimilarMedia.ts b/src/pages/discover/hooks/useSimilarMedia.ts new file mode 100644 index 00000000..bfa7bf47 --- /dev/null +++ b/src/pages/discover/hooks/useSimilarMedia.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; + +import { getMediaDetails, getRelatedMedia } from "@/backend/metadata/tmdb"; +import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; +import type { + TMDBMovieData, + TMDBMovieSearchResult, + TMDBShowData, + TMDBShowSearchResult, +} from "@/backend/metadata/types/tmdb"; +import { fetchFedSimilarItems } from "../lib/personalRecommendations"; + +export function useSimilarMedia({ + mediaId, + mediaType, + limit = 12, + enabled = true, +}: { + mediaId: string; + mediaType: TMDBContentTypes; + limit?: number; + enabled?: boolean; +}) { + const [media, setMedia] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const isTVShow = mediaType === TMDBContentTypes.TV; + const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE; + + const fetch = useCallback(async () => { + if (!mediaId || !enabled) return; + + setIsLoading(true); + setError(null); + + try { + // Try fed-similar API first + const fedSimilarIds = await fetchFedSimilarItems(mediaId, isTVShow); + + if (fedSimilarIds.length > 0) { + // Fetch full details for fed-similar items + const fedSimilarDetailPromises = fedSimilarIds + .slice(0, limit) + .map((tmdbId) => getMediaDetails(tmdbId, type)); + + const fedSimilarDetails = await Promise.allSettled( + fedSimilarDetailPromises, + ); + + const results: (TMDBMovieSearchResult | TMDBShowSearchResult)[] = []; + + for (const result of fedSimilarDetails) { + if (result.status !== "fulfilled" || !result.value) continue; + const item = result.value as TMDBMovieData | TMDBShowData; + + let searchItem: TMDBMovieSearchResult | TMDBShowSearchResult; + if (isTVShow) { + const showItem = item as TMDBShowData; + searchItem = { + adult: showItem.adult ?? false, + backdrop_path: showItem.backdrop_path ?? "", + id: showItem.id, + name: showItem.name, + original_language: showItem.original_language ?? "", + original_name: showItem.original_name ?? "", + overview: showItem.overview ?? "", + poster_path: showItem.poster_path ?? "", + media_type: TMDBContentTypes.TV, + genre_ids: showItem.genres?.map((g) => g.id) ?? [], + popularity: showItem.popularity ?? 0, + first_air_date: showItem.first_air_date ?? "", + vote_average: showItem.vote_average, + vote_count: showItem.vote_count, + origin_country: showItem.origin_country ?? [], + }; + } else { + const movieItem = item as TMDBMovieData; + searchItem = { + adult: movieItem.adult ?? false, + backdrop_path: movieItem.backdrop_path ?? "", + id: movieItem.id, + title: movieItem.title, + original_language: movieItem.original_language ?? "", + original_title: movieItem.original_title ?? "", + overview: movieItem.overview ?? "", + poster_path: movieItem.poster_path ?? "", + media_type: TMDBContentTypes.MOVIE, + genre_ids: movieItem.genres?.map((g) => g.id) ?? [], + popularity: movieItem.popularity ?? 0, + release_date: movieItem.release_date ?? "", + video: movieItem.video ?? false, + vote_average: movieItem.vote_average, + vote_count: movieItem.vote_count, + }; + } + + results.push(searchItem); + } + + if (results.length >= limit / 2) { + // If we have enough results from fed-similar, use them + setMedia(results.slice(0, limit)); + return; + } + } + + // Fall back to TMDB recommendations + console.info( + "Fed-similar API returned insufficient or no results, falling back to TMDB", + ); + const tmdbResults = await getRelatedMedia(mediaId, type, limit); + setMedia(tmdbResults); + } catch (err) { + console.error("Failed to load similar media:", err); + + // Try TMDB fallback on error + try { + console.info("Attempting TMDB fallback..."); + const tmdbResults = await getRelatedMedia(mediaId, type, limit); + setMedia(tmdbResults); + setError(null); + } catch (tmdbErr) { + setError((tmdbErr as Error).message); + setMedia([]); + } + } finally { + setIsLoading(false); + } + }, [mediaId, type, isTVShow, limit, enabled]); + + useEffect(() => { + fetch(); + }, [fetch]); + + return { + media, + isLoading, + error, + refetch: fetch, + }; +} diff --git a/src/pages/discover/lib/personalRecommendations.ts b/src/pages/discover/lib/personalRecommendations.ts index cfbff3e7..481886b7 100644 --- a/src/pages/discover/lib/personalRecommendations.ts +++ b/src/pages/discover/lib/personalRecommendations.ts @@ -78,7 +78,7 @@ function bookmarkToDiscoverMedia(b: BookmarkSource): DiscoverMedia { /** * Fetches similar items from the fed-similar API */ -async function fetchFedSimilarItems( +export async function fetchFedSimilarItems( tmdbId: string, isTVShow: boolean, ): Promise {