mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
Add For you carousel
This commit is contained in:
parent
ce5bcace5b
commit
8016cd683e
5 changed files with 466 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
145
src/pages/discover/hooks/usePersonalRecommendations.ts
Normal file
145
src/pages/discover/hooks/usePersonalRecommendations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
153
src/pages/discover/lib/personalRecommendations.ts
Normal file
153
src/pages/discover/lib/personalRecommendations.ts
Normal 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];
|
||||
}
|
||||
Loading…
Reference in a new issue