diff --git a/.env.example b/.env.example index a01ce6e66..97f7b2573 100644 --- a/.env.example +++ b/.env.example @@ -224,7 +224,11 @@ FANARTTV_APIKEY= GOOGLE_BOOKS_API_KEY= ISBNDB_API_KEY= OMDB_APIKEY= +# Trakt public metadata API client ID. OAuth tokens are not needed for TV/movie metadata lookups. TRAKTTV_APIKEY= +TRAKTTV_TIMEOUT=30 +TRAKTTV_RETRY_TIMES=3 +TRAKTTV_RETRY_DELAY=100 # Temp paths for extraction TEMP_UNRAR_PATH='/var/www/nntmux/resources/tmp/unrar/' diff --git a/app/Services/MovieService.php b/app/Services/MovieService.php index e6c3e430d..c57b0c664 100644 --- a/app/Services/MovieService.php +++ b/app/Services/MovieService.php @@ -189,25 +189,28 @@ class MovieService $data['trailer'] ); } - $imdbId = (str_starts_with($data['ids']['imdb'], 'tt')) ? substr($data['ids']['imdb'], 2) : $data['ids']['imdb']; + $imdbId = (string) $data['ids']['imdb']; + $imdbId = str_starts_with($imdbId, 'tt') ? substr($imdbId, 2) : $imdbId; + $genres = $data['genres'] ?? []; + $genre = is_array($genres) ? implode(', ', $genres) : (string) $genres; $cover = 0; if (File::isFile($this->imgSavePath.$imdbId.'-cover.jpg')) { $cover = 1; } return $this->update([ - 'genre' => implode(', ', $data['genres']), + 'genre' => $genre, 'imdbid' => $this->checkTraktValue($imdbId), - 'language' => $this->checkTraktValue($data['language']), - 'plot' => $this->checkTraktValue($data['overview']), - 'rating' => $this->checkTraktValue($data['rating']), - 'tagline' => $this->checkTraktValue($data['tagline']), - 'title' => $this->checkTraktValue($data['title']), - 'tmdbid' => $this->checkTraktValue($data['ids']['tmdb']), - 'traktid' => $this->checkTraktValue($data['ids']['trakt']), - 'trailer' => $this->checkTraktValue($data['trailer']), + 'language' => $this->checkTraktValue($data['language'] ?? ''), + 'plot' => $this->checkTraktValue($data['overview'] ?? ''), + 'rating' => $this->checkTraktValue($data['rating'] ?? ''), + 'tagline' => $this->checkTraktValue($data['tagline'] ?? ''), + 'title' => $this->checkTraktValue($data['title'] ?? ''), + 'tmdbid' => $this->checkTraktValue($data['ids']['tmdb'] ?? 0), + 'traktid' => $this->checkTraktValue($data['ids']['trakt'] ?? 0), + 'trailer' => $this->checkTraktValue($data['trailer'] ?? ''), 'cover' => $cover, - 'year' => $this->checkTraktValue($data['year']), + 'year' => $this->checkTraktValue($data['year'] ?? ''), ]); } diff --git a/app/Services/TraktService.php b/app/Services/TraktService.php index a31aad998..d520b6770 100644 --- a/app/Services/TraktService.php +++ b/app/Services/TraktService.php @@ -18,7 +18,11 @@ use Illuminate\Support\Str; * A modern service wrapper for the Trakt.tv API. * Provides methods to fetch movie and TV show information. * - * API Documentation: https://trakt.docs.apiary.io/ + * API Documentation: https://docs.trakt.tv/ + * + * This wrapper intentionally uses public metadata endpoints only. OAuth should + * only be added if a future feature needs user-scoped Trakt data such as + * watchlists, watched history, private lists, ratings, or sync endpoints. */ class TraktService { @@ -30,6 +34,8 @@ class TraktService protected const ID_TYPES = ['imdb', 'tmdb', 'trakt', 'tvdb']; + protected const SEARCH_TYPES = ['movie', 'show', 'episode', 'person', 'list']; + /** Minimum seconds between requests to avoid rate limiting (Trakt/Cloudflare). */ protected const THROTTLE_SECONDS = 1; @@ -46,7 +52,7 @@ class TraktService public function __construct(?string $clientId = null) { - $this->clientId = $clientId ?? (string) config('nntmux_api.trakttv_api_key', ''); + $this->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); @@ -84,8 +90,9 @@ class TraktService protected function getHeaders(): array { return [ + 'Accept' => 'application/json', 'Content-Type' => 'application/json', - 'trakt-api-version' => self::API_VERSION, + 'trakt-api-version' => (string) self::API_VERSION, 'trakt-api-key' => $this->clientId, ]; } @@ -232,10 +239,7 @@ class TraktService return null; } - $extended = match ($extended) { - 'aliases', 'full', 'full,aliases' => $extended, - default => 'min', - }; + $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); @@ -243,7 +247,7 @@ class TraktService return null; } - $cacheKey = "trakt_episode_{$idType}_{$showId}_{$season}_{$episode}_{$extended}"; + $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}", [ @@ -272,8 +276,9 @@ class TraktService $idPriority = ['trakt', 'tmdb', 'tvdb', 'imdb']; foreach ($idPriority as $idType) { - if (! empty($ids[$idType]) && $ids[$idType] > 0) { - $result = $this->getEpisodeSummary($ids[$idType], $season, $episode, $extended, $idType); + $id = $ids[$idType] ?? null; + if ($this->isUsableExternalId($id, $idType)) { + $result = $this->getEpisodeSummary($id, $season, $episode, $extended, $idType); if ($result !== null) { return $result; } @@ -297,10 +302,16 @@ class TraktService return null; } - return match ($idType) { - 'imdb' => is_numeric($id) ? 'tt'.str_pad((string) $id, 8, '0', STR_PAD_LEFT) : (string) $id, - 'trakt', 'tmdb', 'tvdb' => (string) $id, - }; + $formattedId = trim((string) $id); + if ($formattedId === '') { + return null; + } + + if ($idType === 'imdb') { + return $this->formatImdbId($formattedId); + } + + return $formattedId; } /** @@ -387,8 +398,12 @@ class TraktService { 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) { @@ -409,12 +424,16 @@ class TraktService return null; } - $extended = $extended === 'full' ? 'full' : 'min'; - $slug = Str::slug($movie); - $cacheKey = "trakt_movie_{$slug}_{$extended}"; + $extended = $this->normalizeExtended($extended, ['min', 'full']); + $movieId = $this->formatMediaId($movie); + if ($movieId === null) { + return null; + } - return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) { - return $this->get("movies/{$slug}", ['extended' => $extended]); + $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]); }); } @@ -447,24 +466,35 @@ class TraktService return null; } - // Format IMDB ID with 'tt' prefix if needed - if ($idType === 'imdb' && is_numeric($id)) { - $id = 'tt'.$id; + $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' => $id, + 'id' => $formattedId, ]; - if (! empty($mediaType)) { + 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_-]/', '_', (string) $id).'_'.($mediaType ?: 'all'); + $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 ($id, $idType, $mediaType) { - return $this->get('search/'.$idType.'/'.$id, ['type' => $mediaType ?: null]); + 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])); }); } @@ -477,7 +507,8 @@ class TraktService */ public function searchShows(string $query, string $type = 'show'): ?array { - if (empty($query)) { + $query = trim($query); + if ($query === '' || ! in_array($type, self::SEARCH_TYPES, true)) { return null; } @@ -504,12 +535,16 @@ class TraktService return null; } - $extended = $extended === 'full' ? 'full' : 'min'; - $slug = Str::slug($showKey); - $cacheKey = "trakt_show_{$slug}_{$extended}"; + $extended = $this->normalizeExtended($extended, ['min', 'full']); + $showId = $this->formatMediaId($showKey); + if ($showId === null) { + return null; + } - return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) { - return $this->get("shows/{$slug}", ['extended' => $extended]); + $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]); }); } @@ -522,15 +557,16 @@ class TraktService */ public function getShowSeasons(string $show, string $extended = 'full'): ?array { - if (empty($show)) { + $showId = $this->formatMediaId($show); + if ($showId === null) { return null; } - $slug = Str::slug($show); - $cacheKey = "trakt_seasons_{$slug}_{$extended}"; + $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 ($slug, $extended) { - return $this->get("shows/{$slug}/seasons", ['extended' => $extended]); + return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $extended) { + return $this->get("shows/{$showId}/seasons", ['extended' => $extended]); }); } @@ -544,15 +580,16 @@ class TraktService */ public function getSeasonEpisodes(string $show, int $season, string $extended = 'full'): ?array { - if (empty($show)) { + $showId = $this->formatMediaId($show); + if ($showId === null || $season < 0) { return null; } - $slug = Str::slug($show); - $cacheKey = "trakt_season_episodes_{$slug}_{$season}_{$extended}"; + $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 ($slug, $season, $extended) { - return $this->get("shows/{$slug}/seasons/{$season}", ['extended' => $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]); }); } @@ -565,6 +602,8 @@ class TraktService */ 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) { @@ -584,6 +623,8 @@ class TraktService */ 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) { @@ -603,6 +644,8 @@ class TraktService */ 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) { @@ -622,6 +665,8 @@ class TraktService */ 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) { @@ -642,6 +687,16 @@ class TraktService 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'); + } } /** @@ -652,5 +707,62 @@ class TraktService $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) !== ''; } } diff --git a/app/Services/TvProcessing/Pipes/TraktPipe.php b/app/Services/TvProcessing/Pipes/TraktPipe.php index 4c25da65b..9fede427f 100644 --- a/app/Services/TvProcessing/Pipes/TraktPipe.php +++ b/app/Services/TvProcessing/Pipes/TraktPipe.php @@ -13,10 +13,10 @@ use App\Services\TvProcessing\TvProcessingResult; */ class TraktPipe extends AbstractTvProviderPipe { - // Video type and source constants (matching Videos class protected constants) + // Video type and source constants (matching AbstractTvProvider constants) private const TYPE_TV = 0; - private const SOURCE_TRAKT = 5; + private const SOURCE_TRAKT = 4; protected int $priority = 50; diff --git a/app/Services/TvProcessing/Providers/AbstractTvProvider.php b/app/Services/TvProcessing/Providers/AbstractTvProvider.php index 77c04ed81..17f6d9ac3 100644 --- a/app/Services/TvProcessing/Providers/AbstractTvProvider.php +++ b/app/Services/TvProcessing/Providers/AbstractTvProvider.php @@ -520,7 +520,7 @@ abstract class AbstractTvProvider extends BaseVideoProvider $following = '[^a-z0-9]([(|\[]\w+[)|\]]\s)*?(\d\d-\d\d|\d{1,3}x\d{2,3}|\(?(19|20)\d{2}\)?|(480|720|1080|2160)[ip]|AAC2?|BD-?Rip|Blu-?Ray|D0?\d|DD5|DiVX|DLMux|DTS|DVD(-?Rip)?|E\d{2,3}|[HX][\-_. ]?26[45]|ITA(-ENG)?|HEVC|[HPS]DTV|PROPER|REPACK|Season|Episode|S\d+[^a-z0-9]?((E\d+)[abr]?)*|WEB[\-_. ]?(DL|Rip)|XViD)[^a-z0-9]?'; // Handle fansub/release group prefixes like [SubsPlease], [Erai-raws], [ASW], etc. - $cleanRelname = preg_replace('/^\[[^\]]+\][_\s]*/i', '', $relname); + $cleanRelname = preg_replace('/^\[[^]]+][_\s]*/i', '', $relname); if (preg_match('/^([^a-z0-9]{2,}|(sample|proof|repost)-)(?P[\w .\-!&]+?)'.$following.'/i', $cleanRelname, $hits)) { $showName = $hits['name']; @@ -888,10 +888,10 @@ abstract class AbstractTvProvider extends BaseVideoProvider $required = ['name', 'season_number', 'episode_number', 'air_date', 'overview']; break; case 'traktS': - $required = ['title', 'ids', 'overview', 'first_aired', 'airs', 'country']; + $required = ['title', 'ids']; break; case 'traktE': - $required = ['title', 'season', 'number', 'overview', 'first_aired']; + $required = ['title', 'season', 'number']; break; } diff --git a/app/Services/TvProcessing/Providers/TraktProvider.php b/app/Services/TvProcessing/Providers/TraktProvider.php index 2348c893d..a7e7c51a9 100644 --- a/app/Services/TvProcessing/Providers/TraktProvider.php +++ b/app/Services/TvProcessing/Providers/TraktProvider.php @@ -31,12 +31,12 @@ class TraktProvider extends AbstractTvProvider /** * The URL to grab the TV fanart. */ - public string $fanartUrl; + public string $fanartUrl = ''; /** * The localized (network airing) timezone of the show. */ - private string $localizedTZ; + private string $localizedTZ = ''; /** * @throws \Exception @@ -278,6 +278,12 @@ class TraktProvider extends AbstractTvProvider { $return = false; + if ($videoId > 0 && (int) $series === -1 && (int) $episode === -1) { + $this->addEpisodesForShow($siteId, $videoId); + + return false; + } + // If we have a video ID, get all available external IDs for fallback if ($videoId > 0) { $ids = $this->getAllSiteIdsFromVideoID($videoId); @@ -352,15 +358,20 @@ class TraktProvider extends AbstractTvProvider sleep(1); foreach ($response as $show) { - if (! is_bool($show)) { + if (is_array($show) && isset($show['show']) && is_array($show['show'])) { + $showTitle = (string) ($show['show']['title'] ?? ''); + if ($showTitle === '') { + continue; + } + // Check for exact title match first and then terminate if found - if ($show['show']['title'] === $name) { + if (strcasecmp($showTitle, $name) === 0) { $highest = $show; break; } // Check each show title for similarity and then find the highest similar value - $matchPercent = $this->checkMatch($show['show']['title'], $name, self::MATCH_PROBABILITY); + $matchPercent = $this->checkMatch($showTitle, $name, self::MATCH_PROBABILITY); // If new match has a higher percentage, set as new matched title if ($matchPercent > $highestMatch) { @@ -369,7 +380,7 @@ class TraktProvider extends AbstractTvProvider } } } - if ($highest !== null) { + if ($highest !== null && ! empty($highest['show']['ids']['trakt'])) { $fullShow = $this->client->getShowSummary($highest['show']['ids']['trakt']); if ($this->checkRequiredAttr($fullShow, 'traktS')) { $return = $this->formatShowInfo($fullShow); @@ -387,31 +398,39 @@ class TraktProvider extends AbstractTvProvider */ public function formatShowInfo(mixed $show): array { - preg_match('/tt(?P\d{6,})$/i', (string) ($show['ids']['imdb'] ?? ''), $imdb); - $this->posterUrl = $show['images']['poster']['thumb'] ?? ''; - $this->fanartUrl = $show['images']['fanart']['thumb'] ?? ''; - $this->localizedTZ = $show['airs']['timezone'] ?? ''; + if (! is_array($show)) { + return []; + } + + $ids = is_array($show['ids'] ?? null) ? $show['ids'] : []; + $airs = is_array($show['airs'] ?? null) ? $show['airs'] : []; + $images = is_array($show['images'] ?? null) ? $show['images'] : []; + + preg_match('/tt(?P\d{6,})$/i', (string) ($ids['imdb'] ?? ''), $imdb); + $this->posterUrl = is_array($images['poster'] ?? null) ? (string) ($images['poster']['thumb'] ?? '') : ''; + $this->fanartUrl = is_array($images['fanart'] ?? null) ? (string) ($images['fanart']['thumb'] ?? '') : ''; + $this->localizedTZ = (string) ($airs['timezone'] ?? ''); $imdbId = (string) ($imdb['imdbid'] ?? ''); - $tvdbId = $show['ids']['tvdb'] ?? 0; + $tvdbId = (int) ($ids['tvdb'] ?? 0); // Look up TVMaze ID using TVDB or IMDB $tvmazeId = $this->lookupTvMazeId($tvdbId, $imdbId); return [ 'type' => parent::TYPE_TV, - 'title' => $show['title'], - 'summary' => $show['overview'], - 'started' => Carbon::parse($show['first_aired'], $this->localizedTZ)->format('Y-m-d'), - 'publisher' => $show['network'], - 'country' => $show['country'], + 'title' => (string) ($show['title'] ?? ''), + 'summary' => (string) ($show['overview'] ?? ''), + 'started' => $this->formatTraktDate($show['first_aired'] ?? null), + 'publisher' => (string) ($show['network'] ?? ''), + 'country' => (string) ($show['country'] ?? ''), 'source' => parent::SOURCE_TRAKT, 'imdb' => $imdbId, 'tvdb' => $tvdbId, - 'trakt' => $show['ids']['trakt'], - 'tvrage' => $show['ids']['tvrage'] ?? 0, + 'trakt' => (int) ($ids['trakt'] ?? 0), + 'tvrage' => (int) ($ids['tvrage'] ?? 0), 'tvmaze' => $tvmazeId, - 'tmdb' => $show['ids']['tmdb'] ?? 0, + 'tmdb' => (int) ($ids['tmdb'] ?? 0), 'aliases' => isset($show['aliases']) && ! empty($show['aliases']) ? $show['aliases'] : '', 'localzone' => $this->localizedTZ, ]; @@ -460,14 +479,63 @@ class TraktProvider extends AbstractTvProvider */ public function formatEpisodeInfo(mixed $episode): array { + $season = (int) ($episode['season'] ?? 0); + $episodeNumber = (int) ($episode['number'] ?? ($episode['episode'] ?? 0)); + return [ - 'title' => (string) $episode['title'], - 'series' => (int) $episode['season'], + 'title' => (string) ($episode['title'] ?? ''), + 'series' => $season, // Prefer the Trakt 'number' field for episode number, fall back to 'episode' if present - 'episode' => (int) ($episode['number'] ?? ($episode['episode'] ?? 0)), - 'se_complete' => 'S'.sprintf('%02d', $episode['season']).'E'.sprintf('%02d', ($episode['number'] ?? ($episode['episode'] ?? 0))), - 'firstaired' => Carbon::parse($episode['first_aired'], $this->localizedTZ)->format('Y-m-d'), - 'summary' => (string) $episode['overview'], + 'episode' => $episodeNumber, + 'se_complete' => 'S'.sprintf('%02d', $season).'E'.sprintf('%02d', $episodeNumber), + 'firstaired' => $this->formatTraktDate($episode['first_aired'] ?? null), + 'summary' => (string) ($episode['overview'] ?? ''), ]; } + + private function addEpisodesForShow(int|string $siteId, int $videoId): void + { + $seasons = $this->client->getShowSeasons((string) $siteId, 'episodes'); + if (! is_array($seasons)) { + return; + } + + foreach ($seasons as $seasonInfo) { + if (! is_array($seasonInfo)) { + continue; + } + + $seasonNumber = (int) ($seasonInfo['number'] ?? -1); + if ($seasonNumber < 0) { + continue; + } + + $episodes = is_array($seasonInfo['episodes'] ?? null) + ? $seasonInfo['episodes'] + : $this->client->getSeasonEpisodes((string) $siteId, $seasonNumber, 'full'); + + if (! is_array($episodes)) { + continue; + } + + foreach ($episodes as $singleEpisode) { + if (is_array($singleEpisode) && $this->checkRequiredAttr($singleEpisode, 'traktE')) { + $this->addEpisode($videoId, $this->formatEpisodeInfo($singleEpisode)); + } + } + } + } + + private function formatTraktDate(mixed $date): string + { + if (empty($date)) { + return ''; + } + + try { + return Carbon::parse((string) $date, $this->localizedTZ !== '' ? $this->localizedTZ : null)->format('Y-m-d'); + } catch (\Throwable) { + return ''; + } + } } diff --git a/config/nntmux_api.php b/config/nntmux_api.php index 93898cbe3..d19974229 100644 --- a/config/nntmux_api.php +++ b/config/nntmux_api.php @@ -11,4 +11,7 @@ return [ 'imdbapi_dev_cooldown_seconds' => env('IMDBAPI_DEV_COOLDOWN_SECONDS', 300), 'omdb_api_key' => env('OMDB_APIKEY', ''), 'trakttv_api_key' => env('TRAKTTV_APIKEY', ''), + 'trakttv_timeout' => env('TRAKTTV_TIMEOUT', 30), + 'trakttv_retry_times' => env('TRAKTTV_RETRY_TIMES', 3), + 'trakttv_retry_delay' => env('TRAKTTV_RETRY_DELAY', 100), ]; diff --git a/tests/Unit/Services/TraktServiceTest.php b/tests/Unit/Services/TraktServiceTest.php index a15f96968..54218d9d8 100644 --- a/tests/Unit/Services/TraktServiceTest.php +++ b/tests/Unit/Services/TraktServiceTest.php @@ -50,4 +50,130 @@ class TraktServiceTest extends TestCase && $request['extended'] === 'full'; }); } + + public function test_public_metadata_requests_use_api_key_headers_without_oauth(): void + { + Http::fake([ + 'https://api.trakt.tv/movies/tt0137523*' => Http::response([ + 'title' => 'Fight Club', + 'ids' => [ + 'imdb' => 'tt0137523', + ], + ], 200), + ]); + + $summary = $this->service->getMovieSummary('tt0137523', 'full'); + + $this->assertNotNull($summary); + + Http::assertSent(function ($request) { + $headers = array_change_key_case($request->headers(), CASE_LOWER); + + return ($headers['trakt-api-key'][0] ?? null) === 'test_trakt_key' + && ($headers['trakt-api-version'][0] ?? null) === '2' + && ! array_key_exists('authorization', $headers); + }); + } + + public function test_search_by_id_formats_imdb_id_without_padding(): void + { + Http::fake([ + 'https://api.trakt.tv/search/imdb/tt0137523*' => Http::response([ + [ + 'type' => 'movie', + 'movie' => [ + 'title' => 'Fight Club', + ], + ], + ], 200), + ]); + + $results = $this->service->searchById('0137523', 'imdb', 'movie'); + + $this->assertIsArray($results); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'https://api.trakt.tv/search/imdb/tt0137523') + && ! str_contains($request->url(), 'tt00137523') + && $request['type'] === 'movie'; + }); + } + + public function test_episode_summary_with_imdb_id_preserves_leading_zeroes(): void + { + Http::fake([ + 'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1*' => Http::response([ + 'title' => 'Pilot', + 'season' => 1, + 'number' => 1, + ], 200), + ]); + + $episode = $this->service->getEpisodeSummary('0137523', 1, 1, 'full', 'imdb'); + + $this->assertIsArray($episode); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1') + && ! str_contains($request->url(), 'tt00137523') + && $request['extended'] === 'full'; + }); + } + + public function test_episode_summary_fallback_accepts_tt_prefixed_imdb_id(): void + { + Http::fake([ + 'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1*' => Http::response([ + 'title' => 'Pilot', + 'season' => 1, + 'number' => 1, + ], 200), + ]); + + $episode = $this->service->getEpisodeSummaryWithFallback(['imdb' => 'tt0137523'], 1, 1, 'full'); + + $this->assertIsArray($episode); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'https://api.trakt.tv/shows/tt0137523/seasons/1/episodes/1') + && $request['extended'] === 'full'; + }); + } + + public function test_search_by_id_omits_empty_type_query_parameter(): void + { + Http::fake([ + 'https://api.trakt.tv/search/trakt/1390*' => Http::response([], 200), + ]); + + $this->service->searchById(1390, 'trakt'); + + Http::assertSent(function ($request) { + parse_str(parse_url($request->url(), PHP_URL_QUERY) ?: '', $query); + + return str_contains($request->url(), 'https://api.trakt.tv/search/trakt/1390') + && ! array_key_exists('type', $query); + }); + } + + public function test_get_show_seasons_supports_episode_extended_public_endpoint(): void + { + Http::fake([ + 'https://api.trakt.tv/shows/1390/seasons*' => Http::response([ + [ + 'number' => 1, + 'episodes' => [], + ], + ], 200), + ]); + + $seasons = $this->service->getShowSeasons('1390', 'episodes'); + + $this->assertIsArray($seasons); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'https://api.trakt.tv/shows/1390/seasons') + && $request['extended'] === 'episodes'; + }); + } }