Add For you carousel

This commit is contained in:
Pas 2026-02-08 21:59:39 -07:00
parent ce5bcace5b
commit 8016cd683e
5 changed files with 466 additions and 0 deletions

View file

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

View file

@ -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 (
<div>
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex flex-col pl-2 lg:pl-[68px]">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-0 text-balance">
{sectionTitle}
</h2>
</div>
</div>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[categorySlug] = el;
}}
onWheel={handleWheel}
>
<div className="lg:w-12" />
{isLoading
? Array.from({ length: 10 }, (_, i) => `for-you-skeleton-${i}`).map(
(skeletonId) => (
<div
key={skeletonId}
className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
>
<MediaCard
media={{
id: skeletonId,
title: "",
poster: "",
type: isTVShow ? "show" : "movie",
}}
forceSkeleton
/>
</div>
),
)
: media.map((item) => (
<div
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
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"
>
<MediaCard
linkable
media={discoverMediaToCardMedia(item, isTVShow)}
onShowDetails={onShowDetails}
/>
</div>
))}
<div className="lg:w-12" />
</div>
{!isMobile && (
<CarouselNavButtons
categorySlug={categorySlug}
carouselRefs={carouselRefs}
/>
)}
</div>
</div>
);
}

View file

@ -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(
<PersonalRecommendationsCarousel
key="movie-for-you"
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>,
);
// 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(
<PersonalRecommendationsCarousel
key="tv-for-you"
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>,
);
// TV Show Recommendations - only show if there are TV show progress items
if (tvProgressItems.length > 0) {
carousels.push(

View file

@ -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<void>;
sectionTitle: string;
hasRecommendations: boolean;
}
function getHistorySources(
items: Record<string, { type: "movie" | "show"; watchedAt: number }>,
): HistorySource[] {
const byKey: Map<string, HistorySource> = 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<DiscoverMedia[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string>();
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,
};
}

View file

@ -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<string>,
): Promise<DiscoverMedia[]> {
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<string>();
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<number>([]);
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];
}