new use similar media

This commit is contained in:
Pas 2026-02-15 11:51:59 -07:00
parent 5b73ca114e
commit fbd5cfc93c
4 changed files with 302 additions and 60 deletions

View file

@ -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<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const carouselRef = useRef<HTMLDivElement>(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({
</div>
))
: // Show actual media cards when loaded
similarMedia.map((media) => (
<div
key={media.id}
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
style={{ scrollSnapAlign: "start" }}
>
<MediaCard
media={media}
linkable
onShowDetails={handleShowDetails}
/>
</div>
))}
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 (
<div
key={media.id}
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
style={{ scrollSnapAlign: "start" }}
>
<MediaCard
media={item}
linkable
onShowDetails={handleShowDetails}
/>
</div>
);
})}
<div className="md:w-12" />
</div>

View file

@ -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,

View file

@ -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<TMDBMovieSearchResult[] | TMDBShowSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View file

@ -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<string[]> {