diff --git a/src/pages/discover/lib/personalRecommendations.ts b/src/pages/discover/lib/personalRecommendations.ts index 7870d757..cfbff3e7 100644 --- a/src/pages/discover/lib/personalRecommendations.ts +++ b/src/pages/discover/lib/personalRecommendations.ts @@ -1,7 +1,9 @@ -import { getRelatedMedia } from "@/backend/metadata/tmdb"; +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 type { DiscoverMedia } from "@/pages/discover/types/discover"; @@ -73,9 +75,29 @@ function bookmarkToDiscoverMedia(b: BookmarkSource): DiscoverMedia { }; } +/** + * Fetches similar items from the fed-similar API + */ +async function fetchFedSimilarItems( + tmdbId: string, + isTVShow: boolean, +): Promise { + try { + const endpoint = isTVShow + ? `https://fed-similar.up.railway.app/tv/${tmdbId}` + : `https://fed-similar.up.railway.app/movie/${tmdbId}`; + const response = await fetch(endpoint); + if (!response.ok) return []; + const items = await response.json(); + return Array.isArray(items) ? items : []; + } catch (error) { + return []; + } +} + /** * Fetches personal recommendations by: - * 1. Getting related media for up to MAX_HISTORY_FOR_RELATED history items, MAX_CURRENT_FOR_RELATED progress items, and MAX_BOOKMARK_FOR_RELATED bookmark + * 1. Getting related media from fed-similar API and TMDB for history, progress, and bookmark items * 2. Merging and deduping, excluding items already in history/progress/bookmarks * 3. Adding up to MAX_BOOKMARK_REMINDERS bookmarked items as "reminders" */ @@ -123,21 +145,104 @@ export async function fetchPersonalRecommendations( } } - const relatedPromises = sourceIds.map((id) => + // Fetch from both fed-similar API and TMDB + const fedSimilarPromises = sourceIds.map((id) => + fetchFedSimilarItems(id, isTVShow), + ); + + const tmdbPromises = sourceIds.map((id) => getRelatedMedia(id, type, RELATED_PER_ITEM_LIMIT), ); - const relatedResults = await Promise.allSettled(relatedPromises); + const [fedSimilarResults, tmdbResults] = await Promise.allSettled([ + Promise.all(fedSimilarPromises), + Promise.all(tmdbPromises), + ]); + const merged: DiscoverMedia[] = []; const seenIds = new Set([]); + const seenFedSimilarIds = new Set(); - for (const result of relatedResults) { - if (result.status !== "fulfilled" || !result.value) continue; - for (const item of result.value) { - const idStr = String(item.id); - if (excludeIds.has(idStr) || seenIds.has(item.id)) continue; + // Process fed-similar results first (higher priority) + if (fedSimilarResults.status === "fulfilled") { + for (const fedSimilarItems of fedSimilarResults.value) { + for (const tmdbId of fedSimilarItems) { + if (excludeIds.has(tmdbId) || seenFedSimilarIds.has(tmdbId)) { + continue; + } + seenFedSimilarIds.add(tmdbId); + } + } + + // Fetch full details for fed-similar items + const fedSimilarDetailPromises = Array.from(seenFedSimilarIds) + .slice(0, 20) + .map((tmdbId) => getMediaDetails(tmdbId, type)); + + const fedSimilarDetails = await Promise.allSettled( + fedSimilarDetailPromises, + ); + + for (const result of fedSimilarDetails) { + if (result.status !== "fulfilled" || !result.value) continue; + const item = result.value as TMDBMovieData | TMDBShowData; + if (seenIds.has(item.id)) continue; seenIds.add(item.id); - merged.push(toDiscoverMedia(item, isTVShow)); + + 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, + }; + } + + merged.push(toDiscoverMedia(searchItem, isTVShow)); + } + } + + // Process TMDB results (lower priority) + if (tmdbResults.status === "fulfilled") { + for (const result of tmdbResults.value) { + for (const item of result) { + const idStr = String(item.id); + if (excludeIds.has(idStr) || seenIds.has(item.id)) continue; + seenIds.add(item.id); + merged.push(toDiscoverMedia(item, isTVShow)); + } } }