pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

trakt stuff

Pas f6e9f2be 8fa57d9f

+218 -11
+1
example.env
··· 1 1 VITE_TMDB_READ_API_KEY=... 2 2 VITE_OPENSEARCH_ENABLED=false 3 + VITE_ENABLE_TRAKT=false 3 4 4 5 # make sure the cors proxy url does NOT have a slash at the end 5 6 VITE_CORS_PROXY_URL=...
+213 -11
src/backend/metadata/traktApi.ts
··· 1 + import { conf } from "@/setup/config"; 1 2 import { SimpleCache } from "@/utils/cache"; 3 + import { getTurnstileToken } from "@/utils/turnstile"; 2 4 3 5 import { getMediaDetails } from "./tmdb"; 4 6 import { TMDBContentTypes, TMDBMovieData } from "./types/tmdb"; ··· 49 51 traktCache.setCompare((a, b) => a.endpoint === b.endpoint); 50 52 traktCache.initialize(); 51 53 54 + // Authentication state - only track concurrent requests 55 + let isAuthenticating = false; 56 + let authToken: string | null = null; 57 + let tokenExpiry: Date | null = null; 58 + 59 + /** 60 + * Clears the authentication token 61 + */ 62 + function clearAuthToken(): void { 63 + authToken = null; 64 + tokenExpiry = null; 65 + localStorage.removeItem("trakt_auth_token"); 66 + localStorage.removeItem("trakt_token_expiry"); 67 + } 68 + 69 + /** 70 + * Stores the authentication token in memory and localStorage 71 + */ 72 + function storeAuthToken(token: string, expiresAt: string): void { 73 + const expiryDate = new Date(expiresAt); 74 + if (Number.isNaN(expiryDate.getTime())) { 75 + console.error("Invalid expiry date format:", expiresAt); 76 + return; 77 + } 78 + 79 + authToken = token; 80 + tokenExpiry = expiryDate; 81 + 82 + // Store in localStorage for persistence 83 + localStorage.setItem("trakt_auth_token", token); 84 + localStorage.setItem("trakt_token_expiry", expiresAt); 85 + } 86 + 87 + /** 88 + * Checks if user is authenticated by checking token validity 89 + */ 90 + function isAuthenticated(): boolean { 91 + // Check memory first 92 + if (authToken && tokenExpiry && tokenExpiry > new Date()) { 93 + return true; 94 + } 95 + 96 + // Check localStorage 97 + const storedToken = localStorage.getItem("trakt_auth_token"); 98 + const storedExpiry = localStorage.getItem("trakt_token_expiry"); 99 + 100 + if (storedToken && storedExpiry) { 101 + const expiryDate = new Date(storedExpiry); 102 + if (expiryDate > new Date()) { 103 + authToken = storedToken; 104 + tokenExpiry = expiryDate; 105 + return true; 106 + } 107 + // Token expired, clear it 108 + clearAuthToken(); 109 + } 110 + 111 + return false; 112 + } 113 + 114 + /** 115 + * Authenticates with the Trakt API using Cloudflare Turnstile 116 + * Stores the auth token for use in API requests 117 + */ 118 + async function authenticateWithTurnstile(): Promise<void> { 119 + // Prevent concurrent authentication attempts 120 + if (isAuthenticating) { 121 + // Wait for existing authentication to complete 122 + await new Promise<void>((resolve) => { 123 + const checkAuth = () => { 124 + if (!isAuthenticating) { 125 + resolve(); 126 + } else { 127 + setTimeout(checkAuth, 100); 128 + } 129 + }; 130 + checkAuth(); 131 + }); 132 + return; 133 + } 134 + 135 + isAuthenticating = true; 136 + 137 + try { 138 + const turnstileToken = await getTurnstileToken("0x4AAAAAAB6ocCCpurfWRZyC"); 139 + 140 + // Authenticate with the API 141 + const response = await fetch(`${TRAKT_BASE_URL}/auth`, { 142 + method: "POST", 143 + headers: { 144 + "Content-Type": "application/json", 145 + }, 146 + body: JSON.stringify({ 147 + token: turnstileToken, 148 + }), 149 + }); 150 + 151 + if (!response.ok) { 152 + throw new Error(`Authentication failed: ${response.statusText}`); 153 + } 154 + 155 + const result = await response.json(); 156 + 157 + if (!result.success) { 158 + throw new Error(result.message || "Authentication failed"); 159 + } 160 + 161 + // Store the auth token 162 + storeAuthToken(result.auth_token, result.expires_at); 163 + } finally { 164 + isAuthenticating = false; 165 + } 166 + } 167 + 52 168 // Base function to fetch from Trakt API 53 169 async function fetchFromTrakt<T = TraktListResponse>( 54 170 endpoint: string, 55 171 ): Promise<T> { 172 + // Check if Trakt is enabled 173 + if (!conf().ENABLE_TRAKT) { 174 + throw new Error("Trakt API is not enabled, using tmdb lists instead."); 175 + } 176 + 56 177 // Check cache first 57 178 const cacheKey: TraktCacheKey = { endpoint }; 58 179 const cachedResult = traktCache.get(cacheKey); ··· 60 181 return cachedResult as T; 61 182 } 62 183 63 - // Make the API request 64 - const response = await fetch(`${TRAKT_BASE_URL}${endpoint}`); 184 + // Ensure we're authenticated 185 + if (!isAuthenticated()) { 186 + await authenticateWithTurnstile(); 187 + } 188 + 189 + // Make the API request with authorization header 190 + const headers: Record<string, string> = {}; 191 + if (authToken) { 192 + headers.Authorization = `Bearer ${authToken}`; 193 + } 194 + 195 + let response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { 196 + headers, 197 + }); 198 + 199 + // If request fails, try re-authenticating and retry once 65 200 if (!response.ok) { 66 - throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); 201 + // If 401, clear token and re-authenticate 202 + if (response.status === 401) { 203 + clearAuthToken(); 204 + } 205 + 206 + // Re-authenticate and retry 207 + await authenticateWithTurnstile(); 208 + 209 + // Rebuild headers after re-authentication 210 + const retryHeaders: Record<string, string> = {}; 211 + if (authToken) { 212 + retryHeaders.Authorization = `Bearer ${authToken}`; 213 + } 214 + 215 + response = await fetch(`${TRAKT_BASE_URL}${endpoint}`, { 216 + headers: retryHeaders, 217 + }); 218 + 219 + // If retry also fails, throw error 220 + if (!response.ok) { 221 + throw new Error( 222 + `Failed to fetch from ${endpoint}: ${response.statusText}`, 223 + ); 224 + } 67 225 } 226 + 68 227 const result = await response.json(); 69 228 70 229 // Cache the result for 1 hour (3600 seconds) ··· 84 243 url += `/${season}/${episode}`; 85 244 } 86 245 246 + // Check if Trakt is enabled 247 + if (!conf().ENABLE_TRAKT) { 248 + throw new Error("Trakt API is not enabled"); 249 + } 250 + 87 251 // Check cache first 88 252 const cacheKey: TraktCacheKey = { endpoint: url }; 89 253 const cachedResult = traktCache.get(cacheKey); ··· 91 255 return cachedResult as TraktReleaseResponse; 92 256 } 93 257 94 - // Make the API request 95 - const response = await fetch(`${TRAKT_BASE_URL}${url}`); 258 + // Ensure we're authenticated 259 + if (!isAuthenticated()) { 260 + await authenticateWithTurnstile(); 261 + } 262 + 263 + // Make the API request with authorization header 264 + const headers: Record<string, string> = {}; 265 + if (authToken) { 266 + headers.Authorization = `Bearer ${authToken}`; 267 + } 268 + 269 + let response = await fetch(`${TRAKT_BASE_URL}${url}`, { 270 + headers, 271 + }); 272 + 273 + // If request fails, try re-authenticating and retry once 96 274 if (!response.ok) { 97 - throw new Error(`Failed to fetch release details: ${response.statusText}`); 275 + // If 401, clear token and re-authenticate 276 + if (response.status === 401) { 277 + clearAuthToken(); 278 + } 279 + 280 + // Re-authenticate and retry 281 + await authenticateWithTurnstile(); 282 + 283 + // Rebuild headers after re-authentication 284 + const retryHeaders: Record<string, string> = {}; 285 + if (authToken) { 286 + retryHeaders.Authorization = `Bearer ${authToken}`; 287 + } 288 + 289 + response = await fetch(`${TRAKT_BASE_URL}${url}`, { 290 + headers: retryHeaders, 291 + }); 292 + 293 + // If retry also fails, throw error 294 + if (!response.ok) { 295 + throw new Error( 296 + `Failed to fetch release details: ${response.statusText}`, 297 + ); 298 + } 98 299 } 300 + 99 301 const result = await response.json(); 100 302 101 303 // Cache the result for 1 hour (3600 seconds) ··· 200 402 201 403 const lists: CuratedMovieList[] = []; 202 404 203 - for (const config of listConfigs) { 405 + for (const listConfig of listConfigs) { 204 406 try { 205 - const response = await fetchFromTrakt(config.endpoint); 407 + const response = await fetchFromTrakt(listConfig.endpoint); 206 408 lists.push({ 207 - listName: config.name, 208 - listSlug: config.slug, 409 + listName: listConfig.name, 410 + listSlug: listConfig.slug, 209 411 tmdbIds: response.movie_tmdb_ids.slice(0, 30), // Limit to first 30 items 210 412 count: Math.min(response.movie_tmdb_ids.length, 30), // Update count to reflect the limit 211 413 }); 212 414 } catch (error) { 213 - console.error(`Failed to fetch ${config.name}:`, error); 415 + console.error(`Failed to fetch ${listConfig.name}:`, error); 214 416 } 215 417 } 216 418
+4
src/setup/config.ts
··· 19 19 BACKEND_URL: string; 20 20 DISALLOWED_IDS: string; 21 21 TURNSTILE_KEY: string; 22 + ENABLE_TRAKT: string; 22 23 CDN_REPLACEMENTS: string; 23 24 HAS_ONBOARDING: string; 24 25 ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string; ··· 48 49 BACKEND_URL: string | null; 49 50 DISALLOWED_IDS: string[]; 50 51 TURNSTILE_KEY: string | null; 52 + ENABLE_TRAKT: boolean; 51 53 CDN_REPLACEMENTS: Array<string[]>; 52 54 HAS_ONBOARDING: boolean; 53 55 ALLOW_AUTOPLAY: boolean; ··· 81 83 BACKEND_URL: import.meta.env.VITE_BACKEND_URL, 82 84 DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, 83 85 TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, 86 + ENABLE_TRAKT: import.meta.env.VITE_ENABLE_TRAKT, 84 87 CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, 85 88 HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, 86 89 ALLOW_AUTOPLAY: import.meta.env.VITE_ALLOW_AUTOPLAY, ··· 142 145 HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", 143 146 ALLOW_AUTOPLAY: getKey("ALLOW_AUTOPLAY", "false") === "true", 144 147 TURNSTILE_KEY: getKey("TURNSTILE_KEY"), 148 + ENABLE_TRAKT: getKey("ENABLE_TRAKT", "false") === "true", 145 149 DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") 146 150 .split(",") 147 151 .map((v) => v.trim())