clientId = trim($clientId ?? (string) config('nntmux_api.trakttv_api_key', '')); $this->timeout = (int) config('nntmux_api.trakttv_timeout', 30); $this->retryTimes = (int) config('nntmux_api.trakttv_retry_times', 3); $this->retryDelay = (int) config('nntmux_api.trakttv_retry_delay', 100); } /** * Throttle requests: ensure minimum delay since last Trakt API call (global across workers). */ protected function throttle(): void { $key = 'trakt_last_request_at'; $last = Cache::get($key); if ($last !== null) { $elapsed = microtime(true) - (float) $last; if ($elapsed < self::THROTTLE_SECONDS) { usleep((int) ((self::THROTTLE_SECONDS - $elapsed) * 1_000_000)); } } Cache::put($key, microtime(true), 10); } /** * Check if the API key is configured. */ public function isConfigured(): bool { return ! empty($this->clientId); } /** * Get the request headers for Trakt API. * * @return array */ protected function getHeaders(): array { return [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'trakt-api-version' => (string) self::API_VERSION, 'trakt-api-key' => $this->clientId, ]; } /** * Make a GET request to the Trakt API. * Throttles requests, retries on transient failures, and on 429 (rate limit) backs off then retries once. * * @param string $endpoint The API endpoint * @param array $params Query parameters * @param bool $isRetryAfterRateLimit Internal: true when this is the single retry after a 429 * @return array|null Response data or null on failure */ protected function get(string $endpoint, array $params = [], bool $isRetryAfterRateLimit = false): ?array { if (! $this->isConfigured()) { Log::debug('Trakt API key is not configured'); return null; } $this->throttle(); $url = self::BASE_URL.'/'.ltrim($endpoint, '/'); try { $response = Http::timeout($this->timeout) ->retry($this->retryTimes, $this->retryDelay, function (\Throwable $exception, PendingRequest $request, ?string $key = null) { // Don't retry on 404 - resource doesn't exist if ($exception instanceof RequestException) { $status = $exception->response->status(); if ($status === 404) { return false; } // Don't retry 429 with short delay - we handle it below with backoff if ($status === 429) { return false; } } return true; }, throw: false) ->withHeaders($this->getHeaders()) ->get($url, $params); if ($response->successful()) { $data = $response->json(); // Check for API error responses if (isset($data['status']) && $data['status'] === 'failure') { Log::debug('Trakt API returned failure status', [ 'endpoint' => $endpoint, ]); return null; } return $data; } // Handle 429 (rate limit / Cloudflare 1015): back off then retry once if ($response->status() === 429 && ! $isRetryAfterRateLimit) { $backoff = $this->getRateLimitBackoff($response); Log::warning('Trakt API rate limited (429), backing off then retrying once', [ 'endpoint' => $endpoint, 'backoff_seconds' => $backoff, ]); sleep($backoff); return $this->get($endpoint, $params, true); } // Handle 404 - normal (resource not found) if ($response->status() === 404) { Log::debug('Trakt: Resource not found', ['endpoint' => $endpoint]); return null; } Log::warning('Trakt API request failed', [ 'endpoint' => $endpoint, 'status' => $response->status(), 'body' => $response->body(), ]); return null; } catch (\Throwable $e) { // Check if this is a 404 wrapped in an exception if ($e instanceof RequestException && $e->response->status() === 404) { Log::debug('Trakt: Resource not found', ['endpoint' => $endpoint]); return null; } Log::warning('Trakt API request exception', [ 'endpoint' => $endpoint, 'message' => $e->getMessage(), ]); return null; } } /** * Get backoff seconds from 429 response (Retry-After header or default). */ protected function getRateLimitBackoff(Response $response): int { $retryAfter = $response->header('Retry-After'); if ($retryAfter !== null && is_numeric($retryAfter)) { $seconds = (int) $retryAfter; return min(max($seconds, 1), 300); } return self::RATE_LIMIT_BACKOFF_SECONDS; } /** * Get episode summary using multiple ID types for fallback. * * @param int|string $showId The show ID (Trakt, IMDB, TMDB, or TVDB) * @param int|string $season The season number * @param int|string $episode The episode number * @param string $extended Extended info level: 'min', 'full', 'aliases', 'full,aliases' * @param string $idType The ID type: 'trakt', 'imdb', 'tmdb', 'tvdb' (default: 'trakt') * @return array|null Episode data or null on failure */ public function getEpisodeSummary( int|string $showId, int|string $season, int|string $episode, string $extended = 'min', string $idType = 'trakt' ): ?array { // Validate parameters to avoid unnecessary API calls with invalid IDs $showIdInt = is_numeric($showId) ? (int) $showId : 0; $seasonInt = is_numeric($season) ? (int) $season : -1; $episodeInt = is_numeric($episode) ? (int) $episode : -1; // For numeric show IDs, validate they are positive // Season and episode must be non-negative (season 0 can be specials) if (($showIdInt <= 0 && is_numeric($showId)) || $seasonInt < 0 || $episodeInt < 0) { return null; } $extended = $this->normalizeExtended($extended, ['min', 'full', 'aliases', 'full,aliases']); // Format the show ID based on type for cache key and API call $formattedId = $this->formatShowIdForApi($showId, $idType); if ($formattedId === null) { return null; } $cacheKey = "trakt_episode_{$idType}_{$formattedId}_{$season}_{$episode}_{$extended}"; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($formattedId, $season, $episode, $extended) { return $this->get("shows/{$formattedId}/seasons/{$season}/episodes/{$episode}", [ 'extended' => $extended, ]); }); } /** * Get episode summary with fallback to multiple ID types. * Tries Trakt ID first, then falls back to TMDB, TVDB, and IMDB. * * @param array $ids Array of IDs: ['trakt' => X, 'tmdb' => Y, 'tvdb' => Z, 'imdb' => W] * @param int|string $season The season number * @param int|string $episode The episode number * @param string $extended Extended info level * @return array|null Episode data or null on failure */ public function getEpisodeSummaryWithFallback( array $ids, int|string $season, int|string $episode, string $extended = 'min' ): ?array { // Priority order for ID types $idPriority = ['trakt', 'tmdb', 'tvdb', 'imdb']; foreach ($idPriority as $idType) { $id = $ids[$idType] ?? null; if ($this->isUsableExternalId($id, $idType)) { $result = $this->getEpisodeSummary($id, $season, $episode, $extended, $idType); if ($result !== null) { return $result; } } } return null; } /** * Format show ID for Trakt API based on ID type. * Trakt API accepts different ID formats for lookups. * * @param int|string $id The show ID * @param string $idType The ID type: 'trakt', 'imdb', 'tmdb', 'tvdb' * @return string|null Formatted ID for API call or null if invalid */ protected function formatShowIdForApi(int|string $id, string $idType): ?string { if (! in_array($idType, self::ID_TYPES, true)) { return null; } $formattedId = trim((string) $id); if ($formattedId === '') { return null; } if ($idType === 'imdb') { return $this->formatImdbId($formattedId); } return $formattedId; } /** * Get show summary with all external IDs. * This enriches the response with IDs from all supported providers. * * @param int|string $showId Show ID * @param string $idType The ID type: 'trakt', 'imdb', 'tmdb', 'tvdb' * @return array|null Show data with all IDs or null on failure */ public function getShowWithAllIds(int|string $showId, string $idType = 'trakt'): ?array { $formattedId = $this->formatShowIdForApi($showId, $idType); if ($formattedId === null) { return null; } $cacheKey = "trakt_show_ids_{$idType}_{$showId}"; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($formattedId) { return $this->get("shows/{$formattedId}", ['extended' => 'full']); }); } /** * Look up a show by external ID and return all available IDs. * Useful for cross-referencing between TMDB, TVDB, Trakt, and IMDB. * * @param int|string $id The external ID * @param string $idType The ID type: 'imdb', 'tmdb', 'tvdb' * @return array|null Array with all IDs: ['trakt' => X, 'imdb' => Y, 'tmdb' => Z, 'tvdb' => W] or null */ public function lookupShowIds(int|string $id, string $idType = 'tmdb'): ?array { if (! in_array($idType, self::ID_TYPES, true)) { return null; } // Use the search endpoint to find the show by external ID $results = $this->searchById($id, $idType, 'show'); if (empty($results)) { return null; } // Get the first matching show $show = $results[0]['show'] ?? null; if ($show === null || ! isset($show['ids'])) { // @phpstan-ignore isset.offset return null; } return [ 'trakt' => $show['ids']['trakt'] ?? 0, 'imdb' => $show['ids']['imdb'] ?? null, 'tmdb' => $show['ids']['tmdb'] ?? 0, 'tvdb' => $show['ids']['tvdb'] ?? 0, 'tvrage' => $show['ids']['tvrage'] ?? 0, 'slug' => $show['ids']['slug'] ?? '', ]; } /** * Get current box office movies. * * @return array|null Box office data or null on failure */ public function getBoxOffice(): ?array { $cacheKey = 'trakt_boxoffice_'.date('Y-m-d'); return Cache::remember($cacheKey, now()->addHours(6), function () { return $this->get('movies/boxoffice'); }); } /** * Get TV show calendar. * * @param string $startDate Start date (YYYY-MM-DD format, defaults to today) * @param int $days Number of days to retrieve (default: 7) * @return array|null Calendar data or null on failure */ public function getCalendar(string $startDate = '', int $days = 7): ?array { if (empty($startDate)) { $startDate = date('Y-m-d'); } elseif (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { return null; } $days = max(1, min($days, 33)); $cacheKey = "trakt_calendar_{$startDate}_{$days}"; return Cache::remember($cacheKey, now()->addHours(6), function () use ($startDate, $days) { return $this->get("calendars/all/shows/{$startDate}/{$days}"); }); } /** * Get movie summary. * * @param string $movie Movie slug or ID * @param string $extended Extended info level: 'min' or 'full' * @return array|null Movie data or null on failure */ public function getMovieSummary(string $movie, string $extended = 'min'): ?array { if (empty($movie)) { return null; } $extended = $this->normalizeExtended($extended, ['min', 'full']); $movieId = $this->formatMediaId($movie); if ($movieId === null) { return null; } $cacheKey = 'trakt_movie_'.md5($movieId).'_'.$extended; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($movieId, $extended) { return $this->get("movies/{$movieId}", ['extended' => $extended]); }); } /** * Get IMDB ID for a movie. * * @param string $movie Movie slug or ID * @return string|null IMDB ID or null on failure */ public function getMovieImdbId(string $movie): ?string { $data = $this->getMovieSummary($movie, 'min'); return $data['ids']['imdb'] ?? null; } /** * Search by external ID. * * @param int|string $id The ID to search for * @param string $idType The ID type: 'imdb', 'tmdb', 'trakt', 'tvdb' * @param string $mediaType Media type: 'movie', 'show', 'episode', or empty for all * @return array|null Search results or null on failure */ public function searchById(int|string $id, string $idType = 'trakt', string $mediaType = ''): ?array { if (! in_array($idType, self::ID_TYPES, true)) { Log::warning('Trakt: Invalid ID type', ['idType' => $idType]); return null; } $formattedId = trim((string) $id); if ($formattedId === '') { return null; } if ($idType === 'imdb') { $formattedId = $this->formatImdbId($formattedId); if ($formattedId === null) { return null; } } $params = [ 'id_type' => $idType, 'id' => $formattedId, ]; if ($mediaType !== '' && ! in_array($mediaType, self::SEARCH_TYPES, true)) { return null; } if ($mediaType !== '') { $params['type'] = $mediaType; } $cacheKey = 'trakt_search_'.$idType.'_'.preg_replace('/[^a-zA-Z0-9_-]/', '_', $formattedId).'_'.($params['type'] ?? 'all'); return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($formattedId, $idType, $params) { return $this->get('search/'.$idType.'/'.$formattedId, array_intersect_key($params, ['type' => true])); }); } /** * Search for a show by name. * * @param string $query Search query * @param string $type Search type: 'show', 'movie', 'episode', 'person', 'list' * @return array|null Search results or null on failure */ public function searchShows(string $query, string $type = 'show'): ?array { $query = trim($query); if ($query === '' || ! in_array($type, self::SEARCH_TYPES, true)) { return null; } $slug = Str::slug($query); $cacheKey = "trakt_search_{$type}_{$slug}"; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($query, $type) { return $this->get('search/'.$type, ['query' => $query]); }); } /** * Get show summary. * * @param int|string $show Show slug or ID * @param string $extended Extended info level: 'min' or 'full' * @return array|null Show data or null on failure */ public function getShowSummary(int|string $show, string $extended = 'full'): ?array { $showKey = trim((string) $show); if ($showKey === '') { return null; } $extended = $this->normalizeExtended($extended, ['min', 'full']); $showId = $this->formatMediaId($showKey); if ($showId === null) { return null; } $cacheKey = 'trakt_show_'.md5($showId).'_'.$extended; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $extended) { return $this->get("shows/{$showId}", ['extended' => $extended]); }); } /** * Get show seasons. * * @param string $show Show slug or ID * @param string $extended Extended info level * @return array|null Seasons data or null on failure */ public function getShowSeasons(string $show, string $extended = 'full'): ?array { $showId = $this->formatMediaId($show); if ($showId === null) { return null; } $extended = $this->normalizeExtended($extended, ['min', 'full', 'episodes', 'full,episodes']); $cacheKey = 'trakt_seasons_'.md5($showId).'_'.$extended; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $extended) { return $this->get("shows/{$showId}/seasons", ['extended' => $extended]); }); } /** * Get season episodes. * * @param string $show Show slug or ID * @param int $season Season number * @param string $extended Extended info level * @return array|null Episodes data or null on failure */ public function getSeasonEpisodes(string $show, int $season, string $extended = 'full'): ?array { $showId = $this->formatMediaId($show); if ($showId === null || $season < 0) { return null; } $extended = $this->normalizeExtended($extended, ['min', 'full']); $cacheKey = 'trakt_season_episodes_'.md5($showId)."_{$season}_{$extended}"; return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $season, $extended) { return $this->get("shows/{$showId}/seasons/{$season}", ['extended' => $extended]); }); } /** * Get trending shows. * * @param int $limit Number of results to return * @param string $extended Extended info level * @return array|null Trending shows or null on failure */ public function getTrendingShows(int $limit = 10, string $extended = 'full'): ?array { $limit = $this->normalizeLimit($limit); $extended = $this->normalizeExtended($extended, ['min', 'full']); $cacheKey = "trakt_trending_shows_{$limit}_{$extended}"; return Cache::remember($cacheKey, now()->addHours(1), function () use ($limit, $extended) { return $this->get('shows/trending', [ 'limit' => $limit, 'extended' => $extended, ]); }); } /** * Get trending movies. * * @param int $limit Number of results to return * @param string $extended Extended info level * @return array|null Trending movies or null on failure */ public function getTrendingMovies(int $limit = 10, string $extended = 'full'): ?array { $limit = $this->normalizeLimit($limit); $extended = $this->normalizeExtended($extended, ['min', 'full']); $cacheKey = "trakt_trending_movies_{$limit}_{$extended}"; return Cache::remember($cacheKey, now()->addHours(1), function () use ($limit, $extended) { return $this->get('movies/trending', [ 'limit' => $limit, 'extended' => $extended, ]); }); } /** * Get popular shows. * * @param int $limit Number of results to return * @param string $extended Extended info level * @return array|null Popular shows or null on failure */ public function getPopularShows(int $limit = 10, string $extended = 'full'): ?array { $limit = $this->normalizeLimit($limit); $extended = $this->normalizeExtended($extended, ['min', 'full']); $cacheKey = "trakt_popular_shows_{$limit}_{$extended}"; return Cache::remember($cacheKey, now()->addHours(6), function () use ($limit, $extended) { return $this->get('shows/popular', [ 'limit' => $limit, 'extended' => $extended, ]); }); } /** * Get popular movies. * * @param int $limit Number of results to return * @param string $extended Extended info level * @return array|null Popular movies or null on failure */ public function getPopularMovies(int $limit = 10, string $extended = 'full'): ?array { $limit = $this->normalizeLimit($limit); $extended = $this->normalizeExtended($extended, ['min', 'full']); $cacheKey = "trakt_popular_movies_{$limit}_{$extended}"; return Cache::remember($cacheKey, now()->addHours(6), function () use ($limit, $extended) { return $this->get('movies/popular', [ 'limit' => $limit, 'extended' => $extended, ]); }); } /** * Clear cached data for a specific show. */ public function clearShowCache(string $show): void { $slug = Str::slug($show); Cache::forget("trakt_show_{$slug}_min"); Cache::forget("trakt_show_{$slug}_full"); Cache::forget("trakt_seasons_{$slug}_full"); Cache::forget("trakt_seasons_{$slug}_min"); $showId = $this->formatMediaId($show); if ($showId !== null) { Cache::forget('trakt_show_'.md5($showId).'_min'); Cache::forget('trakt_show_'.md5($showId).'_full'); Cache::forget('trakt_seasons_'.md5($showId).'_min'); Cache::forget('trakt_seasons_'.md5($showId).'_full'); Cache::forget('trakt_seasons_'.md5($showId).'_episodes'); Cache::forget('trakt_seasons_'.md5($showId).'_full,episodes'); } } /** * Clear cached data for a specific movie. */ public function clearMovieCache(string $movie): void { $slug = Str::slug($movie); Cache::forget("trakt_movie_{$slug}_min"); Cache::forget("trakt_movie_{$slug}_full"); $movieId = $this->formatMediaId($movie); if ($movieId !== null) { Cache::forget('trakt_movie_'.md5($movieId).'_min'); Cache::forget('trakt_movie_'.md5($movieId).'_full'); } } /** * Normalize IMDb IDs for Trakt endpoints without padding away meaningful * leading zeroes (for example, tt0137523 must stay tt0137523). */ protected function formatImdbId(string $id): ?string { if (preg_match('/^tt\d{6,}$/i', $id) === 1) { return $id; } $digits = preg_replace('/\D/', '', $id); return $digits !== null && preg_match('/^\d{6,}$/', $digits) === 1 ? 'tt'.$digits : null; } protected function formatMediaId(int|string $id): ?string { $id = trim((string) $id); if ($id === '') { return null; } return str_starts_with(strtolower($id), 'tt') ? $this->formatImdbId($id) : Str::slug($id); } /** * @param list $allowed */ protected function normalizeExtended(string $extended, array $allowed): string { return in_array($extended, $allowed, true) ? $extended : $allowed[0]; } protected function normalizeLimit(int $limit): int { return max(1, min($limit, 100)); } protected function isUsableExternalId(mixed $id, string $idType): bool { if ($id === null || $id === '') { return false; } if ($idType === 'imdb') { return $this->formatImdbId((string) $id) !== null; } return is_numeric($id) ? (int) $id > 0 : trim((string) $id) !== ''; } }