From 8016cd683ea0e5933ef5290b7be7cd98ca3ea09f Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:59:39 -0700 Subject: [PATCH] Add For you carousel --- src/assets/locales/en.json | 1 + .../PersonalRecommendationsCarousel.tsx | 146 +++++++++++++++++ src/pages/discover/discoverContent.tsx | 21 +++ .../hooks/usePersonalRecommendations.ts | 145 +++++++++++++++++ .../discover/lib/personalRecommendations.ts | 153 ++++++++++++++++++ 5 files changed, 466 insertions(+) create mode 100644 src/pages/discover/components/PersonalRecommendationsCarousel.tsx create mode 100644 src/pages/discover/hooks/usePersonalRecommendations.ts create mode 100644 src/pages/discover/lib/personalRecommendations.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index b37a0495..0df56f1d 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1424,6 +1424,7 @@ "moviesOn": "Movies on {{provider}}", "tvshowsOn": "Shows on {{provider}}", "recommended": "Because You Watched: {{title}}", + "forYou": "For You", "genreMovies": "{{genre}} Movies", "genreShows": "{{genre}} Shows", "categoryMovies": "{{category}} Movies", diff --git a/src/pages/discover/components/PersonalRecommendationsCarousel.tsx b/src/pages/discover/components/PersonalRecommendationsCarousel.tsx new file mode 100644 index 00000000..a2f1e294 --- /dev/null +++ b/src/pages/discover/components/PersonalRecommendationsCarousel.tsx @@ -0,0 +1,146 @@ +import React, { useRef } from "react"; + +import { MediaCard } from "@/components/media/MediaCard"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import type { DiscoverMedia } from "@/pages/discover/types/discover"; +import { MediaItem } from "@/utils/mediaTypes"; + +import { CarouselNavButtons } from "./CarouselNavButtons"; +import { usePersonalRecommendations } from "../hooks/usePersonalRecommendations"; + +interface PersonalRecommendationsCarouselProps { + isTVShow: boolean; + carouselRefs: React.MutableRefObject<{ + [key: string]: HTMLDivElement | null; + }>; + onShowDetails?: (media: MediaItem) => void; +} + +function getPosterUrl(posterPath: string): string { + if (!posterPath) return "/placeholder.png"; + if (posterPath.startsWith("http")) return posterPath; + return `https://image.tmdb.org/t/p/w342${posterPath}`; +} + +function discoverMediaToCardMedia( + item: DiscoverMedia, + isTVShow: boolean, +): MediaItem { + return { + id: item.id.toString(), + title: item.title || item.name || "", + poster: getPosterUrl(item.poster_path), + type: isTVShow ? "show" : "movie", + year: isTVShow + ? item.first_air_date + ? parseInt(item.first_air_date.split("-")[0]!, 10) + : undefined + : item.release_date + ? parseInt(item.release_date.split("-")[0]!, 10) + : undefined, + }; +} + +export function PersonalRecommendationsCarousel({ + isTVShow, + carouselRefs, + onShowDetails, +}: PersonalRecommendationsCarouselProps) { + const { isMobile } = useIsMobile(); + const isScrollingRef = useRef(false); + const browser = !!window.chrome; + + const { media, isLoading, sectionTitle, hasRecommendations } = + usePersonalRecommendations({ isTVShow, enabled: true }); + + const categorySlug = `for-you-${isTVShow ? "tv" : "movie"}`; + + const handleWheel = React.useCallback( + (e: React.WheelEvent) => { + if (isScrollingRef.current) return; + isScrollingRef.current = true; + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + e.stopPropagation(); + e.preventDefault(); + } + if (browser) { + setTimeout(() => { + isScrollingRef.current = false; + }, 345); + } else { + isScrollingRef.current = false; + } + }, + [browser], + ); + + if (!hasRecommendations) return null; + + return ( +
+
+
+

+ {sectionTitle} +

+
+
+
+
{ + carouselRefs.current[categorySlug] = el; + }} + onWheel={handleWheel} + > +
+ + {isLoading + ? Array.from({ length: 10 }, (_, i) => `for-you-skeleton-${i}`).map( + (skeletonId) => ( +
+ +
+ ), + ) + : media.map((item) => ( +
) => + e.preventDefault() + } + key={item.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" + > + +
+ ))} + +
+
+ + {!isMobile && ( + + )} +
+
+ ); +} diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index 1d2c9f99..75ba4fd2 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -13,6 +13,7 @@ import { MediaItem } from "@/utils/mediaTypes"; import { DiscoverNavigation } from "./components/DiscoverNavigation"; import type { FeaturedMedia } from "./components/FeaturedCarousel"; import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; +import { PersonalRecommendationsCarousel } from "./components/PersonalRecommendationsCarousel"; import { ScrollToTopButton } from "./components/ScrollToTopButton"; export function DiscoverContent() { @@ -49,6 +50,16 @@ export function DiscoverContent() { const renderMoviesContent = () => { const carousels = []; + // For You - personal recommendations from watch history, progress, and bookmarks + carousels.push( + , + ); + // Movie Recommendations - only show if there are movie progress items if (movieProgressItems.length > 0) { carousels.push( @@ -137,6 +148,16 @@ export function DiscoverContent() { const renderTVShowsContent = () => { const carousels = []; + // For You - personal recommendations from watch history, progress, and bookmarks + carousels.push( + , + ); + // TV Show Recommendations - only show if there are TV show progress items if (tvProgressItems.length > 0) { carousels.push( diff --git a/src/pages/discover/hooks/usePersonalRecommendations.ts b/src/pages/discover/hooks/usePersonalRecommendations.ts new file mode 100644 index 00000000..ba42b219 --- /dev/null +++ b/src/pages/discover/hooks/usePersonalRecommendations.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { DiscoverMedia } from "@/pages/discover/types/discover"; +import { useBookmarkStore } from "@/stores/bookmarks"; +import { useProgressStore } from "@/stores/progress"; +import { useWatchHistoryStore } from "@/stores/watchHistory"; + +import { + type BookmarkSource, + type HistorySource, + type ProgressSource, + fetchPersonalRecommendations, +} from "../lib/personalRecommendations"; + +export interface UsePersonalRecommendationsOptions { + isTVShow: boolean; + enabled?: boolean; +} + +export interface UsePersonalRecommendationsReturn { + media: DiscoverMedia[]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; + sectionTitle: string; + hasRecommendations: boolean; +} + +function getHistorySources( + items: Record, +): HistorySource[] { + const byKey: Map = new Map(); + + for (const [key, item] of Object.entries(items)) { + const isEpisode = key.includes("-"); + const tmdbId = isEpisode ? key.split("-")[0]! : key; + const existing = byKey.get(tmdbId); + const watchedAt = item.watchedAt; + if (!existing || watchedAt > existing.watchedAt) { + byKey.set(tmdbId, { tmdbId, type: item.type, watchedAt }); + } + } + + return Array.from(byKey.values()).sort((a, b) => b.watchedAt - a.watchedAt); +} + +export function usePersonalRecommendations({ + isTVShow, + enabled = true, +}: UsePersonalRecommendationsOptions): UsePersonalRecommendationsReturn { + const { t } = useTranslation(); + const [media, setMedia] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const watchHistoryItems = useWatchHistoryStore((s) => s.items); + const progressItems = useProgressStore.getState().items; + const bookmarks = useBookmarkStore((s) => s.bookmarks); + + const buildExcludeSet = useCallback(() => { + const exclude = new Set(); + for (const key of Object.keys(watchHistoryItems)) { + if (key.includes("-")) exclude.add(key.split("-")[0]!); + else exclude.add(key); + } + for (const id of Object.keys(progressItems)) exclude.add(id); + for (const id of Object.keys(bookmarks)) exclude.add(id); + return exclude; + }, [watchHistoryItems, progressItems, bookmarks]); + + const fetch = useCallback(async () => { + const history: HistorySource[] = getHistorySources(watchHistoryItems); + const progress: ProgressSource[] = Object.entries(progressItems).map( + ([tmdbId, item]) => ({ tmdbId, type: item.type }), + ); + const bookmarkList: BookmarkSource[] = Object.entries(bookmarks).map( + ([tmdbId, item]) => ({ + tmdbId, + type: item.type, + title: item.title, + year: item.year, + poster: item.poster, + }), + ); + + const hasAnySource = + history.some((h) => h.type === (isTVShow ? "show" : "movie")) || + progress.some((p) => p.type === (isTVShow ? "show" : "movie")) || + bookmarkList.some((b) => b.type === (isTVShow ? "show" : "movie")); + + if (!hasAnySource) { + setMedia([]); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const excludeIds = buildExcludeSet(); + const results = await fetchPersonalRecommendations( + isTVShow, + history, + progress, + bookmarkList, + excludeIds, + ); + setMedia(results); + } catch (err) { + setError((err as Error).message); + setMedia([]); + } finally { + setIsLoading(false); + } + }, [isTVShow, watchHistoryItems, progressItems, bookmarks, buildExcludeSet]); + + useEffect(() => { + if (enabled) fetch(); + }, [enabled, fetch]); + + const historyCount = getHistorySources(watchHistoryItems).filter( + (h) => h.type === (isTVShow ? "show" : "movie"), + ).length; + const progressCount = Object.values(progressItems).filter( + (p) => p.type === (isTVShow ? "show" : "movie"), + ).length; + const bookmarkCount = Object.values(bookmarks).filter( + (b) => b.type === (isTVShow ? "show" : "movie"), + ).length; + const hasRecommendations = + historyCount > 0 || progressCount > 0 || bookmarkCount > 0; + + const sectionTitle = t("discover.carousel.title.forYou"); + + return { + media, + isLoading, + error, + refetch: fetch, + sectionTitle, + hasRecommendations, + }; +} diff --git a/src/pages/discover/lib/personalRecommendations.ts b/src/pages/discover/lib/personalRecommendations.ts new file mode 100644 index 00000000..7870d757 --- /dev/null +++ b/src/pages/discover/lib/personalRecommendations.ts @@ -0,0 +1,153 @@ +import { getRelatedMedia } from "@/backend/metadata/tmdb"; +import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; +import type { + TMDBMovieSearchResult, + TMDBShowSearchResult, +} from "@/backend/metadata/types/tmdb"; +import type { DiscoverMedia } from "@/pages/discover/types/discover"; + +// Tuning constants for the recommendation algorithm +export const MAX_HISTORY_FOR_RELATED = 5; +export const MAX_CURRENT_FOR_RELATED = 2; +export const MAX_BOOKMARK_FOR_RELATED = 1; +export const MAX_BOOKMARK_REMINDERS = 2; +export const RELATED_PER_ITEM_LIMIT = 10; + +export interface HistorySource { + tmdbId: string; + type: "movie" | "show"; + watchedAt: number; +} + +export interface ProgressSource { + tmdbId: string; + type: "movie" | "show"; +} + +export interface BookmarkSource { + tmdbId: string; + type: "movie" | "show"; + title: string; + year?: number; + poster?: string; +} + +function toDiscoverMedia( + item: TMDBMovieSearchResult | TMDBShowSearchResult, + isTVShow: boolean, +): DiscoverMedia { + const isMovie = !isTVShow; + return { + id: item.id, + title: isMovie + ? (item as TMDBMovieSearchResult).title + : (item as TMDBShowSearchResult).name, + name: isTVShow ? (item as TMDBShowSearchResult).name : undefined, + poster_path: item.poster_path ?? "", + backdrop_path: item.backdrop_path ?? "", + overview: item.overview ?? "", + vote_average: item.vote_average ?? 0, + vote_count: item.vote_count ?? 0, + type: isTVShow ? "show" : "movie", + release_date: isMovie + ? (item as TMDBMovieSearchResult).release_date + : undefined, + first_air_date: isTVShow + ? (item as TMDBShowSearchResult).first_air_date + : undefined, + }; +} + +function bookmarkToDiscoverMedia(b: BookmarkSource): DiscoverMedia { + return { + id: Number(b.tmdbId), + title: b.title, + poster_path: b.poster ?? "", + backdrop_path: "", + overview: "", + vote_average: 0, + vote_count: 0, + type: b.type, + release_date: b.year ? `${b.year}-01-01` : undefined, + first_air_date: b.year ? `${b.year}-01-01` : undefined, + }; +} + +/** + * 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 + * 2. Merging and deduping, excluding items already in history/progress/bookmarks + * 3. Adding up to MAX_BOOKMARK_REMINDERS bookmarked items as "reminders" + */ +export async function fetchPersonalRecommendations( + isTVShow: boolean, + history: HistorySource[], + progress: ProgressSource[], + bookmarks: BookmarkSource[], + excludeIds: Set, +): Promise { + const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE; + + const historyFiltered = history + .filter((h) => h.type === (isTVShow ? "show" : "movie")) + .sort((a, b) => b.watchedAt - a.watchedAt) + .slice(0, MAX_HISTORY_FOR_RELATED); + + const progressFiltered = progress + .filter((p) => p.type === (isTVShow ? "show" : "movie")) + .slice(0, MAX_CURRENT_FOR_RELATED); + + const bookmarksFiltered = bookmarks.filter( + (b) => b.type === (isTVShow ? "show" : "movie"), + ); + + const sourceIds: string[] = []; + const seenSources = new Set(); + + for (const h of historyFiltered) { + if (!seenSources.has(h.tmdbId)) { + seenSources.add(h.tmdbId); + sourceIds.push(h.tmdbId); + } + } + for (const p of progressFiltered) { + if (!seenSources.has(p.tmdbId)) { + seenSources.add(p.tmdbId); + sourceIds.push(p.tmdbId); + } + } + for (const b of bookmarksFiltered.slice(0, MAX_BOOKMARK_FOR_RELATED)) { + if (!seenSources.has(b.tmdbId)) { + seenSources.add(b.tmdbId); + sourceIds.push(b.tmdbId); + } + } + + const relatedPromises = sourceIds.map((id) => + getRelatedMedia(id, type, RELATED_PER_ITEM_LIMIT), + ); + + const relatedResults = await Promise.allSettled(relatedPromises); + const merged: DiscoverMedia[] = []; + const seenIds = 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; + seenIds.add(item.id); + merged.push(toDiscoverMedia(item, isTVShow)); + } + } + + const reminders: DiscoverMedia[] = []; + for (const b of bookmarksFiltered) { + if (excludeIds.has(b.tmdbId) || seenIds.has(Number(b.tmdbId))) continue; + if (reminders.length >= MAX_BOOKMARK_REMINDERS) break; + seenIds.add(Number(b.tmdbId)); + reminders.push(bookmarkToDiscoverMedia(b)); + } + + return [...reminders, ...merged]; +}