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

Configure Feed

Select the types of activity you want to include in your feed.

new use similar media

Pas fbd5cfc9 5b73ca11

+302 -60
+45 -55
src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx
··· 1 - import { useEffect, useRef, useState } from "react"; 1 + import { useEffect, useRef } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 3 4 - import { getMediaPoster, getRelatedMedia } from "@/backend/metadata/tmdb"; 5 4 import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; 6 5 import { MediaCard, MediaCardSkeleton } from "@/components/media/MediaCard"; 7 6 import { useIsMobile } from "@/hooks/useIsMobile"; 8 7 import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; 8 + import { useSimilarMedia } from "@/pages/discover/hooks/useSimilarMedia"; 9 9 import { useOverlayStack } from "@/stores/interface/overlayStack"; 10 10 import { MediaItem } from "@/utils/mediaTypes"; 11 11 ··· 21 21 const { t } = useTranslation(); 22 22 const { isMobile } = useIsMobile(); 23 23 const { showModal } = useOverlayStack(); 24 - const [similarMedia, setSimilarMedia] = useState<MediaItem[]>([]); 25 - const [isLoading, setIsLoading] = useState(true); 26 24 const carouselRef = useRef<HTMLDivElement>(null); 27 25 const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({ 28 26 similar: null, 29 27 }); 30 28 31 - useEffect(() => { 32 - const loadSimilarMedia = async () => { 33 - setIsLoading(true); 34 - try { 35 - const results = await getRelatedMedia(mediaId, mediaType, 12); 36 - const mediaItems: MediaItem[] = results.map((result) => { 37 - const isMovie = "title" in result; 38 - return { 39 - id: result.id.toString(), 40 - title: isMovie ? result.title : result.name, 41 - poster: getMediaPoster(result.poster_path) || "/placeholder.png", 42 - type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show", 43 - year: isMovie 44 - ? result.release_date 45 - ? new Date(result.release_date).getFullYear() 46 - : 0 47 - : result.first_air_date 48 - ? new Date(result.first_air_date).getFullYear() 49 - : 0, 50 - release_date: isMovie 51 - ? result.release_date 52 - ? new Date(result.release_date) 53 - : undefined 54 - : result.first_air_date 55 - ? new Date(result.first_air_date) 56 - : undefined, 57 - }; 58 - }); 59 - setSimilarMedia(mediaItems); 60 - } catch (err) { 61 - console.error("Failed to load similar media:", err); 62 - } finally { 63 - setIsLoading(false); 64 - } 65 - }; 66 - 67 - loadSimilarMedia(); 68 - }, [mediaId, mediaType]); 29 + const { media: similarMedia, isLoading } = useSimilarMedia({ 30 + mediaId, 31 + mediaType, 32 + limit: 12, 33 + }); 69 34 70 35 useEffect(() => { 71 36 if (carouselRef.current) { ··· 112 77 </div> 113 78 )) 114 79 : // Show actual media cards when loaded 115 - similarMedia.map((media) => ( 116 - <div 117 - key={media.id} 118 - 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" 119 - style={{ scrollSnapAlign: "start" }} 120 - > 121 - <MediaCard 122 - media={media} 123 - linkable 124 - onShowDetails={handleShowDetails} 125 - /> 126 - </div> 127 - ))} 80 + similarMedia.map((media) => { 81 + const isMovie = "title" in media; 82 + const item: MediaItem = { 83 + id: media.id.toString(), 84 + title: isMovie ? media.title : media.name, 85 + poster: media.poster_path 86 + ? `https://image.tmdb.org/t/p/w342${media.poster_path}` 87 + : "/placeholder.png", 88 + type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show", 89 + year: isMovie 90 + ? media.release_date 91 + ? new Date(media.release_date).getFullYear() 92 + : 0 93 + : media.first_air_date 94 + ? new Date(media.first_air_date).getFullYear() 95 + : 0, 96 + release_date: isMovie 97 + ? media.release_date 98 + ? new Date(media.release_date) 99 + : undefined 100 + : media.first_air_date 101 + ? new Date(media.first_air_date) 102 + : undefined, 103 + }; 104 + return ( 105 + <div 106 + key={media.id} 107 + 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" 108 + style={{ scrollSnapAlign: "start" }} 109 + > 110 + <MediaCard 111 + media={item} 112 + linkable 113 + onShowDetails={handleShowDetails} 114 + /> 115 + </div> 116 + ); 117 + })} 128 118 129 119 <div className="md:w-12" /> 130 120 </div>
+114 -4
src/pages/discover/hooks/useDiscoverMedia.ts
··· 1 1 import { useCallback, useEffect, useState } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 3 4 - import { get } from "@/backend/metadata/tmdb"; 4 + import { getMediaDetails, get } from "@/backend/metadata/tmdb"; 5 + import type { 6 + TMDBMovieData, 7 + TMDBMovieSearchResult, 8 + TMDBShowData, 9 + TMDBShowSearchResult, 10 + } from "@/backend/metadata/types/tmdb"; 11 + import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; 5 12 import { 6 13 PROVIDER_TO_TRAKT_MAP, 7 14 getAppleMovieReleases, ··· 24 31 } from "@/backend/metadata/traktApi"; 25 32 import { paginateResults } from "@/backend/metadata/traktFunctions"; 26 33 import type { TraktListResponse } from "@/backend/metadata/types/trakt"; 27 - import { 28 - EDITOR_PICKS_MOVIES, 34 + import { EDITOR_PICKS_MOVIES, 29 35 EDITOR_PICKS_TV_SHOWS, 30 36 MOVIE_PROVIDERS, 31 37 TV_PROVIDERS, ··· 39 45 UseDiscoverMediaProps, 40 46 UseDiscoverMediaReturn, 41 47 } from "@/pages/discover/types/discover"; 48 + import { fetchFedSimilarItems } from "../lib/personalRecommendations"; 42 49 import { conf } from "@/setup/config"; 43 50 import { useLanguageStore } from "@/stores/language"; 44 51 import { getTmdbLanguageCode } from "@/utils/language"; ··· 321 328 } 322 329 }, [mediaType, formattedLanguage, isCarouselView]); 323 330 331 + const fetchRecommendationsWithFedSimilar = useCallback( 332 + async (mediaId: string) => { 333 + const isTVShow = mediaType === "tv"; 334 + const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE; 335 + 336 + try { 337 + // Try fed-similar API first 338 + const fedSimilarIds = await fetchFedSimilarItems(mediaId, isTVShow); 339 + 340 + if (fedSimilarIds.length > 0) { 341 + // Fetch full details for fed-similar items 342 + const fedSimilarDetailPromises = fedSimilarIds 343 + .slice(0, isCarouselView ? 20 : 100) 344 + .map((tmdbId) => getMediaDetails(tmdbId, type)); 345 + 346 + const fedSimilarDetails = await Promise.allSettled( 347 + fedSimilarDetailPromises, 348 + ); 349 + 350 + const results: any[] = []; 351 + 352 + for (const result of fedSimilarDetails) { 353 + if (result.status !== "fulfilled" || !result.value) continue; 354 + const item = result.value as TMDBMovieData | TMDBShowData; 355 + 356 + let searchItem: TMDBMovieSearchResult | TMDBShowSearchResult; 357 + if (isTVShow) { 358 + const showItem = item as TMDBShowData; 359 + searchItem = { 360 + adult: showItem.adult ?? false, 361 + backdrop_path: showItem.backdrop_path ?? "", 362 + id: showItem.id, 363 + name: showItem.name, 364 + original_language: showItem.original_language ?? "", 365 + original_name: showItem.original_name ?? "", 366 + overview: showItem.overview ?? "", 367 + poster_path: showItem.poster_path ?? "", 368 + media_type: TMDBContentTypes.TV, 369 + genre_ids: showItem.genres?.map((g) => g.id) ?? [], 370 + popularity: showItem.popularity ?? 0, 371 + first_air_date: showItem.first_air_date ?? "", 372 + vote_average: showItem.vote_average, 373 + vote_count: showItem.vote_count, 374 + origin_country: showItem.origin_country ?? [], 375 + }; 376 + } else { 377 + const movieItem = item as TMDBMovieData; 378 + searchItem = { 379 + adult: movieItem.adult ?? false, 380 + backdrop_path: movieItem.backdrop_path ?? "", 381 + id: movieItem.id, 382 + title: movieItem.title, 383 + original_language: movieItem.original_language ?? "", 384 + original_title: movieItem.original_title ?? "", 385 + overview: movieItem.overview ?? "", 386 + poster_path: movieItem.poster_path ?? "", 387 + media_type: TMDBContentTypes.MOVIE, 388 + genre_ids: movieItem.genres?.map((g) => g.id) ?? [], 389 + popularity: movieItem.popularity ?? 0, 390 + release_date: movieItem.release_date ?? "", 391 + video: movieItem.video ?? false, 392 + vote_average: movieItem.vote_average, 393 + vote_count: movieItem.vote_count, 394 + }; 395 + } 396 + 397 + results.push(searchItem); 398 + } 399 + 400 + // If we have enough results from fed-similar, return them 401 + const minResults = isCarouselView ? 5 : 10; 402 + if (results.length >= minResults) { 403 + console.info( 404 + `Using fed-similar API results (${results.length} items)`, 405 + ); 406 + return { 407 + results: results.map((item) => ({ 408 + ...item, 409 + type: mediaType === "movie" ? "movie" : "show", 410 + })), 411 + hasMore: false, 412 + }; 413 + } 414 + } 415 + 416 + // Fall back to TMDB recommendations 417 + console.info( 418 + "Fed-similar API returned insufficient or no results, falling back to TMDB", 419 + ); 420 + const data = await fetchTMDBMedia(`/${mediaType}/${mediaId}/recommendations`); 421 + return data; 422 + } catch (err) { 423 + console.error("Error fetching fed-similar recommendations:", err); 424 + 425 + // Try TMDB fallback on error 426 + console.info("Attempting TMDB fallback..."); 427 + return await fetchTMDBMedia(`/${mediaType}/${mediaId}/recommendations`); 428 + } 429 + }, 430 + [mediaType, formattedLanguage, isCarouselView, fetchTMDBMedia], 431 + ); 432 + 324 433 const fetchMedia = useCallback(async () => { 325 434 // Skip fetching recommendations if no ID is provided 326 435 if (contentType === "recommendations" && !id) { ··· 454 563 455 564 case "recommendations": 456 565 if (!id) throw new Error("Media ID is required for recommendations"); 457 - data = await fetchTMDBMedia(`/${mediaType}/${id}/recommendations`); 566 + data = await fetchRecommendationsWithFedSimilar(id); 458 567 setSectionTitle( 459 568 t("discover.carousel.title.recommended", { title: mediaTitle }), 460 569 ); ··· 520 629 fetchTMDBMedia, 521 630 fetchTraktMedia, 522 631 fetchEditorPicks, 632 + fetchRecommendationsWithFedSimilar, 523 633 t, 524 634 page, 525 635 getTraktProviderFunction,
+142
src/pages/discover/hooks/useSimilarMedia.ts
··· 1 + import { useCallback, useEffect, useState } from "react"; 2 + 3 + import { getMediaDetails, getRelatedMedia } from "@/backend/metadata/tmdb"; 4 + import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; 5 + import type { 6 + TMDBMovieData, 7 + TMDBMovieSearchResult, 8 + TMDBShowData, 9 + TMDBShowSearchResult, 10 + } from "@/backend/metadata/types/tmdb"; 11 + import { fetchFedSimilarItems } from "../lib/personalRecommendations"; 12 + 13 + export function useSimilarMedia({ 14 + mediaId, 15 + mediaType, 16 + limit = 12, 17 + enabled = true, 18 + }: { 19 + mediaId: string; 20 + mediaType: TMDBContentTypes; 21 + limit?: number; 22 + enabled?: boolean; 23 + }) { 24 + const [media, setMedia] = useState<TMDBMovieSearchResult[] | TMDBShowSearchResult[]>([]); 25 + const [isLoading, setIsLoading] = useState(false); 26 + const [error, setError] = useState<string | null>(null); 27 + 28 + const isTVShow = mediaType === TMDBContentTypes.TV; 29 + const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE; 30 + 31 + const fetch = useCallback(async () => { 32 + if (!mediaId || !enabled) return; 33 + 34 + setIsLoading(true); 35 + setError(null); 36 + 37 + try { 38 + // Try fed-similar API first 39 + const fedSimilarIds = await fetchFedSimilarItems(mediaId, isTVShow); 40 + 41 + if (fedSimilarIds.length > 0) { 42 + // Fetch full details for fed-similar items 43 + const fedSimilarDetailPromises = fedSimilarIds 44 + .slice(0, limit) 45 + .map((tmdbId) => getMediaDetails(tmdbId, type)); 46 + 47 + const fedSimilarDetails = await Promise.allSettled( 48 + fedSimilarDetailPromises, 49 + ); 50 + 51 + const results: (TMDBMovieSearchResult | TMDBShowSearchResult)[] = []; 52 + 53 + for (const result of fedSimilarDetails) { 54 + if (result.status !== "fulfilled" || !result.value) continue; 55 + const item = result.value as TMDBMovieData | TMDBShowData; 56 + 57 + let searchItem: TMDBMovieSearchResult | TMDBShowSearchResult; 58 + if (isTVShow) { 59 + const showItem = item as TMDBShowData; 60 + searchItem = { 61 + adult: showItem.adult ?? false, 62 + backdrop_path: showItem.backdrop_path ?? "", 63 + id: showItem.id, 64 + name: showItem.name, 65 + original_language: showItem.original_language ?? "", 66 + original_name: showItem.original_name ?? "", 67 + overview: showItem.overview ?? "", 68 + poster_path: showItem.poster_path ?? "", 69 + media_type: TMDBContentTypes.TV, 70 + genre_ids: showItem.genres?.map((g) => g.id) ?? [], 71 + popularity: showItem.popularity ?? 0, 72 + first_air_date: showItem.first_air_date ?? "", 73 + vote_average: showItem.vote_average, 74 + vote_count: showItem.vote_count, 75 + origin_country: showItem.origin_country ?? [], 76 + }; 77 + } else { 78 + const movieItem = item as TMDBMovieData; 79 + searchItem = { 80 + adult: movieItem.adult ?? false, 81 + backdrop_path: movieItem.backdrop_path ?? "", 82 + id: movieItem.id, 83 + title: movieItem.title, 84 + original_language: movieItem.original_language ?? "", 85 + original_title: movieItem.original_title ?? "", 86 + overview: movieItem.overview ?? "", 87 + poster_path: movieItem.poster_path ?? "", 88 + media_type: TMDBContentTypes.MOVIE, 89 + genre_ids: movieItem.genres?.map((g) => g.id) ?? [], 90 + popularity: movieItem.popularity ?? 0, 91 + release_date: movieItem.release_date ?? "", 92 + video: movieItem.video ?? false, 93 + vote_average: movieItem.vote_average, 94 + vote_count: movieItem.vote_count, 95 + }; 96 + } 97 + 98 + results.push(searchItem); 99 + } 100 + 101 + if (results.length >= limit / 2) { 102 + // If we have enough results from fed-similar, use them 103 + setMedia(results.slice(0, limit)); 104 + return; 105 + } 106 + } 107 + 108 + // Fall back to TMDB recommendations 109 + console.info( 110 + "Fed-similar API returned insufficient or no results, falling back to TMDB", 111 + ); 112 + const tmdbResults = await getRelatedMedia(mediaId, type, limit); 113 + setMedia(tmdbResults); 114 + } catch (err) { 115 + console.error("Failed to load similar media:", err); 116 + 117 + // Try TMDB fallback on error 118 + try { 119 + console.info("Attempting TMDB fallback..."); 120 + const tmdbResults = await getRelatedMedia(mediaId, type, limit); 121 + setMedia(tmdbResults); 122 + setError(null); 123 + } catch (tmdbErr) { 124 + setError((tmdbErr as Error).message); 125 + setMedia([]); 126 + } 127 + } finally { 128 + setIsLoading(false); 129 + } 130 + }, [mediaId, type, isTVShow, limit, enabled]); 131 + 132 + useEffect(() => { 133 + fetch(); 134 + }, [fetch]); 135 + 136 + return { 137 + media, 138 + isLoading, 139 + error, 140 + refetch: fetch, 141 + }; 142 + }
+1 -1
src/pages/discover/lib/personalRecommendations.ts
··· 78 78 /** 79 79 * Fetches similar items from the fed-similar API 80 80 */ 81 - async function fetchFedSimilarItems( 81 + export async function fetchFedSimilarItems( 82 82 tmdbId: string, 83 83 isTVShow: boolean, 84 84 ): Promise<string[]> {