From 5cc4485629da5147cd6c1048ab0d77f91dcf9b77 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 2 Jun 2025 23:38:56 -0600 Subject: [PATCH] add trackt latest and 4k --- src/assets/locales/en.json | 6 + src/backend/metadata/traktApi.ts | 75 ++++++ src/pages/discover/MoreContent.tsx | 227 ++++++++++++++---- .../discover/components/MediaCarousel.tsx | 8 + src/pages/discover/discoverContent.tsx | 155 ++++++++++-- 5 files changed, 409 insertions(+), 62 deletions(-) create mode 100644 src/backend/metadata/traktApi.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index df057c2d..1c074fde 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -901,6 +901,12 @@ "movies": "{{category}} Movies", "tvshows": "{{category}} Shows", "inCinemas": "In Cinemas", + "popular": "Most Popular", + "nowPlaying": "In Cinemas", + "topRated": "Top Rated", + "latestReleases": "Latest Releases", + "4kReleases": "4K Releases", + "onTheAir": "On The Air", "popularOn": "Popular {{type}} on {{provider}}", "editorPicksMovies": "Editor Picks Movies", "editorPicksShows": "Editor Picks Shows", diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts new file mode 100644 index 00000000..ee7152cf --- /dev/null +++ b/src/backend/metadata/traktApi.ts @@ -0,0 +1,75 @@ +import { MWMediaType } from "./types/mw"; + +export interface TraktLatestResponse { + tmdb_ids: number[]; + count: number; +} + +export interface TraktReleaseResponse { + tmdb_id: number; + title: string; + year?: number; + type: "movie" | "episode"; + season?: number; + episode?: number; + quality?: string; + source?: string; + group?: string; +} + +export type TraktContentType = "movie" | "episode"; + +export const TRAKT_BASE_URL = "https://airdate.up.railway.app"; + +export async function getLatestReleases(): Promise { + const response = await fetch(`${TRAKT_BASE_URL}/latest`); + if (!response.ok) { + throw new Error(`Failed to fetch latest releases: ${response.statusText}`); + } + return response.json(); +} + +export async function getLatest4KReleases(): Promise { + const response = await fetch(`${TRAKT_BASE_URL}/latest4k`); + if (!response.ok) { + throw new Error( + `Failed to fetch latest 4K releases: ${response.statusText}`, + ); + } + return response.json(); +} + +export async function getReleaseDetails( + id: string, + season?: number, + episode?: number, +): Promise { + let url = `${TRAKT_BASE_URL}/release/${id}`; + if (season !== undefined && episode !== undefined) { + url += `/${season}/${episode}`; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch release details: ${response.statusText}`); + } + return response.json(); +} + +export async function getAppleTVReleases(): Promise { + const response = await fetch(`${TRAKT_BASE_URL}/appletv`); + if (!response.ok) { + throw new Error( + `Failed to fetch Apple TV releases: ${response.statusText}`, + ); + } + return response.json(); +} + +export function convertToMediaType(type: TraktContentType): MWMediaType { + return type === "movie" ? MWMediaType.MOVIE : MWMediaType.SERIES; +} + +export function convertFromMediaType(type: MWMediaType): TraktContentType { + return type === MWMediaType.MOVIE ? "movie" : "episode"; +} diff --git a/src/pages/discover/MoreContent.tsx b/src/pages/discover/MoreContent.tsx index 840a8efc..117bd351 100644 --- a/src/pages/discover/MoreContent.tsx +++ b/src/pages/discover/MoreContent.tsx @@ -5,6 +5,10 @@ import { useNavigate, useParams } from "react-router-dom"; import { useWindowSize } from "react-use"; import { get } from "@/backend/metadata/tmdb"; +import { + getLatest4KReleases, + getLatestReleases, +} from "@/backend/metadata/traktApi"; import { Button } from "@/components/buttons/Button"; import { Dropdown, OptionItem } from "@/components/form/Dropdown"; import { Icon, Icons } from "@/components/Icon"; @@ -22,7 +26,7 @@ import { ProgressMediaItem, useProgressStore } from "@/stores/progress"; import { getTmdbLanguageCode } from "@/utils/language"; import { MediaItem } from "@/utils/mediaTypes"; -import { Genre, categories, tvCategories } from "./common"; +import { Genre, Movie, categories, tvCategories } from "./common"; import { EDITOR_PICKS_MOVIES, EDITOR_PICKS_TV_SHOWS, @@ -123,6 +127,18 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { const isTVShow = mediaType === "tv"; let endpoint = ""; + // Map category URLs to their corresponding TMDB endpoints + const categoryEndpointMap: { [key: string]: string } = { + // Movie categories + "now-playing-movie": "/movie/now_playing", + "top-rated-movie": "/movie/top_rated", + "most-popular-movie": "/movie/popular", + // TV categories + "on-the-air-tv": "/tv/on_the_air", + "top-rated-tv": "/tv/top_rated", + "most-popular-tv": "/tv/popular", + }; + // Handle recommendations separately if (contentType === "recommendations") { // Get title from progress store instead of fetching details @@ -141,10 +157,21 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { }, ); + const processedResults = results.results.map((item: any) => { + const isItemTVShow = Boolean(item.first_air_date); + return { + ...item, + type: isItemTVShow ? "show" : "movie", + // Keep both dates in the raw data + first_air_date: item.first_air_date, + release_date: item.release_date, + }; + }); + if (append) { - setMedias((prev) => [...prev, ...results.results]); + setMedias((prev) => [...prev, ...processedResults]); } else { - setMedias(results.results); + setMedias(processedResults); } setHasMore(page < results.total_pages); setCurrentPage(page); @@ -153,35 +180,90 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { // Handle editor picks separately if (category?.includes("editor-picks")) { - const editorPicks = isTVShow + const isEditorPicksTV = category.includes("tv"); + const editorPicks = isEditorPicksTV ? EDITOR_PICKS_TV_SHOWS : EDITOR_PICKS_MOVIES; // Fetch details for all editor picks const promises = editorPicks.map((item) => - get(`/${isTVShow ? "tv" : "movie"}/${item.id}`, { + get(`/${isEditorPicksTV ? "tv" : "movie"}/${item.id}`, { api_key: conf().TMDB_READ_API_KEY, language: formattedLanguage, }), ); const results = await Promise.all(promises); - setMedias(results); + const validResults = results.map((item) => ({ + ...item, + type: isEditorPicksTV ? "show" : "movie", + release_date: isEditorPicksTV + ? item.first_air_date + : item.release_date, + })); + setMedias(validResults); setHasMore(false); return; } - // Determine the correct endpoint based on the type - if (contentType === "category") { - const categoryList = isTVShow ? tvCategories : categories; - const categoryData = categoryList.find((c) => c.urlPath === id); - if (categoryData) { - endpoint = categoryData.endpoint; - } else { - endpoint = isTVShow ? "/discover/tv" : "/discover/movie"; + // Handle Trakt categories separately + if ( + category?.includes("latest-releases") || + category?.includes("4k-releases") + ) { + try { + const traktFunction = category?.includes("latest-releases") + ? getLatestReleases + : getLatest4KReleases; + + const traktData = await traktFunction(); + const moviePromises = traktData.tmdb_ids + .slice((page - 1) * 20, page * 20) + .map((tmdbId: number) => + get(`/movie/${tmdbId}`, { + api_key: conf().TMDB_READ_API_KEY, + language: formattedLanguage, + }).catch(() => null), + ); + + const results = await Promise.all(moviePromises); + const validMovies = results + .filter((movie: any): movie is Movie => movie !== null) + .map((movie: Movie) => { + const isItemTVShow = Boolean(movie.first_air_date); + return { + ...movie, + type: isItemTVShow ? "show" : "movie", + // Keep both dates in the raw data + first_air_date: movie.first_air_date, + release_date: movie.release_date, + }; + }); + + if (append) { + setMedias((prev) => [...prev, ...validMovies]); + } else { + setMedias(validMovies); + } + setHasMore(traktData.tmdb_ids.length > page * 20); + return; + } catch (error) { + console.error(`Error fetching ${category}:`, error); } - } else { + } + + // Determine the correct endpoint based on the category + if (category && categoryEndpointMap[category]) { + endpoint = categoryEndpointMap[category]; + } else if (contentType === "provider") { endpoint = isTVShow ? "/discover/tv" : "/discover/movie"; + } else if (contentType === "genre") { + endpoint = isTVShow ? "/discover/tv" : "/discover/movie"; + } + + if (!endpoint) { + console.error("No endpoint found for category:", category); + return; } const allResults: any[] = []; @@ -203,7 +285,18 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { } const data = await get(endpoint, params); - allResults.push(...data.results); + const processedResults = data.results.map((item: any) => { + const isItemTVShow = Boolean(item.first_air_date); + return { + ...item, + type: isItemTVShow ? "show" : "movie", + // Keep both dates in the raw data + first_air_date: item.first_air_date, + release_date: item.release_date, + }; + }); + + allResults.push(...processedResults); // Check if we've reached the end if (currentPageNum >= data.total_pages) { @@ -259,12 +352,36 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { }); } - if (category === "editor-picks-tv" || category === "editor-picks-movie") { - return category === "editor-picks-tv" + if (category?.includes("editor-picks")) { + return category.includes("tv") ? t("discover.carousel.title.editorPicksShows") : t("discover.carousel.title.editorPicksMovies"); } + if (category?.includes("latest-releases")) { + return t("discover.carousel.title.latestReleases"); + } + + if (category?.includes("4k-releases")) { + return t("discover.carousel.title.4kReleases"); + } + + // Map category URLs to their display titles + const categoryTitleMap: { [key: string]: string } = { + // Movie categories + "now-playing-movie": t("discover.carousel.title.nowPlaying"), + "top-rated-movie": t("discover.carousel.title.topRated"), + "most-popular-movie": t("discover.carousel.title.popular"), + // TV categories + "on-the-air-tv": t("discover.carousel.title.onTheAir"), + "top-rated-tv": t("discover.carousel.title.topRated"), + "most-popular-tv": t("discover.carousel.title.popular"), + }; + + if (category && categoryTitleMap[category]) { + return categoryTitleMap[category]; + } + if (!contentType || !id) return ""; if (contentType === "provider") { @@ -620,34 +737,52 @@ export function MoreContent({ onShowDetails }: MoreContentProps) { {renderGenreButtons()}
- {medias.map((media) => ( -
) => - e.preventDefault() - } - > - -
- ))} + {medias.map((media) => { + // Determine if this is a TV show based on the presence of first_air_date + const isTVShow = Boolean(media.first_air_date); + const releaseDate = isTVShow + ? media.first_air_date + : media.release_date; + const year = releaseDate + ? parseInt(releaseDate.split("-")[0], 10) + : undefined; + + const mediaItem: MediaItem = { + id: media.id.toString(), + title: media.title || media.name || "", + poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`, + type: isTVShow ? "show" : "movie", + year, + release_date: releaseDate ? new Date(releaseDate) : undefined, + }; + + console.log("Final MediaCard item:", { + id: mediaItem.id, + title: mediaItem.title, + year: mediaItem.year, + release_date: mediaItem.release_date?.toISOString(), + type: mediaItem.type, + raw_first_air_date: media.first_air_date, + raw_release_date: media.release_date, + isTVShow, + }); + + return ( +
) => + e.preventDefault() + } + > + +
+ ); + })}
{hasMore && (
diff --git a/src/pages/discover/components/MediaCarousel.tsx b/src/pages/discover/components/MediaCarousel.tsx index ff4b9f2f..669d54f1 100644 --- a/src/pages/discover/components/MediaCarousel.tsx +++ b/src/pages/discover/components/MediaCarousel.tsx @@ -147,6 +147,14 @@ export function MediaCarousel({ : t("discover.carousel.title.editorPicksMovies"); } + if (categoryName === "Latest Releases") { + return t("discover.carousel.title.latestReleases"); + } + + if (categoryName === "4K Releases") { + return t("discover.carousel.title.4kReleases"); + } + if ( categoryName.includes("Movies on") || categoryName.includes("Shows on") diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index dd81697e..7608e684 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -2,6 +2,10 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { get } from "@/backend/metadata/tmdb"; +import { + getLatest4KReleases, + getLatestReleases, +} from "@/backend/metadata/traktApi"; import { WideContainer } from "@/components/layout/WideContainer"; import { DetailsModal } from "@/components/overlays/details/DetailsModal"; import { useModal } from "@/components/overlays/Modal"; @@ -163,6 +167,11 @@ export function DiscoverContent() { const [selectedTVSource, setSelectedTVSource] = useState(""); const progressStore = useProgressStore(); const { t } = useTranslation(); + const [latestReleases, setLatestReleases] = useState([]); + const [latest4KReleases, setLatest4KReleases] = useState([]); + const [isLoadingLatest, setIsLoadingLatest] = useState(false); + const [isLoading4K, setIsLoading4K] = useState(false); + const [isTraktAvailable, setIsTraktAvailable] = useState(false); const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); @@ -485,6 +494,98 @@ export function DiscoverContent() { t, ]); + // Fetch latest releases from Trakt + useEffect(() => { + const fetchLatestReleases = async () => { + if (!isMoviesTab) return; + setIsLoadingLatest(true); + try { + const traktData = await getLatestReleases(); + const moviePromises = traktData.tmdb_ids.slice(0, 20).map( + (id) => + get(`/movie/${id}`, { + api_key: conf().TMDB_READ_API_KEY, + language: formattedLanguage, + }).catch(() => null), // Handle failed TMDB fetches gracefully + ); + + const results = await Promise.all(moviePromises); + const validMovies = results + .filter((movie): movie is Movie => movie !== null) + .map((movie) => ({ + ...movie, + type: "movie" as const, + })); + setLatestReleases(validMovies); + setIsTraktAvailable(true); + } catch (error) { + console.error("Error fetching latest releases:", error); + setIsTraktAvailable(false); + // Fallback to TMDB if Trakt fails + const data = await get("/movie/now_playing", { + api_key: conf().TMDB_READ_API_KEY, + language: formattedLanguage, + }); + setLatestReleases( + data.results.map((movie: any) => ({ + ...movie, + type: "movie" as const, + })), + ); + } finally { + setIsLoadingLatest(false); + } + }; + + fetchLatestReleases(); + }, [isMoviesTab, formattedLanguage]); + + // Fetch 4K releases from Trakt + useEffect(() => { + const fetch4KReleases = async () => { + if (!isMoviesTab) return; + setIsLoading4K(true); + try { + const traktData = await getLatest4KReleases(); + const moviePromises = traktData.tmdb_ids.slice(0, 20).map( + (id) => + get(`/movie/${id}`, { + api_key: conf().TMDB_READ_API_KEY, + language: formattedLanguage, + }).catch(() => null), // Handle failed TMDB fetches gracefully + ); + + const results = await Promise.all(moviePromises); + const validMovies = results + .filter((movie): movie is Movie => movie !== null) + .map((movie) => ({ + ...movie, + type: "movie" as const, + })); + setLatest4KReleases(validMovies); + setIsTraktAvailable(true); + } catch (error) { + console.error("Error fetching 4K releases:", error); + setIsTraktAvailable(false); + // Fallback to TMDB if Trakt fails + const data = await get("/movie/popular", { + api_key: conf().TMDB_READ_API_KEY, + language: formattedLanguage, + }); + setLatest4KReleases( + data.results.map((movie: any) => ({ + ...movie, + type: "movie" as const, + })), + ); + } finally { + setIsLoading4K(false); + } + }; + + fetch4KReleases(); + }, [isMoviesTab, formattedLanguage]); + const handleShowDetails = async (media: MediaItem | FeaturedMedia) => { setDetailsData({ id: Number(media.id), @@ -513,14 +614,25 @@ export function DiscoverContent() { /> )} - {/* In Cinemas */} - + {/* Latest Releases or In Cinemas */} + {isTraktAvailable ? ( + + ) : ( + + )} {/* Top Rated */} - {/* Popular */} - + {/* 4K Releases or Popular */} + {isTraktAvailable ? ( + + ) : ( + + )} {/* Provider Movies */}