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];
+}