mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
new use similar media
This commit is contained in:
parent
5b73ca114e
commit
fbd5cfc93c
4 changed files with 302 additions and 60 deletions
|
|
@ -1,11 +1,11 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getMediaPoster, getRelatedMedia } from "@/backend/metadata/tmdb";
|
||||
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
|
||||
import { MediaCard, MediaCardSkeleton } from "@/components/media/MediaCard";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
|
||||
import { useSimilarMedia } from "@/pages/discover/hooks/useSimilarMedia";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
|
|
@ -21,51 +21,16 @@ export function SimilarMediaCarousel({
|
|||
const { t } = useTranslation();
|
||||
const { isMobile } = useIsMobile();
|
||||
const { showModal } = useOverlayStack();
|
||||
const [similarMedia, setSimilarMedia] = useState<MediaItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({
|
||||
similar: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSimilarMedia = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await getRelatedMedia(mediaId, mediaType, 12);
|
||||
const mediaItems: MediaItem[] = results.map((result) => {
|
||||
const isMovie = "title" in result;
|
||||
return {
|
||||
id: result.id.toString(),
|
||||
title: isMovie ? result.title : result.name,
|
||||
poster: getMediaPoster(result.poster_path) || "/placeholder.png",
|
||||
type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show",
|
||||
year: isMovie
|
||||
? result.release_date
|
||||
? new Date(result.release_date).getFullYear()
|
||||
: 0
|
||||
: result.first_air_date
|
||||
? new Date(result.first_air_date).getFullYear()
|
||||
: 0,
|
||||
release_date: isMovie
|
||||
? result.release_date
|
||||
? new Date(result.release_date)
|
||||
: undefined
|
||||
: result.first_air_date
|
||||
? new Date(result.first_air_date)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
setSimilarMedia(mediaItems);
|
||||
} catch (err) {
|
||||
console.error("Failed to load similar media:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSimilarMedia();
|
||||
}, [mediaId, mediaType]);
|
||||
const { media: similarMedia, isLoading } = useSimilarMedia({
|
||||
mediaId,
|
||||
mediaType,
|
||||
limit: 12,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselRef.current) {
|
||||
|
|
@ -112,19 +77,44 @@ export function SimilarMediaCarousel({
|
|||
</div>
|
||||
))
|
||||
: // Show actual media cards when loaded
|
||||
similarMedia.map((media) => (
|
||||
<div
|
||||
key={media.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"
|
||||
style={{ scrollSnapAlign: "start" }}
|
||||
>
|
||||
<MediaCard
|
||||
media={media}
|
||||
linkable
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
similarMedia.map((media) => {
|
||||
const isMovie = "title" in media;
|
||||
const item: MediaItem = {
|
||||
id: media.id.toString(),
|
||||
title: isMovie ? media.title : media.name,
|
||||
poster: media.poster_path
|
||||
? `https://image.tmdb.org/t/p/w342${media.poster_path}`
|
||||
: "/placeholder.png",
|
||||
type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show",
|
||||
year: isMovie
|
||||
? media.release_date
|
||||
? new Date(media.release_date).getFullYear()
|
||||
: 0
|
||||
: media.first_air_date
|
||||
? new Date(media.first_air_date).getFullYear()
|
||||
: 0,
|
||||
release_date: isMovie
|
||||
? media.release_date
|
||||
? new Date(media.release_date)
|
||||
: undefined
|
||||
: media.first_air_date
|
||||
? new Date(media.first_air_date)
|
||||
: undefined,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={media.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"
|
||||
style={{ scrollSnapAlign: "start" }}
|
||||
>
|
||||
<MediaCard
|
||||
media={item}
|
||||
linkable
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { getMediaDetails, get } from "@/backend/metadata/tmdb";
|
||||
import type {
|
||||
TMDBMovieData,
|
||||
TMDBMovieSearchResult,
|
||||
TMDBShowData,
|
||||
TMDBShowSearchResult,
|
||||
} from "@/backend/metadata/types/tmdb";
|
||||
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
|
||||
import {
|
||||
PROVIDER_TO_TRAKT_MAP,
|
||||
getAppleMovieReleases,
|
||||
|
|
@ -24,8 +31,7 @@ import {
|
|||
} from "@/backend/metadata/traktApi";
|
||||
import { paginateResults } from "@/backend/metadata/traktFunctions";
|
||||
import type { TraktListResponse } from "@/backend/metadata/types/trakt";
|
||||
import {
|
||||
EDITOR_PICKS_MOVIES,
|
||||
import { EDITOR_PICKS_MOVIES,
|
||||
EDITOR_PICKS_TV_SHOWS,
|
||||
MOVIE_PROVIDERS,
|
||||
TV_PROVIDERS,
|
||||
|
|
@ -39,6 +45,7 @@ import type {
|
|||
UseDiscoverMediaProps,
|
||||
UseDiscoverMediaReturn,
|
||||
} from "@/pages/discover/types/discover";
|
||||
import { fetchFedSimilarItems } from "../lib/personalRecommendations";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { getTmdbLanguageCode } from "@/utils/language";
|
||||
|
|
@ -321,6 +328,108 @@ export function useDiscoverMedia({
|
|||
}
|
||||
}, [mediaType, formattedLanguage, isCarouselView]);
|
||||
|
||||
const fetchRecommendationsWithFedSimilar = useCallback(
|
||||
async (mediaId: string) => {
|
||||
const isTVShow = mediaType === "tv";
|
||||
const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE;
|
||||
|
||||
try {
|
||||
// Try fed-similar API first
|
||||
const fedSimilarIds = await fetchFedSimilarItems(mediaId, isTVShow);
|
||||
|
||||
if (fedSimilarIds.length > 0) {
|
||||
// Fetch full details for fed-similar items
|
||||
const fedSimilarDetailPromises = fedSimilarIds
|
||||
.slice(0, isCarouselView ? 20 : 100)
|
||||
.map((tmdbId) => getMediaDetails(tmdbId, type));
|
||||
|
||||
const fedSimilarDetails = await Promise.allSettled(
|
||||
fedSimilarDetailPromises,
|
||||
);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const result of fedSimilarDetails) {
|
||||
if (result.status !== "fulfilled" || !result.value) continue;
|
||||
const item = result.value as TMDBMovieData | TMDBShowData;
|
||||
|
||||
let searchItem: TMDBMovieSearchResult | TMDBShowSearchResult;
|
||||
if (isTVShow) {
|
||||
const showItem = item as TMDBShowData;
|
||||
searchItem = {
|
||||
adult: showItem.adult ?? false,
|
||||
backdrop_path: showItem.backdrop_path ?? "",
|
||||
id: showItem.id,
|
||||
name: showItem.name,
|
||||
original_language: showItem.original_language ?? "",
|
||||
original_name: showItem.original_name ?? "",
|
||||
overview: showItem.overview ?? "",
|
||||
poster_path: showItem.poster_path ?? "",
|
||||
media_type: TMDBContentTypes.TV,
|
||||
genre_ids: showItem.genres?.map((g) => g.id) ?? [],
|
||||
popularity: showItem.popularity ?? 0,
|
||||
first_air_date: showItem.first_air_date ?? "",
|
||||
vote_average: showItem.vote_average,
|
||||
vote_count: showItem.vote_count,
|
||||
origin_country: showItem.origin_country ?? [],
|
||||
};
|
||||
} else {
|
||||
const movieItem = item as TMDBMovieData;
|
||||
searchItem = {
|
||||
adult: movieItem.adult ?? false,
|
||||
backdrop_path: movieItem.backdrop_path ?? "",
|
||||
id: movieItem.id,
|
||||
title: movieItem.title,
|
||||
original_language: movieItem.original_language ?? "",
|
||||
original_title: movieItem.original_title ?? "",
|
||||
overview: movieItem.overview ?? "",
|
||||
poster_path: movieItem.poster_path ?? "",
|
||||
media_type: TMDBContentTypes.MOVIE,
|
||||
genre_ids: movieItem.genres?.map((g) => g.id) ?? [],
|
||||
popularity: movieItem.popularity ?? 0,
|
||||
release_date: movieItem.release_date ?? "",
|
||||
video: movieItem.video ?? false,
|
||||
vote_average: movieItem.vote_average,
|
||||
vote_count: movieItem.vote_count,
|
||||
};
|
||||
}
|
||||
|
||||
results.push(searchItem);
|
||||
}
|
||||
|
||||
// If we have enough results from fed-similar, return them
|
||||
const minResults = isCarouselView ? 5 : 10;
|
||||
if (results.length >= minResults) {
|
||||
console.info(
|
||||
`Using fed-similar API results (${results.length} items)`,
|
||||
);
|
||||
return {
|
||||
results: results.map((item) => ({
|
||||
...item,
|
||||
type: mediaType === "movie" ? "movie" : "show",
|
||||
})),
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to TMDB recommendations
|
||||
console.info(
|
||||
"Fed-similar API returned insufficient or no results, falling back to TMDB",
|
||||
);
|
||||
const data = await fetchTMDBMedia(`/${mediaType}/${mediaId}/recommendations`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Error fetching fed-similar recommendations:", err);
|
||||
|
||||
// Try TMDB fallback on error
|
||||
console.info("Attempting TMDB fallback...");
|
||||
return await fetchTMDBMedia(`/${mediaType}/${mediaId}/recommendations`);
|
||||
}
|
||||
},
|
||||
[mediaType, formattedLanguage, isCarouselView, fetchTMDBMedia],
|
||||
);
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
// Skip fetching recommendations if no ID is provided
|
||||
if (contentType === "recommendations" && !id) {
|
||||
|
|
@ -454,7 +563,7 @@ export function useDiscoverMedia({
|
|||
|
||||
case "recommendations":
|
||||
if (!id) throw new Error("Media ID is required for recommendations");
|
||||
data = await fetchTMDBMedia(`/${mediaType}/${id}/recommendations`);
|
||||
data = await fetchRecommendationsWithFedSimilar(id);
|
||||
setSectionTitle(
|
||||
t("discover.carousel.title.recommended", { title: mediaTitle }),
|
||||
);
|
||||
|
|
@ -520,6 +629,7 @@ export function useDiscoverMedia({
|
|||
fetchTMDBMedia,
|
||||
fetchTraktMedia,
|
||||
fetchEditorPicks,
|
||||
fetchRecommendationsWithFedSimilar,
|
||||
t,
|
||||
page,
|
||||
getTraktProviderFunction,
|
||||
|
|
|
|||
142
src/pages/discover/hooks/useSimilarMedia.ts
Normal file
142
src/pages/discover/hooks/useSimilarMedia.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getMediaDetails, getRelatedMedia } from "@/backend/metadata/tmdb";
|
||||
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
|
||||
import type {
|
||||
TMDBMovieData,
|
||||
TMDBMovieSearchResult,
|
||||
TMDBShowData,
|
||||
TMDBShowSearchResult,
|
||||
} from "@/backend/metadata/types/tmdb";
|
||||
import { fetchFedSimilarItems } from "../lib/personalRecommendations";
|
||||
|
||||
export function useSimilarMedia({
|
||||
mediaId,
|
||||
mediaType,
|
||||
limit = 12,
|
||||
enabled = true,
|
||||
}: {
|
||||
mediaId: string;
|
||||
mediaType: TMDBContentTypes;
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const [media, setMedia] = useState<TMDBMovieSearchResult[] | TMDBShowSearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isTVShow = mediaType === TMDBContentTypes.TV;
|
||||
const type = isTVShow ? TMDBContentTypes.TV : TMDBContentTypes.MOVIE;
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!mediaId || !enabled) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try fed-similar API first
|
||||
const fedSimilarIds = await fetchFedSimilarItems(mediaId, isTVShow);
|
||||
|
||||
if (fedSimilarIds.length > 0) {
|
||||
// Fetch full details for fed-similar items
|
||||
const fedSimilarDetailPromises = fedSimilarIds
|
||||
.slice(0, limit)
|
||||
.map((tmdbId) => getMediaDetails(tmdbId, type));
|
||||
|
||||
const fedSimilarDetails = await Promise.allSettled(
|
||||
fedSimilarDetailPromises,
|
||||
);
|
||||
|
||||
const results: (TMDBMovieSearchResult | TMDBShowSearchResult)[] = [];
|
||||
|
||||
for (const result of fedSimilarDetails) {
|
||||
if (result.status !== "fulfilled" || !result.value) continue;
|
||||
const item = result.value as TMDBMovieData | TMDBShowData;
|
||||
|
||||
let searchItem: TMDBMovieSearchResult | TMDBShowSearchResult;
|
||||
if (isTVShow) {
|
||||
const showItem = item as TMDBShowData;
|
||||
searchItem = {
|
||||
adult: showItem.adult ?? false,
|
||||
backdrop_path: showItem.backdrop_path ?? "",
|
||||
id: showItem.id,
|
||||
name: showItem.name,
|
||||
original_language: showItem.original_language ?? "",
|
||||
original_name: showItem.original_name ?? "",
|
||||
overview: showItem.overview ?? "",
|
||||
poster_path: showItem.poster_path ?? "",
|
||||
media_type: TMDBContentTypes.TV,
|
||||
genre_ids: showItem.genres?.map((g) => g.id) ?? [],
|
||||
popularity: showItem.popularity ?? 0,
|
||||
first_air_date: showItem.first_air_date ?? "",
|
||||
vote_average: showItem.vote_average,
|
||||
vote_count: showItem.vote_count,
|
||||
origin_country: showItem.origin_country ?? [],
|
||||
};
|
||||
} else {
|
||||
const movieItem = item as TMDBMovieData;
|
||||
searchItem = {
|
||||
adult: movieItem.adult ?? false,
|
||||
backdrop_path: movieItem.backdrop_path ?? "",
|
||||
id: movieItem.id,
|
||||
title: movieItem.title,
|
||||
original_language: movieItem.original_language ?? "",
|
||||
original_title: movieItem.original_title ?? "",
|
||||
overview: movieItem.overview ?? "",
|
||||
poster_path: movieItem.poster_path ?? "",
|
||||
media_type: TMDBContentTypes.MOVIE,
|
||||
genre_ids: movieItem.genres?.map((g) => g.id) ?? [],
|
||||
popularity: movieItem.popularity ?? 0,
|
||||
release_date: movieItem.release_date ?? "",
|
||||
video: movieItem.video ?? false,
|
||||
vote_average: movieItem.vote_average,
|
||||
vote_count: movieItem.vote_count,
|
||||
};
|
||||
}
|
||||
|
||||
results.push(searchItem);
|
||||
}
|
||||
|
||||
if (results.length >= limit / 2) {
|
||||
// If we have enough results from fed-similar, use them
|
||||
setMedia(results.slice(0, limit));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to TMDB recommendations
|
||||
console.info(
|
||||
"Fed-similar API returned insufficient or no results, falling back to TMDB",
|
||||
);
|
||||
const tmdbResults = await getRelatedMedia(mediaId, type, limit);
|
||||
setMedia(tmdbResults);
|
||||
} catch (err) {
|
||||
console.error("Failed to load similar media:", err);
|
||||
|
||||
// Try TMDB fallback on error
|
||||
try {
|
||||
console.info("Attempting TMDB fallback...");
|
||||
const tmdbResults = await getRelatedMedia(mediaId, type, limit);
|
||||
setMedia(tmdbResults);
|
||||
setError(null);
|
||||
} catch (tmdbErr) {
|
||||
setError((tmdbErr as Error).message);
|
||||
setMedia([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mediaId, type, isTVShow, limit, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
return {
|
||||
media,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetch,
|
||||
};
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ function bookmarkToDiscoverMedia(b: BookmarkSource): DiscoverMedia {
|
|||
/**
|
||||
* Fetches similar items from the fed-similar API
|
||||
*/
|
||||
async function fetchFedSimilarItems(
|
||||
export async function fetchFedSimilarItems(
|
||||
tmdbId: string,
|
||||
isTVShow: boolean,
|
||||
): Promise<string[]> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue