pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

Add For you carousel

Pas 8016cd68 ce5bcace

+466
+1
src/assets/locales/en.json
··· 1424 "moviesOn": "Movies on {{provider}}", 1425 "tvshowsOn": "Shows on {{provider}}", 1426 "recommended": "Because You Watched: {{title}}", 1427 "genreMovies": "{{genre}} Movies", 1428 "genreShows": "{{genre}} Shows", 1429 "categoryMovies": "{{category}} Movies",
··· 1424 "moviesOn": "Movies on {{provider}}", 1425 "tvshowsOn": "Shows on {{provider}}", 1426 "recommended": "Because You Watched: {{title}}", 1427 + "forYou": "For You", 1428 "genreMovies": "{{genre}} Movies", 1429 "genreShows": "{{genre}} Shows", 1430 "categoryMovies": "{{category}} Movies",
+146
src/pages/discover/components/PersonalRecommendationsCarousel.tsx
···
··· 1 + import React, { useRef } from "react"; 2 + 3 + import { MediaCard } from "@/components/media/MediaCard"; 4 + import { useIsMobile } from "@/hooks/useIsMobile"; 5 + import type { DiscoverMedia } from "@/pages/discover/types/discover"; 6 + import { MediaItem } from "@/utils/mediaTypes"; 7 + 8 + import { CarouselNavButtons } from "./CarouselNavButtons"; 9 + import { usePersonalRecommendations } from "../hooks/usePersonalRecommendations"; 10 + 11 + interface PersonalRecommendationsCarouselProps { 12 + isTVShow: boolean; 13 + carouselRefs: React.MutableRefObject<{ 14 + [key: string]: HTMLDivElement | null; 15 + }>; 16 + onShowDetails?: (media: MediaItem) => void; 17 + } 18 + 19 + function getPosterUrl(posterPath: string): string { 20 + if (!posterPath) return "/placeholder.png"; 21 + if (posterPath.startsWith("http")) return posterPath; 22 + return `https://image.tmdb.org/t/p/w342${posterPath}`; 23 + } 24 + 25 + function discoverMediaToCardMedia( 26 + item: DiscoverMedia, 27 + isTVShow: boolean, 28 + ): MediaItem { 29 + return { 30 + id: item.id.toString(), 31 + title: item.title || item.name || "", 32 + poster: getPosterUrl(item.poster_path), 33 + type: isTVShow ? "show" : "movie", 34 + year: isTVShow 35 + ? item.first_air_date 36 + ? parseInt(item.first_air_date.split("-")[0]!, 10) 37 + : undefined 38 + : item.release_date 39 + ? parseInt(item.release_date.split("-")[0]!, 10) 40 + : undefined, 41 + }; 42 + } 43 + 44 + export function PersonalRecommendationsCarousel({ 45 + isTVShow, 46 + carouselRefs, 47 + onShowDetails, 48 + }: PersonalRecommendationsCarouselProps) { 49 + const { isMobile } = useIsMobile(); 50 + const isScrollingRef = useRef(false); 51 + const browser = !!window.chrome; 52 + 53 + const { media, isLoading, sectionTitle, hasRecommendations } = 54 + usePersonalRecommendations({ isTVShow, enabled: true }); 55 + 56 + const categorySlug = `for-you-${isTVShow ? "tv" : "movie"}`; 57 + 58 + const handleWheel = React.useCallback( 59 + (e: React.WheelEvent) => { 60 + if (isScrollingRef.current) return; 61 + isScrollingRef.current = true; 62 + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { 63 + e.stopPropagation(); 64 + e.preventDefault(); 65 + } 66 + if (browser) { 67 + setTimeout(() => { 68 + isScrollingRef.current = false; 69 + }, 345); 70 + } else { 71 + isScrollingRef.current = false; 72 + } 73 + }, 74 + [browser], 75 + ); 76 + 77 + if (!hasRecommendations) return null; 78 + 79 + return ( 80 + <div> 81 + <div className="flex items-center justify-between ml-2 md:ml-8 mt-2"> 82 + <div className="flex flex-col pl-2 lg:pl-[68px]"> 83 + <h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-0 text-balance"> 84 + {sectionTitle} 85 + </h2> 86 + </div> 87 + </div> 88 + <div className="relative overflow-hidden carousel-container md:pb-4"> 89 + <div 90 + id={`carousel-${categorySlug}`} 91 + 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" 92 + ref={(el) => { 93 + carouselRefs.current[categorySlug] = el; 94 + }} 95 + onWheel={handleWheel} 96 + > 97 + <div className="lg:w-12" /> 98 + 99 + {isLoading 100 + ? Array.from({ length: 10 }, (_, i) => `for-you-skeleton-${i}`).map( 101 + (skeletonId) => ( 102 + <div 103 + key={skeletonId} 104 + 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" 105 + > 106 + <MediaCard 107 + media={{ 108 + id: skeletonId, 109 + title: "", 110 + poster: "", 111 + type: isTVShow ? "show" : "movie", 112 + }} 113 + forceSkeleton 114 + /> 115 + </div> 116 + ), 117 + ) 118 + : media.map((item) => ( 119 + <div 120 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 121 + e.preventDefault() 122 + } 123 + key={item.id} 124 + 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" 125 + > 126 + <MediaCard 127 + linkable 128 + media={discoverMediaToCardMedia(item, isTVShow)} 129 + onShowDetails={onShowDetails} 130 + /> 131 + </div> 132 + ))} 133 + 134 + <div className="lg:w-12" /> 135 + </div> 136 + 137 + {!isMobile && ( 138 + <CarouselNavButtons 139 + categorySlug={categorySlug} 140 + carouselRefs={carouselRefs} 141 + /> 142 + )} 143 + </div> 144 + </div> 145 + ); 146 + }
+21
src/pages/discover/discoverContent.tsx
··· 13 import { DiscoverNavigation } from "./components/DiscoverNavigation"; 14 import type { FeaturedMedia } from "./components/FeaturedCarousel"; 15 import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; 16 import { ScrollToTopButton } from "./components/ScrollToTopButton"; 17 18 export function DiscoverContent() { ··· 48 // Render Movies content with lazy loading 49 const renderMoviesContent = () => { 50 const carousels = []; 51 52 // Movie Recommendations - only show if there are movie progress items 53 if (movieProgressItems.length > 0) { ··· 136 // Render TV Shows content with lazy loading 137 const renderTVShowsContent = () => { 138 const carousels = []; 139 140 // TV Show Recommendations - only show if there are TV show progress items 141 if (tvProgressItems.length > 0) {
··· 13 import { DiscoverNavigation } from "./components/DiscoverNavigation"; 14 import type { FeaturedMedia } from "./components/FeaturedCarousel"; 15 import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; 16 + import { PersonalRecommendationsCarousel } from "./components/PersonalRecommendationsCarousel"; 17 import { ScrollToTopButton } from "./components/ScrollToTopButton"; 18 19 export function DiscoverContent() { ··· 49 // Render Movies content with lazy loading 50 const renderMoviesContent = () => { 51 const carousels = []; 52 + 53 + // For You - personal recommendations from watch history, progress, and bookmarks 54 + carousels.push( 55 + <PersonalRecommendationsCarousel 56 + key="movie-for-you" 57 + isTVShow={false} 58 + carouselRefs={carouselRefs} 59 + onShowDetails={handleShowDetails} 60 + />, 61 + ); 62 63 // Movie Recommendations - only show if there are movie progress items 64 if (movieProgressItems.length > 0) { ··· 147 // Render TV Shows content with lazy loading 148 const renderTVShowsContent = () => { 149 const carousels = []; 150 + 151 + // For You - personal recommendations from watch history, progress, and bookmarks 152 + carousels.push( 153 + <PersonalRecommendationsCarousel 154 + key="tv-for-you" 155 + isTVShow 156 + carouselRefs={carouselRefs} 157 + onShowDetails={handleShowDetails} 158 + />, 159 + ); 160 161 // TV Show Recommendations - only show if there are TV show progress items 162 if (tvProgressItems.length > 0) {
+145
src/pages/discover/hooks/usePersonalRecommendations.ts
···
··· 1 + import { useCallback, useEffect, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import type { DiscoverMedia } from "@/pages/discover/types/discover"; 5 + import { useBookmarkStore } from "@/stores/bookmarks"; 6 + import { useProgressStore } from "@/stores/progress"; 7 + import { useWatchHistoryStore } from "@/stores/watchHistory"; 8 + 9 + import { 10 + type BookmarkSource, 11 + type HistorySource, 12 + type ProgressSource, 13 + fetchPersonalRecommendations, 14 + } from "../lib/personalRecommendations"; 15 + 16 + export interface UsePersonalRecommendationsOptions { 17 + isTVShow: boolean; 18 + enabled?: boolean; 19 + } 20 + 21 + export interface UsePersonalRecommendationsReturn { 22 + media: DiscoverMedia[]; 23 + isLoading: boolean; 24 + error: string | null; 25 + refetch: () => Promise<void>; 26 + sectionTitle: string; 27 + hasRecommendations: boolean; 28 + } 29 + 30 + function getHistorySources( 31 + items: Record<string, { type: "movie" | "show"; watchedAt: number }>, 32 + ): HistorySource[] { 33 + const byKey: Map<string, HistorySource> = new Map(); 34 + 35 + for (const [key, item] of Object.entries(items)) { 36 + const isEpisode = key.includes("-"); 37 + const tmdbId = isEpisode ? key.split("-")[0]! : key; 38 + const existing = byKey.get(tmdbId); 39 + const watchedAt = item.watchedAt; 40 + if (!existing || watchedAt > existing.watchedAt) { 41 + byKey.set(tmdbId, { tmdbId, type: item.type, watchedAt }); 42 + } 43 + } 44 + 45 + return Array.from(byKey.values()).sort((a, b) => b.watchedAt - a.watchedAt); 46 + } 47 + 48 + export function usePersonalRecommendations({ 49 + isTVShow, 50 + enabled = true, 51 + }: UsePersonalRecommendationsOptions): UsePersonalRecommendationsReturn { 52 + const { t } = useTranslation(); 53 + const [media, setMedia] = useState<DiscoverMedia[]>([]); 54 + const [isLoading, setIsLoading] = useState(false); 55 + const [error, setError] = useState<string | null>(null); 56 + 57 + const watchHistoryItems = useWatchHistoryStore((s) => s.items); 58 + const progressItems = useProgressStore.getState().items; 59 + const bookmarks = useBookmarkStore((s) => s.bookmarks); 60 + 61 + const buildExcludeSet = useCallback(() => { 62 + const exclude = new Set<string>(); 63 + for (const key of Object.keys(watchHistoryItems)) { 64 + if (key.includes("-")) exclude.add(key.split("-")[0]!); 65 + else exclude.add(key); 66 + } 67 + for (const id of Object.keys(progressItems)) exclude.add(id); 68 + for (const id of Object.keys(bookmarks)) exclude.add(id); 69 + return exclude; 70 + }, [watchHistoryItems, progressItems, bookmarks]); 71 + 72 + const fetch = useCallback(async () => { 73 + const history: HistorySource[] = getHistorySources(watchHistoryItems); 74 + const progress: ProgressSource[] = Object.entries(progressItems).map( 75 + ([tmdbId, item]) => ({ tmdbId, type: item.type }), 76 + ); 77 + const bookmarkList: BookmarkSource[] = Object.entries(bookmarks).map( 78 + ([tmdbId, item]) => ({ 79 + tmdbId, 80 + type: item.type, 81 + title: item.title, 82 + year: item.year, 83 + poster: item.poster, 84 + }), 85 + ); 86 + 87 + const hasAnySource = 88 + history.some((h) => h.type === (isTVShow ? "show" : "movie")) || 89 + progress.some((p) => p.type === (isTVShow ? "show" : "movie")) || 90 + bookmarkList.some((b) => b.type === (isTVShow ? "show" : "movie")); 91 + 92 + if (!hasAnySource) { 93 + setMedia([]); 94 + setError(null); 95 + return; 96 + } 97 + 98 + setIsLoading(true); 99 + setError(null); 100 + 101 + try { 102 + const excludeIds = buildExcludeSet(); 103 + const results = await fetchPersonalRecommendations( 104 + isTVShow, 105 + history, 106 + progress, 107 + bookmarkList, 108 + excludeIds, 109 + ); 110 + setMedia(results); 111 + } catch (err) { 112 + setError((err as Error).message); 113 + setMedia([]); 114 + } finally { 115 + setIsLoading(false); 116 + } 117 + }, [isTVShow, watchHistoryItems, progressItems, bookmarks, buildExcludeSet]); 118 + 119 + useEffect(() => { 120 + if (enabled) fetch(); 121 + }, [enabled, fetch]); 122 + 123 + const historyCount = getHistorySources(watchHistoryItems).filter( 124 + (h) => h.type === (isTVShow ? "show" : "movie"), 125 + ).length; 126 + const progressCount = Object.values(progressItems).filter( 127 + (p) => p.type === (isTVShow ? "show" : "movie"), 128 + ).length; 129 + const bookmarkCount = Object.values(bookmarks).filter( 130 + (b) => b.type === (isTVShow ? "show" : "movie"), 131 + ).length; 132 + const hasRecommendations = 133 + historyCount > 0 || progressCount > 0 || bookmarkCount > 0; 134 + 135 + const sectionTitle = t("discover.carousel.title.forYou"); 136 + 137 + return { 138 + media, 139 + isLoading, 140 + error, 141 + refetch: fetch, 142 + sectionTitle, 143 + hasRecommendations, 144 + }; 145 + }
+153
src/pages/discover/lib/personalRecommendations.ts
···
··· 1 + import { getRelatedMedia } from "@/backend/metadata/tmdb"; 2 + import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; 3 + import type { 4 + TMDBMovieSearchResult, 5 + TMDBShowSearchResult, 6 + } from "@/backend/metadata/types/tmdb"; 7 + import type { DiscoverMedia } from "@/pages/discover/types/discover"; 8 + 9 + // Tuning constants for the recommendation algorithm 10 + export const MAX_HISTORY_FOR_RELATED = 5; 11 + export const MAX_CURRENT_FOR_RELATED = 2; 12 + export const MAX_BOOKMARK_FOR_RELATED = 1; 13 + export const MAX_BOOKMARK_REMINDERS = 2; 14 + export const RELATED_PER_ITEM_LIMIT = 10; 15 + 16 + export interface HistorySource { 17 + tmdbId: string; 18 + type: "movie" | "show"; 19 + watchedAt: number; 20 + } 21 + 22 + export interface ProgressSource { 23 + tmdbId: string; 24 + type: "movie" | "show"; 25 + } 26 + 27 + export interface BookmarkSource { 28 + tmdbId: string; 29 + type: "movie" | "show"; 30 + title: string; 31 + year?: number; 32 + poster?: string; 33 + } 34 + 35 + function toDiscoverMedia( 36 + item: TMDBMovieSearchResult | TMDBShowSearchResult, 37 + isTVShow: boolean, 38 + ): DiscoverMedia { 39 + const isMovie = !isTVShow; 40 + return { 41 + id: item.id, 42 + title: isMovie 43 + ? (item as TMDBMovieSearchResult).title 44 + : (item as TMDBShowSearchResult).name, 45 + name: isTVShow ? (item as TMDBShowSearchResult).name : undefined, 46 + poster_path: item.poster_path ?? "", 47 + backdrop_path: item.backdrop_path ?? "", 48 + overview: item.overview ?? "", 49 + vote_average: item.vote_average ?? 0, 50 + vote_count: item.vote_count ?? 0, 51 + type: isTVShow ? "show" : "movie", 52 + release_date: isMovie 53 + ? (item as TMDBMovieSearchResult).release_date 54 + : undefined, 55 + first_air_date: isTVShow 56 + ? (item as TMDBShowSearchResult).first_air_date 57 + : undefined, 58 + }; 59 + } 60 + 61 + function bookmarkToDiscoverMedia(b: BookmarkSource): DiscoverMedia { 62 + return { 63 + id: Number(b.tmdbId), 64 + title: b.title, 65 + poster_path: b.poster ?? "", 66 + backdrop_path: "", 67 + overview: "", 68 + vote_average: 0, 69 + vote_count: 0, 70 + type: b.type, 71 + release_date: b.year ? `${b.year}-01-01` : undefined, 72 + first_air_date: b.year ? `${b.year}-01-01` : undefined, 73 + }; 74 + } 75 + 76 + /** 77 + * Fetches personal recommendations by: 78 + * 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 79 + * 2. Merging and deduping, excluding items already in history/progress/bookmarks 80 + * 3. Adding up to MAX_BOOKMARK_REMINDERS bookmarked items as "reminders" 81 + */ 82 + export async function fetchPersonalRecommendations( 83 + isTVShow: boolean, 84 + history: HistorySource[], 85 + progress: ProgressSource[], 86 + bookmarks: BookmarkSource[], 87 + excludeIds: Set<string>, 88 + ): Promise<DiscoverMedia[]> { 89 + const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE; 90 + 91 + const historyFiltered = history 92 + .filter((h) => h.type === (isTVShow ? "show" : "movie")) 93 + .sort((a, b) => b.watchedAt - a.watchedAt) 94 + .slice(0, MAX_HISTORY_FOR_RELATED); 95 + 96 + const progressFiltered = progress 97 + .filter((p) => p.type === (isTVShow ? "show" : "movie")) 98 + .slice(0, MAX_CURRENT_FOR_RELATED); 99 + 100 + const bookmarksFiltered = bookmarks.filter( 101 + (b) => b.type === (isTVShow ? "show" : "movie"), 102 + ); 103 + 104 + const sourceIds: string[] = []; 105 + const seenSources = new Set<string>(); 106 + 107 + for (const h of historyFiltered) { 108 + if (!seenSources.has(h.tmdbId)) { 109 + seenSources.add(h.tmdbId); 110 + sourceIds.push(h.tmdbId); 111 + } 112 + } 113 + for (const p of progressFiltered) { 114 + if (!seenSources.has(p.tmdbId)) { 115 + seenSources.add(p.tmdbId); 116 + sourceIds.push(p.tmdbId); 117 + } 118 + } 119 + for (const b of bookmarksFiltered.slice(0, MAX_BOOKMARK_FOR_RELATED)) { 120 + if (!seenSources.has(b.tmdbId)) { 121 + seenSources.add(b.tmdbId); 122 + sourceIds.push(b.tmdbId); 123 + } 124 + } 125 + 126 + const relatedPromises = sourceIds.map((id) => 127 + getRelatedMedia(id, type, RELATED_PER_ITEM_LIMIT), 128 + ); 129 + 130 + const relatedResults = await Promise.allSettled(relatedPromises); 131 + const merged: DiscoverMedia[] = []; 132 + const seenIds = new Set<number>([]); 133 + 134 + for (const result of relatedResults) { 135 + if (result.status !== "fulfilled" || !result.value) continue; 136 + for (const item of result.value) { 137 + const idStr = String(item.id); 138 + if (excludeIds.has(idStr) || seenIds.has(item.id)) continue; 139 + seenIds.add(item.id); 140 + merged.push(toDiscoverMedia(item, isTVShow)); 141 + } 142 + } 143 + 144 + const reminders: DiscoverMedia[] = []; 145 + for (const b of bookmarksFiltered) { 146 + if (excludeIds.has(b.tmdbId) || seenIds.has(Number(b.tmdbId))) continue; 147 + if (reminders.length >= MAX_BOOKMARK_REMINDERS) break; 148 + seenIds.add(Number(b.tmdbId)); 149 + reminders.push(bookmarkToDiscoverMedia(b)); 150 + } 151 + 152 + return [...reminders, ...merged]; 153 + }