p-stream/src/backend/metadata/traktApi.ts
2026-02-20 17:51:51 -07:00

434 lines
14 KiB
TypeScript

import { conf } from "@/setup/config";
import { SimpleCache } from "@/utils/cache";
import { getTurnstileToken } from "@/utils/turnstile";
import { getMediaDetails } from "./tmdb";
import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb";
import type {
CuratedMovieList,
TraktListResponse,
TraktNetworkResponse,
TraktReleaseResponse,
} from "./types/trakt";
export const TRAKT_BASE_URL = "https://fed-airdate.pstream.mov";
// Token cookie configuration
const TOKEN_COOKIE_NAME = "turnstile_token";
const TOKEN_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds
/**
* Get turnstile token from cookie or fetch new one
* Returns an object indicating if the token was cached or freshly fetched
*/
const getFreshTurnstileToken = async (): Promise<{
token: string;
isCached: boolean;
}> => {
const now = Date.now();
// Check if we have a valid cached token in cookie
if (typeof window !== "undefined") {
const cookies = document.cookie.split(";");
const tokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith(`${TOKEN_COOKIE_NAME}=`),
);
if (tokenCookie) {
try {
const cookieValue = tokenCookie.split("=")[1];
const cookieData = JSON.parse(decodeURIComponent(cookieValue));
const { token, timestamp } = cookieData;
// Check if token is still valid (within 10 minutes)
if (token && timestamp && now - timestamp < TOKEN_CACHE_DURATION) {
return { token, isCached: true };
}
} catch (error) {
// Invalid cookie format, continue to get new token
console.warn("Invalid turnstile token cookie:", error);
}
}
}
// Get new token from Cloudflare
try {
const token = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC");
// Store token in cookie with expiration
if (typeof window !== "undefined") {
const expiresAt = new Date(now + TOKEN_CACHE_DURATION);
const cookieData = {
token,
timestamp: now,
};
const cookieValue = encodeURIComponent(JSON.stringify(cookieData));
document.cookie = `${TOKEN_COOKIE_NAME}=${cookieValue}; expires=${expiresAt.toUTCString()}; path=/; SameSite=Strict`;
}
return { token, isCached: false };
} catch (error) {
throw new Error(`Failed to get turnstile token: ${error}`);
}
};
/**
* Validate turnstile token with server and store for 10 minutes within api.
*/
const validateAndStoreToken = async (token: string): Promise<void> => {
const response = await fetch(`${TRAKT_BASE_URL}/auth`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new Error(`Token validation failed: ${response.statusText}`);
}
};
// Map provider names to their Trakt endpoints
export const PROVIDER_TO_TRAKT_MAP = {
"8": "netflixmovies", // Netflix Movies
"8tv": "netflixtv", // Netflix TV Shows
"2": "applemovie", // Apple TV+ Movies
"2tv": "appletv", // Apple TV+ (both)
"10": "primemovies", // Prime Video Movies
"10tv": "primetv", // Prime Video TV Shows
"15": "hulumovies", // Hulu Movies
"15tv": "hulutv", // Hulu TV Shows
"337": "disneymovies", // Disney+ Movies
"337tv": "disneytv", // Disney+ TV Shows
"1899": "hbomovies", // Max Movies
"1899tv": "hbotv", // Max TV Shows
"531": "paramountmovies", // Paramount+ Movies
"531tv": "paramounttv", // Paramount+ TV Shows
} as const;
// Map provider names to their image filenames
export const PROVIDER_TO_IMAGE_MAP: Record<string, string> = {
Max: "max",
"Prime Video": "prime",
Netflix: "netflix",
"Disney+": "disney",
Hulu: "hulu",
"Apple TV+": "appletv",
"Paramount+": "paramount",
};
// Cache for Trakt API responses
interface TraktCacheKey {
endpoint: string;
}
const traktCache = new SimpleCache<TraktCacheKey, any>();
traktCache.setCompare((a, b) => a.endpoint === b.endpoint);
traktCache.initialize();
// Base function to fetch from Trakt API
async function fetchFromTrakt<T = TraktListResponse>(
endpoint: string,
): Promise<T> {
if (!conf().USE_TRAKT) {
return null as T;
}
// Check cache first
const cacheKey: TraktCacheKey = { endpoint };
const cachedResult = traktCache.get(cacheKey);
if (cachedResult) {
return cachedResult as T;
}
// Try up to 2 times: first with cached/fresh token, retry with forced fresh token if auth fails
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
// 1. Get turnstile token (cached or fresh)
const { token: turnstileToken, isCached } =
await getFreshTurnstileToken();
// 2. Only validate with server if token wasn't cached (newly fetched)
if (!isCached) {
await validateAndStoreToken(turnstileToken);
}
// 3. Make the API request with validated token
const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, {
headers: {
"x-turnstile-token": turnstileToken,
},
});
if (!response.ok) {
// If auth error on first attempt, clear cookie and retry with fresh token
if (
(response.status === 401 || response.status === 403) &&
attempt === 0
) {
// Clear the cookie to force fresh token on retry
if (typeof window !== "undefined") {
document.cookie = `${TOKEN_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
continue; // Try again
}
throw new Error(
`Failed to fetch from ${endpoint}: ${response.statusText}`,
);
}
const result = await response.json();
// Cache the result for 1 hour (3600 seconds)
traktCache.set(cacheKey, result, 3600);
return result as T;
} catch (error) {
// If this was the second attempt or not an auth error, throw
if (
attempt === 1 ||
!(error instanceof Error && error.message.includes("401"))
) {
throw error;
}
// Otherwise, continue to retry
}
}
throw new Error(`Failed to fetch from ${endpoint} after retries`);
}
// Release details
export async function getReleaseDetails(
id: string,
season?: number,
episode?: number,
): Promise<TraktReleaseResponse> {
let url = `/release/${id}`;
if (season !== undefined && episode !== undefined) {
url += `/${season}/${episode}`;
}
if (!conf().USE_TRAKT) {
return null as unknown as TraktReleaseResponse;
}
// Check cache first
const cacheKey: TraktCacheKey = { endpoint: url };
const cachedResult = traktCache.get(cacheKey);
if (cachedResult) {
return cachedResult as TraktReleaseResponse;
}
// Try up to 2 times: first with cached/fresh token, retry with forced fresh token if auth fails
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
// 1. Get turnstile token (cached or fresh)
const { token: turnstileToken, isCached } =
await getFreshTurnstileToken();
// 2. Only validate with server if token wasn't cached (newly fetched)
if (!isCached) {
await validateAndStoreToken(turnstileToken);
}
// 3. Make the API request with validated token
const response = await fetch(`${TRAKT_BASE_URL}${url}`, {
headers: {
"x-turnstile-token": turnstileToken,
},
});
if (!response.ok) {
// If auth error on first attempt, clear cookie and retry with fresh token
if (
(response.status === 401 || response.status === 403) &&
attempt === 0
) {
// Clear the cookie to force fresh token on retry
if (typeof window !== "undefined") {
document.cookie = `${TOKEN_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
}
continue; // Try again
}
throw new Error(
`Failed to fetch release details: ${response.statusText}`,
);
}
const result = await response.json();
// Cache the result for 1 hour (3600 seconds)
traktCache.set(cacheKey, result, 3600);
return result as TraktReleaseResponse;
} catch (error) {
// If this was the second attempt or not an auth error, throw
if (
attempt === 1 ||
!(error instanceof Error && error.message.includes("401"))
) {
throw error;
}
// Otherwise, continue to retry
}
}
throw new Error(`Failed to fetch release details after retries`);
}
// Latest releases
export const getLatestReleases = () => fetchFromTrakt("/latest");
export const getLatest4KReleases = () => fetchFromTrakt("/latest4k");
export const getLatestTVReleases = () => fetchFromTrakt("/latesttv");
// Streaming service releases
export const getAppleTVReleases = () => fetchFromTrakt("/appletv");
export const getAppleMovieReleases = () => fetchFromTrakt("/applemovie");
export const getNetflixMovies = () => fetchFromTrakt("/netflixmovies");
export const getNetflixTVShows = () => fetchFromTrakt("/netflixtv");
export const getPrimeMovies = () => fetchFromTrakt("/primemovies");
export const getPrimeTVShows = () => fetchFromTrakt("/primetv");
export const getHuluMovies = () => fetchFromTrakt("/hulumovies");
export const getHuluTVShows = () => fetchFromTrakt("/hulutv");
export const getDisneyMovies = () => fetchFromTrakt("/disneymovies");
export const getDisneyTVShows = () => fetchFromTrakt("/disneytv");
export const getHBOMovies = () => fetchFromTrakt("/hbomovies");
export const getHBOTVShows = () => fetchFromTrakt("/hbotv");
export const getParamountMovies = () => fetchFromTrakt("/paramountmovies");
export const getParamountTVShows = () => fetchFromTrakt("/paramounttv");
// Popular content
export const getPopularTVShows = () => fetchFromTrakt("/populartv");
export const getPopularMovies = () => fetchFromTrakt("/popularmovies");
export const getTop10Movies = () => fetchFromTrakt("/top10");
// Discovery content used for the featured carousel
export const getDiscoverContent = () =>
fetchFromTrakt<TraktListResponse>("/discover");
// Network information
export const getNetworkContent = (tmdbId: string) =>
fetchFromTrakt<TraktNetworkResponse>(`/network/${tmdbId}`);
// Curated movie lists
export const getNarrativeMovies = () => fetchFromTrakt("/narrative");
export const getTopMovies = () => fetchFromTrakt("/top");
export const getNeverHeardMovies = () => fetchFromTrakt("/never");
export const getLGBTQContent = () => fetchFromTrakt("/LGBTQ");
export const getMindfuckMovies = () => fetchFromTrakt("/mindfuck");
export const getTrueStoryMovies = () => fetchFromTrakt("/truestory");
export const getChristmasMovies = () => fetchFromTrakt("/christmas");
export const getHalloweenMovies = () => fetchFromTrakt("/halloween");
// export const getGreatestTVShows = () => fetchFromTrakt("/greatesttv"); // We only have movies set up. TODO add more tv routes for curated lists so we can have a new page.
// Get all curated movie lists
export const getCuratedMovieLists = async (): Promise<CuratedMovieList[]> => {
const listConfigs = [
{
name: "Top Rated Christmas Movies",
slug: "christmas",
endpoint: "/christmas",
},
{
name: "Letterboxd Top 250 Narrative Feature Films",
slug: "narrative",
endpoint: "/narrative",
},
{
name: "1001 Greatest Movies of All Time",
slug: "top",
endpoint: "/top",
},
{
name: "Great Movies You May Have Never Heard Of",
slug: "never",
endpoint: "/never",
},
{
name: "LGBT Movies/Shows",
slug: "LGBTQ",
endpoint: "/LGBTQ",
},
{
name: "Best Mindfuck Movies",
slug: "mindfuck",
endpoint: "/mindfuck",
},
{
name: "Based on a True Story Movies",
slug: "truestory",
endpoint: "/truestory",
},
{
name: "Halloween Movies",
slug: "halloween",
endpoint: "/halloween",
},
// {
// name: "Rolling Stone's 100 Greatest TV Shows",
// slug: "greatesttv",
// endpoint: "/greatesttv",
// },
];
const lists: CuratedMovieList[] = [];
for (const config of listConfigs) {
try {
const response = await fetchFromTrakt(config.endpoint);
lists.push({
listName: config.name,
listSlug: config.slug,
tmdbIds: response.movie_tmdb_ids.slice(0, 30), // Limit to first 30 items
count: Math.min(response.movie_tmdb_ids.length, 30), // Update count to reflect the limit
});
} catch (error) {
console.error(`Failed to fetch ${config.name}:`, error);
}
}
return lists;
};
// Fetch movie details for multiple TMDB IDs
export const getMovieDetailsForIds = async (
tmdbIds: number[],
limit: number = 50,
): Promise<TMDBMovieData[]> => {
const limitedIds = tmdbIds.slice(0, limit);
const movieDetails: TMDBMovieData[] = [];
// Process in smaller batches to avoid overwhelming the API
const batchSize = 10;
const batchPromises: Promise<TMDBMovieData[]>[] = [];
for (let i = 0; i < limitedIds.length; i += batchSize) {
const batch = limitedIds.slice(i, i + batchSize);
const batchPromise = Promise.all(
batch.map(async (id) => {
try {
const details = await getMediaDetails(
id.toString(),
TMDBContentTypes.MOVIE,
);
return details as TMDBMovieData;
} catch (error) {
console.error(`Failed to fetch movie details for ID ${id}:`, error);
return null;
}
}),
).then((batchResults) =>
batchResults.filter((result): result is TMDBMovieData => result !== null),
);
batchPromises.push(batchPromise);
}
// Process all batches in parallel
const batchResults = await Promise.all(batchPromises);
movieDetails.push(...batchResults.flat());
return movieDetails;
};