mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-02 03:06:15 +00:00
434 lines
14 KiB
TypeScript
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;
|
|
};
|