diff --git a/example.env b/example.env index 1f28ec6b..13ca18a1 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,6 @@ VITE_TMDB_READ_API_KEY=... VITE_OPENSEARCH_ENABLED=false +VITE_ENABLE_TRAKT=false # make sure the cors proxy url does NOT have a slash at the end VITE_CORS_PROXY_URL=... diff --git a/src/backend/metadata/traktApi.ts b/src/backend/metadata/traktApi.ts index 9c8b0142..b0a61042 100644 --- a/src/backend/metadata/traktApi.ts +++ b/src/backend/metadata/traktApi.ts @@ -1,4 +1,6 @@ +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"; @@ -49,10 +51,129 @@ const traktCache = new SimpleCache(); traktCache.setCompare((a, b) => a.endpoint === b.endpoint); traktCache.initialize(); +// Authentication state - only track concurrent requests +let isAuthenticating = false; +let authToken: string | null = null; +let tokenExpiry: Date | null = null; + +/** + * Clears the authentication token + */ +function clearAuthToken(): void { + authToken = null; + tokenExpiry = null; + localStorage.removeItem("trakt_auth_token"); + localStorage.removeItem("trakt_token_expiry"); +} + +/** + * Stores the authentication token in memory and localStorage + */ +function storeAuthToken(token: string, expiresAt: string): void { + const expiryDate = new Date(expiresAt); + if (Number.isNaN(expiryDate.getTime())) { + console.error("Invalid expiry date format:", expiresAt); + return; + } + + authToken = token; + tokenExpiry = expiryDate; + + // Store in localStorage for persistence + localStorage.setItem("trakt_auth_token", token); + localStorage.setItem("trakt_token_expiry", expiresAt); +} + +/** + * Checks if user is authenticated by checking token validity + */ +function isAuthenticated(): boolean { + // Check memory first + if (authToken && tokenExpiry && tokenExpiry > new Date()) { + return true; + } + + // Check localStorage + const storedToken = localStorage.getItem("trakt_auth_token"); + const storedExpiry = localStorage.getItem("trakt_token_expiry"); + + if (storedToken && storedExpiry) { + const expiryDate = new Date(storedExpiry); + if (expiryDate > new Date()) { + authToken = storedToken; + tokenExpiry = expiryDate; + return true; + } + // Token expired, clear it + clearAuthToken(); + } + + return false; +} + +/** + * Authenticates with the Trakt API using Cloudflare Turnstile + * Stores the auth token for use in API requests + */ +async function authenticateWithTurnstile(): Promise { + // Prevent concurrent authentication attempts + if (isAuthenticating) { + // Wait for existing authentication to complete + await new Promise((resolve) => { + const checkAuth = () => { + if (!isAuthenticating) { + resolve(); + } else { + setTimeout(checkAuth, 100); + } + }; + checkAuth(); + }); + return; + } + + isAuthenticating = true; + + try { + const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); + + // Authenticate with the API + const response = await fetch(`${TRAKT_BASE_URL}/auth`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: turnstileToken, + }), + }); + + if (!response.ok) { + throw new Error(`Authentication failed: ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || "Authentication failed"); + } + + // Store the auth token + storeAuthToken(result.auth_token, result.expires_at); + } finally { + isAuthenticating = false; + } +} + // Base function to fetch from Trakt API async function fetchFromTrakt( endpoint: string, ): Promise { + // Check if Trakt is enabled + if (!conf().ENABLE_TRAKT) { + throw new Error("Trakt API is not enabled, using tmdb lists instead."); + } + // Check cache first const cacheKey: TraktCacheKey = { endpoint }; const cachedResult = traktCache.get(cacheKey); @@ -60,11 +181,49 @@ async function fetchFromTrakt( return cachedResult as T; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); - if (!response.ok) { - throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); + // Ensure we're authenticated + if (!isAuthenticated()) { + await authenticateWithTurnstile(); } + + // Make the API request with authorization header + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + let response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { + headers, + }); + + // If request fails, try re-authenticating and retry once + if (!response.ok) { + // If 401, clear token and re-authenticate + if (response.status === 401) { + clearAuthToken(); + } + + // Re-authenticate and retry + await authenticateWithTurnstile(); + + // Rebuild headers after re-authentication + const retryHeaders: Record = {}; + if (authToken) { + retryHeaders.Authorization = `Bearer ${authToken}`; + } + + response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { + headers: retryHeaders, + }); + + // If retry also fails, throw error + if (!response.ok) { + throw new Error( + `Failed to fetch from ${endpoint}: ${response.statusText}`, + ); + } + } + const result = await response.json(); // Cache the result for 1 hour (3600 seconds) @@ -84,6 +243,11 @@ export async function getReleaseDetails( url += `/${season}/${episode}`; } + // Check if Trakt is enabled + if (!conf().ENABLE_TRAKT) { + throw new Error("Trakt API is not enabled"); + } + // Check cache first const cacheKey: TraktCacheKey = { endpoint: url }; const cachedResult = traktCache.get(cacheKey); @@ -91,11 +255,49 @@ export async function getReleaseDetails( return cachedResult as TraktReleaseResponse; } - // Make the API request - const response = await fetch(`${TRAKT_BASE_URL}${url}`); - if (!response.ok) { - throw new Error(`Failed to fetch release details: ${response.statusText}`); + // Ensure we're authenticated + if (!isAuthenticated()) { + await authenticateWithTurnstile(); } + + // Make the API request with authorization header + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + let response = await fetch(`${TRAKT_BASE_URL}${url}`, { + headers, + }); + + // If request fails, try re-authenticating and retry once + if (!response.ok) { + // If 401, clear token and re-authenticate + if (response.status === 401) { + clearAuthToken(); + } + + // Re-authenticate and retry + await authenticateWithTurnstile(); + + // Rebuild headers after re-authentication + const retryHeaders: Record = {}; + if (authToken) { + retryHeaders.Authorization = `Bearer ${authToken}`; + } + + response = await fetch(`${TRAKT_BASE_URL}${url}`, { + headers: retryHeaders, + }); + + // If retry also fails, throw error + if (!response.ok) { + throw new Error( + `Failed to fetch release details: ${response.statusText}`, + ); + } + } + const result = await response.json(); // Cache the result for 1 hour (3600 seconds) @@ -200,17 +402,17 @@ export const getCuratedMovieLists = async (): Promise => { const lists: CuratedMovieList[] = []; - for (const config of listConfigs) { + for (const listConfig of listConfigs) { try { - const response = await fetchFromTrakt(config.endpoint); + const response = await fetchFromTrakt(listConfig.endpoint); lists.push({ - listName: config.name, - listSlug: config.slug, + listName: listConfig.name, + listSlug: listConfig.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); + console.error(`Failed to fetch ${listConfig.name}:`, error); } } diff --git a/src/setup/config.ts b/src/setup/config.ts index 42b00a58..f9fda765 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -19,6 +19,7 @@ interface Config { BACKEND_URL: string; DISALLOWED_IDS: string; TURNSTILE_KEY: string; + ENABLE_TRAKT: string; CDN_REPLACEMENTS: string; HAS_ONBOARDING: string; ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string; @@ -48,6 +49,7 @@ export interface RuntimeConfig { BACKEND_URL: string | null; DISALLOWED_IDS: string[]; TURNSTILE_KEY: string | null; + ENABLE_TRAKT: boolean; CDN_REPLACEMENTS: Array; HAS_ONBOARDING: boolean; ALLOW_AUTOPLAY: boolean; @@ -81,6 +83,7 @@ const env: Record = { BACKEND_URL: import.meta.env.VITE_BACKEND_URL, DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, + ENABLE_TRAKT: import.meta.env.VITE_ENABLE_TRAKT, CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, @@ -142,6 +145,7 @@ export function conf(): RuntimeConfig { HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true", TURNSTILE_KEY: getKey("TURNSTILE_KEY"), + ENABLE_TRAKT: getKey("ENABLE_TRAKT", "false") === "true", DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") .split(",") .map((v) => v.trim())