add trackt latest and 4k

This commit is contained in:
Pas 2025-06-02 23:38:56 -06:00
parent 3713a85720
commit 5cc4485629
5 changed files with 409 additions and 62 deletions

View file

@ -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",

View file

@ -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<TraktLatestResponse> {
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<TraktLatestResponse> {
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<TraktReleaseResponse> {
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<TraktLatestResponse> {
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";
}

View file

@ -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<any>(`/${isTVShow ? "tv" : "movie"}/${item.id}`, {
get<any>(`/${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<any>(`/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<any>(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()}
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10 pt-8">
{medias.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
>
<MediaCard
media={{
id: media.id.toString(),
title: media.title || media.name || "",
poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`,
type: mediaType === "tv" ? "show" : "movie",
year:
mediaType === "tv"
? media.first_air_date
? parseInt(media.first_air_date.split("-")[0], 10)
: undefined
: media.release_date
? parseInt(media.release_date.split("-")[0], 10)
: undefined,
}}
onShowDetails={handleShowDetails}
linkable={!category?.includes("upcoming")}
/>
</div>
))}
{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 (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
>
<MediaCard
media={mediaItem}
onShowDetails={handleShowDetails}
linkable={!category?.includes("upcoming")}
/>
</div>
);
})}
</div>
{hasMore && (
<div className="flex justify-center mt-8">

View file

@ -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")

View file

@ -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<string>("");
const progressStore = useProgressStore();
const { t } = useTranslation();
const [latestReleases, setLatestReleases] = useState<Movie[]>([]);
const [latest4KReleases, setLatest4KReleases] = useState<Movie[]>([]);
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<any>(`/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<any>("/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<any>(`/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<any>("/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 */}
<LazyMediaCarousel
category={categories[0].name}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Latest Releases or In Cinemas */}
{isTraktAvailable ? (
<LazyMediaCarousel
medias={isLoadingLatest ? undefined : latestReleases}
category="Latest Releases"
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
) : (
<LazyMediaCarousel
category={categories[0].name}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
)}
{/* Top Rated */}
<LazyMediaCarousel
@ -531,14 +643,25 @@ export function DiscoverContent() {
moreContent
/>
{/* Popular */}
<LazyMediaCarousel
category={categories[2].name}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* 4K Releases or Popular */}
{isTraktAvailable ? (
<LazyMediaCarousel
medias={isLoading4K ? undefined : latest4KReleases}
category="4K Releases"
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
) : (
<LazyMediaCarousel
category={categories[2].name}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
)}
{/* Provider Movies */}
<LazyMediaCarousel