From 128cfe688da4876f4968c561c3b5896e99a802c8 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 8 Dec 2025 21:49:37 +0100 Subject: [PATCH] Use custom TMDB client --- Blacklight/Movie.php | 126 ++++--- Blacklight/processing/tv/TMDB.php | 246 +++++++----- app/Services/TmdbClient.php | 321 ++++++++++++++++ composer.json | 1 - composer.lock | 246 ++---------- config/tmdb.php | 23 +- misc/testing/Tests/test_tmdb_API.php | 63 ++-- tests/Unit/TmdbClientTest.php | 544 +++++++++++++++++++++++++++ 8 files changed, 1182 insertions(+), 388 deletions(-) create mode 100644 app/Services/TmdbClient.php create mode 100644 tests/Unit/TmdbClientTest.php diff --git a/Blacklight/Movie.php b/Blacklight/Movie.php index 61084b129..5b9e0339d 100755 --- a/Blacklight/Movie.php +++ b/Blacklight/Movie.php @@ -8,6 +8,7 @@ use App\Models\MovieInfo; use App\Models\Release; use App\Models\Settings; use App\Services\ImdbScraper; +use App\Services\TmdbClient; use Blacklight\libraries\FanartTV; use Blacklight\processing\tv\TraktTv; use Blacklight\utility\Utility; @@ -25,8 +26,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; // ensure scraper imported -use Tmdb; // add TMDB facade +use Illuminate\Support\Str; /** * Class Movie. @@ -744,18 +744,25 @@ class Movie } try { - // Fetch movie data from TMDB - $tmdbLookup = Tmdb::getMoviesApi()->getMovie($lookupId, ['append_to_response' => 'credits']); + $tmdbClient = app(TmdbClient::class); - if (empty($tmdbLookup)) { + if (! $tmdbClient->isConfigured()) { + return false; + } + + // Fetch movie data from TMDB + $tmdbLookup = $tmdbClient->getMovie($lookupId, ['credits']); + + if ($tmdbLookup === null || empty($tmdbLookup)) { Cache::put($cacheKey, false, $expiresAt); return false; } // Title similarity check - if ($this->currentTitle !== '') { - similar_text($this->currentTitle, $tmdbLookup['title'], $percent); + $title = TmdbClient::getString($tmdbLookup, 'title'); + if ($this->currentTitle !== '' && ! empty($title)) { + similar_text($this->currentTitle, $title, $percent); if ($percent < self::MATCH_PERCENT) { Cache::put($cacheKey, false, $expiresAt); @@ -764,31 +771,29 @@ class Movie } // Year similarity check - if ($this->currentYear !== '') { - $tmdbYear = ! empty($tmdbLookup['release_date']) - ? Carbon::parse($tmdbLookup['release_date'])->year - : ''; + $releaseDate = TmdbClient::getString($tmdbLookup, 'release_date'); + if ($this->currentYear !== '' && ! empty($releaseDate)) { + $tmdbYear = Carbon::parse($releaseDate)->year; - if ($tmdbYear !== '') { - similar_text($this->currentYear, $tmdbYear, $percent); - if ($percent < self::YEAR_MATCH_PERCENT) { - Cache::put($cacheKey, false, $expiresAt); + similar_text($this->currentYear, (string) $tmdbYear, $percent); + if ($percent < self::YEAR_MATCH_PERCENT) { + Cache::put($cacheKey, false, $expiresAt); - return false; - } + return false; } } // Build the return array with proper null handling + $imdbIdFromResponse = TmdbClient::getString($tmdbLookup, 'imdb_id'); $ret = [ - 'title' => $tmdbLookup['title'] ?? '', - 'tmdbid' => $tmdbLookup['id'] ?? 0, - 'imdbid' => str_replace('tt', '', $tmdbLookup['imdb_id'] ?? ''), + 'title' => $title, + 'tmdbid' => TmdbClient::getInt($tmdbLookup, 'id'), + 'imdbid' => str_replace('tt', '', $imdbIdFromResponse), 'rating' => '', 'actors' => '', 'director' => '', - 'plot' => $tmdbLookup['overview'] ?? '', - 'tagline' => $tmdbLookup['tagline'] ?? '', + 'plot' => TmdbClient::getString($tmdbLookup, 'overview'), + 'tagline' => TmdbClient::getString($tmdbLookup, 'tagline'), 'year' => '', 'genre' => '', 'cover' => '', @@ -796,48 +801,66 @@ class Movie ]; // Rating - $vote = $tmdbLookup['vote_average'] ?? null; - if ($vote !== null && (int) $vote !== 0) { + $vote = TmdbClient::getFloat($tmdbLookup, 'vote_average'); + if ($vote > 0) { $ret['rating'] = $vote; } // Actors - $actors = Arr::pluck($tmdbLookup['credits']['cast'] ?? [], 'name'); - if (! empty($actors)) { - $ret['actors'] = $actors; + $credits = TmdbClient::getArray($tmdbLookup, 'credits'); + $cast = TmdbClient::getArray($credits, 'cast'); + if (! empty($cast)) { + $actors = []; + foreach ($cast as $member) { + if (is_array($member) && ! empty($member['name'])) { + $actors[] = $member['name']; + } + } + if (! empty($actors)) { + $ret['actors'] = $actors; + } } // Director - get first director only - foreach ($tmdbLookup['credits']['crew'] ?? [] as $crew) { - if ($crew['department'] === 'Directing' && $crew['job'] === 'Director') { - $ret['director'] = $crew['name']; + $crew = TmdbClient::getArray($credits, 'crew'); + foreach ($crew as $crewMember) { + if (! is_array($crewMember)) { + continue; + } + $department = TmdbClient::getString($crewMember, 'department'); + $job = TmdbClient::getString($crewMember, 'job'); + if ($department === 'Directing' && $job === 'Director') { + $ret['director'] = TmdbClient::getString($crewMember, 'name'); break; } } // Year - $released = $tmdbLookup['release_date'] ?? ''; - if (! empty($released)) { - $ret['year'] = Carbon::parse($released)->year; + if (! empty($releaseDate)) { + $ret['year'] = Carbon::parse($releaseDate)->year; } // Genres - $genresa = $tmdbLookup['genres'] ?? []; + $genresa = TmdbClient::getArray($tmdbLookup, 'genres'); if (! empty($genresa)) { $genres = []; foreach ($genresa as $genre) { - $genres[] = $genre['name']; + if (is_array($genre) && ! empty($genre['name'])) { + $genres[] = $genre['name']; + } + } + if (! empty($genres)) { + $ret['genre'] = $genres; } - $ret['genre'] = $genres; } // Cover and backdrop - $posterPath = $tmdbLookup['poster_path'] ?? ''; + $posterPath = TmdbClient::getString($tmdbLookup, 'poster_path'); if (! empty($posterPath)) { $ret['cover'] = 'https://image.tmdb.org/t/p/original'.$posterPath; } - $backdropPath = $tmdbLookup['backdrop_path'] ?? ''; + $backdropPath = TmdbClient::getString($tmdbLookup, 'backdrop_path'); if (! empty($backdropPath)) { $ret['backdrop'] = 'https://image.tmdb.org/t/p/original'.$backdropPath; } @@ -1543,21 +1566,36 @@ class Movie private function searchTMDB(int $releaseId): bool { try { - $data = Tmdb::getSearchApi()->searchMovies($this->currentTitle); - if (empty($data['total_results']) || empty($data['results'])) { + $tmdbClient = app(TmdbClient::class); + + if (! $tmdbClient->isConfigured()) { return false; } - foreach ($data['results'] as $result) { + $data = $tmdbClient->searchMovies($this->currentTitle); + + if ($data === null || empty($data['total_results']) || empty($data['results'])) { + return false; + } + + $results = TmdbClient::getArray($data, 'results'); + foreach ($results as $result) { + if (! is_array($result)) { + continue; + } + // Skip results without ID or release date - if (empty($result['id']) || empty($result['release_date'])) { + $resultId = TmdbClient::getInt($result, 'id'); + $releaseDate = TmdbClient::getString($result, 'release_date'); + + if ($resultId === 0 || empty($releaseDate)) { continue; } // Compare release years similar_text( $this->currentYear, - Carbon::parse($result['release_date'])->year, + (string) Carbon::parse($releaseDate)->year, $percent ); @@ -1566,7 +1604,7 @@ class Movie } // Try to get IMDB ID from TMDB - $ret = $this->fetchTMDBProperties($result['id'], true); + $ret = $this->fetchTMDBProperties((string) $resultId, true); if ($ret === false || empty($ret['imdbid'])) { continue; } diff --git a/Blacklight/processing/tv/TMDB.php b/Blacklight/processing/tv/TMDB.php index b1bdcd1dd..ff3379389 100644 --- a/Blacklight/processing/tv/TMDB.php +++ b/Blacklight/processing/tv/TMDB.php @@ -2,13 +2,8 @@ namespace Blacklight\processing\tv; +use App\Services\TmdbClient; use Blacklight\ReleaseImage; -use Tmdb\Client; -use Tmdb\Exception\TmdbApiException; -use Tmdb\Helper\ImageHelper; -use Tmdb\Laravel\Facades\Tmdb as TmdbClient; -use Tmdb\Repository\ConfigurationRepository; -use Tmdb\Token\Api\ApiToken; class TMDB extends TV { @@ -19,15 +14,10 @@ class TMDB extends TV */ public string $posterUrl = ''; - public ApiToken $token; - - public Client $client; - - public ConfigurationRepository $configRepository; - - public \Tmdb\Model\Configuration $config; - - public ImageHelper $helper; + /** + * Custom TMDB API client + */ + protected TmdbClient $tmdbClient; /** * Fetch banner from site. @@ -67,8 +57,8 @@ class TMDB extends TV // Clean the show name for better match probability $release = $this->parseInfo($row['searchname']); - if (\is_array($release) && $release['name'] !== '') { - if (\in_array($release['cleanname'], $this->titleCache, false)) { + if (is_array($release) && $release['name'] !== '') { + if (in_array($release['cleanname'], $this->titleCache, false)) { if ($this->echooutput) { $this->colorCli->primaryOver(' → '); $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); @@ -101,7 +91,7 @@ class TMDB extends TV // Get the show from TMDB $tmdbShow = $this->getShowInfo((string) $release['cleanname']); - if (\is_array($tmdbShow)) { + if (is_array($tmdbShow)) { // Check if we have the TMDB ID already, if we do use that Video ID $dupeCheck = $this->getVideoIDFromSiteID('tvdb', $tmdbShow['tvdb']); if ($dupeCheck === false) { @@ -263,17 +253,19 @@ class TMDB extends TV */ protected function getShowInfo(string $name): bool|array { - $return = $response = false; + $return = false; - try { - $response = TmdbClient::getSearchApi()->searchTv($name); - } catch (TmdbApiException|\ErrorException $e) { + $this->tmdbClient = app(TmdbClient::class); + + if (! $this->tmdbClient->isConfigured()) { return false; } + $response = $this->tmdbClient->searchTv($name); + sleep(1); - if (\is_array($response) && ! empty($response['results'])) { + if ($response !== null && ! empty($response['results']) && is_array($response['results'])) { $return = $this->matchShowInfo($response['results'], $name); } @@ -287,45 +279,62 @@ class TMDB extends TV { $return = false; $highestMatch = 0; + $highest = null; - $show = []; foreach ($shows as $show) { - if ($this->checkRequiredAttr($show, 'tmdbS')) { - // Check for exact title match first and then terminate if found - if (strtolower($show['name']) === strtolower($cleanName)) { - $highest = $show; - break; - } - // Check each show title for similarity and then find the highest similar value - $matchPercent = $this->checkMatch(strtolower($show['name']), strtolower($cleanName), self::MATCH_PROBABILITY); + if (! is_array($show) || ! $this->checkRequiredAttr($show, 'tmdbS')) { + continue; + } - // If new match has a higher percentage, set as new matched title - if ($matchPercent > $highestMatch) { - $highestMatch = $matchPercent; - $highest = $show; - } + $showName = TmdbClient::getString($show, 'name'); + if (empty($showName)) { + continue; + } + + // Check for exact title match first and then terminate if found + if (strtolower($showName) === strtolower($cleanName)) { + $highest = $show; + break; + } + + // Check each show title for similarity and then find the highest similar value + $matchPercent = $this->checkMatch(strtolower($showName), strtolower($cleanName), self::MATCH_PROBABILITY); + + // If new match has a higher percentage, set as new matched title + if ($matchPercent > $highestMatch) { + $highestMatch = $matchPercent; + $highest = $show; } } - if (! empty($highest)) { - try { - $showAlternativeTitles = TmdbClient::getTvApi()->getAlternativeTitles($highest['id']); - } catch (TmdbApiException $e) { - return false; - } - try { - $showExternalIds = TmdbClient::getTvApi()->getExternalIds($highest['id']); - } catch (TmdbApiException $e) { + + if ($highest !== null && is_array($highest)) { + $showId = TmdbClient::getInt($highest, 'id'); + if ($showId === 0) { return false; } - if (\is_array($showAlternativeTitles)) { - foreach ($showAlternativeTitles['results'] as $aka) { - $highest['alternative_titles'][] = $aka['title']; - } - // Use available network info if present - $highest['network'] = $highest['networks'][0]['name'] ?? ''; - $highest['external_ids'] = $showExternalIds; + $showAlternativeTitles = $this->tmdbClient->getTvAlternativeTitles($showId); + $showExternalIds = $this->tmdbClient->getTvExternalIds($showId); + + if ($showAlternativeTitles === null || $showExternalIds === null) { + return false; } + + $alternativeTitles = []; + $results = TmdbClient::getArray($showAlternativeTitles, 'results'); + foreach ($results as $aka) { + if (is_array($aka) && isset($aka['title'])) { + $alternativeTitles[] = $aka['title']; + } + } + $highest['alternative_titles'] = $alternativeTitles; + + // Use available network info if present + $networks = TmdbClient::getArray($highest, 'networks'); + $highest['network'] = ! empty($networks[0]['name']) ? $networks[0]['name'] : ''; + + $highest['external_ids'] = $showExternalIds; + $return = $this->formatShowInfo($highest); } @@ -366,38 +375,61 @@ class TMDB extends TV { $return = false; - try { - if ($videoId > 0 && (int) $series === -1 && (int) $episode === -1) { - // Bulk fetch all episodes for all seasons and insert - $tvDetails = TmdbClient::getTvApi()->getTvshow($siteId); - if (\is_array($tvDetails) && ! empty($tvDetails['seasons'])) { - foreach ($tvDetails['seasons'] as $seriesInfo) { - if (! empty($seriesInfo['season_number']) && $seriesInfo['season_number'] > 0) { - $seriesData = TmdbClient::getTvSeasonApi()->getSeason($siteId, $seriesInfo['season_number']); - sleep(1); - if (\is_array($seriesData) && ! empty($seriesData['episodes'])) { - foreach ($seriesData['episodes'] as $ep) { - if ($this->checkRequiredAttr($ep, 'tmdbE')) { - $this->addEpisode($videoId, $this->formatEpisodeInfo($ep)); - } - } - } - } - } - } + if (! isset($this->tmdbClient)) { + $this->tmdbClient = app(TmdbClient::class); + } - return false; - } - - $response = TmdbClient::getTvEpisodeApi()->getEpisode($siteId, $series, $episode); - } catch (TmdbApiException $e) { + if (! $this->tmdbClient->isConfigured()) { return false; } + // Bulk fetch all episodes for all seasons and insert + if ($videoId > 0 && (int) $series === -1 && (int) $episode === -1) { + $tvDetails = $this->tmdbClient->getTvShow((int) $siteId); + + if ($tvDetails === null) { + return false; + } + + $seasons = TmdbClient::getArray($tvDetails, 'seasons'); + if (empty($seasons)) { + return false; + } + + foreach ($seasons as $seriesInfo) { + if (! is_array($seriesInfo)) { + continue; + } + + $seasonNumber = TmdbClient::getInt($seriesInfo, 'season_number'); + if ($seasonNumber <= 0) { + continue; + } + + $seriesData = $this->tmdbClient->getTvSeason((int) $siteId, $seasonNumber); + sleep(1); + + if ($seriesData === null) { + continue; + } + + $episodes = TmdbClient::getArray($seriesData, 'episodes'); + foreach ($episodes as $ep) { + if (is_array($ep) && $this->checkRequiredAttr($ep, 'tmdbE')) { + $this->addEpisode($videoId, $this->formatEpisodeInfo($ep)); + } + } + } + + return false; + } + + // Single episode lookup + $response = $this->tmdbClient->getTvEpisode((int) $siteId, (int) $series, (int) $episode); sleep(1); // Handle Single Episode Lookups - if (\is_array($response) && $this->checkRequiredAttr($response, 'tmdbE')) { + if ($response !== null && is_array($response) && $this->checkRequiredAttr($response, 'tmdbE')) { $return = $this->formatEpisodeInfo($response); } @@ -410,30 +442,41 @@ class TMDB extends TV */ protected function formatShowInfo($show): array { + if (! is_array($show)) { + return []; + } + + $posterPath = TmdbClient::getString($show, 'poster_path'); // Prefer a reasonable default size for posters if we only have a path - $this->posterUrl = isset($show['poster_path']) && $show['poster_path'] !== '' - ? 'https://image.tmdb.org/t/p/w500'.$show['poster_path'] + $this->posterUrl = ! empty($posterPath) + ? 'https://image.tmdb.org/t/p/w500'.$posterPath : ''; - if (isset($show['external_ids']['imdb_id'])) { - preg_match('/tt(?P\d{6,7})$/i', $show['external_ids']['imdb_id'], $imdb); + $imdbId = 0; + $externalIds = TmdbClient::getArray($show, 'external_ids'); + if (! empty($externalIds['imdb_id'])) { + preg_match('/tt(?P\d{6,7})$/i', $externalIds['imdb_id'], $imdb); + $imdbId = $imdb['imdbid'] ?? 0; } + $originCountry = TmdbClient::getArray($show, 'origin_country'); + $alternativeTitles = TmdbClient::getArray($show, 'alternative_titles'); + return [ 'type' => parent::TYPE_TV, - 'title' => $show['name'], - 'summary' => $show['overview'], - 'started' => $show['first_air_date'], - 'publisher' => $show['network'] ?? '', - 'country' => $show['origin_country'][0] ?? '', + 'title' => TmdbClient::getString($show, 'name'), + 'summary' => TmdbClient::getString($show, 'overview'), + 'started' => TmdbClient::getString($show, 'first_air_date'), + 'publisher' => TmdbClient::getString($show, 'network'), + 'country' => ! empty($originCountry[0]) ? $originCountry[0] : '', 'source' => parent::SOURCE_TMDB, - 'imdb' => $imdb['imdbid'] ?? 0, - 'tvdb' => $show['external_ids']['tvdb_id'] ?? 0, + 'imdb' => $imdbId, + 'tvdb' => TmdbClient::getInt($externalIds, 'tvdb_id'), 'trakt' => 0, - 'tvrage' => $show['external_ids']['tvrage_id'] ?? 0, + 'tvrage' => TmdbClient::getInt($externalIds, 'tvrage_id'), 'tvmaze' => 0, - 'tmdb' => $show['id'], - 'aliases' => ! empty($show['alternative_titles']) ? $show['alternative_titles'] : '', + 'tmdb' => TmdbClient::getInt($show, 'id'), + 'aliases' => ! empty($alternativeTitles) ? $alternativeTitles : '', 'localzone' => "''", ]; } @@ -444,13 +487,20 @@ class TMDB extends TV */ protected function formatEpisodeInfo($episode): array { + if (! is_array($episode)) { + return []; + } + + $seasonNumber = TmdbClient::getInt($episode, 'season_number'); + $episodeNumber = TmdbClient::getInt($episode, 'episode_number'); + return [ - 'title' => (string) $episode['name'], - 'series' => (int) $episode['season_number'], - 'episode' => (int) $episode['episode_number'], - 'se_complete' => 'S'.sprintf('%02d', $episode['season_number']).'E'.sprintf('%02d', $episode['episode_number']), - 'firstaired' => (string) $episode['air_date'], - 'summary' => (string) $episode['overview'], + 'title' => TmdbClient::getString($episode, 'name'), + 'series' => $seasonNumber, + 'episode' => $episodeNumber, + 'se_complete' => 'S'.sprintf('%02d', $seasonNumber).'E'.sprintf('%02d', $episodeNumber), + 'firstaired' => TmdbClient::getString($episode, 'air_date'), + 'summary' => TmdbClient::getString($episode, 'overview'), ]; } } diff --git a/app/Services/TmdbClient.php b/app/Services/TmdbClient.php new file mode 100644 index 000000000..900b417c3 --- /dev/null +++ b/app/Services/TmdbClient.php @@ -0,0 +1,321 @@ +apiKey = (string) config('tmdb.api_key', ''); + $this->timeout = (int) config('tmdb.timeout', 30); + $this->retryTimes = (int) config('tmdb.retry_times', 3); + $this->retryDelay = (int) config('tmdb.retry_delay', 100); + } + + /** + * Check if the API key is configured + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey); + } + + /** + * Make a GET request to the TMDB API + * + * @param string $endpoint The API endpoint + * @param array $params Additional query parameters + * @return array|null Response data or null on failure + */ + protected function get(string $endpoint, array $params = []): ?array + { + if (! $this->isConfigured()) { + Log::warning('TMDB API key is not configured'); + + return null; + } + + $params['api_key'] = $this->apiKey; + + try { + $response = Http::timeout($this->timeout) + ->retry($this->retryTimes, $this->retryDelay) + ->get(self::BASE_URL.$endpoint, $params); + + if ($response->successful()) { + return $response->json(); + } + + // Handle specific error codes + if ($response->status() === 404) { + return null; + } + + Log::warning('TMDB API request failed', [ + 'endpoint' => $endpoint, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } catch (\Throwable $e) { + Log::warning('TMDB API request exception', [ + 'endpoint' => $endpoint, + 'message' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Get the full image URL + * + * @param string|null $path The image path from TMDB + * @param string $size The image size (w92, w154, w185, w342, w500, w780, original) + */ + public function getImageUrl(?string $path, string $size = 'w500'): string + { + if (empty($path)) { + return ''; + } + + return self::IMAGE_BASE_URL.'/'.$size.$path; + } + + // ========================================================================= + // MOVIE METHODS + // ========================================================================= + + /** + * Search for movies by title + * + * @param string $query The search query + * @param int $page Page number for pagination + * @param string|null $year Filter by release year + * @return array|null Search results or null on failure + */ + public function searchMovies(string $query, int $page = 1, ?string $year = null): ?array + { + $params = [ + 'query' => $query, + 'page' => $page, + 'include_adult' => false, + ]; + + if ($year !== null) { + $params['year'] = $year; + } + + return $this->get('/search/movie', $params); + } + + /** + * Get movie details by TMDB ID or IMDB ID + * + * @param int|string $id The TMDB ID or IMDB ID (with 'tt' prefix) + * @param array $appendToResponse Additional data to append (e.g., ['credits', 'external_ids']) + * @return array|null Movie data or null on failure + */ + public function getMovie(int|string $id, array $appendToResponse = []): ?array + { + $params = []; + + if (! empty($appendToResponse)) { + $params['append_to_response'] = implode(',', $appendToResponse); + } + + return $this->get('/movie/'.$id, $params); + } + + /** + * Get movie credits (cast and crew) + * + * @param int $movieId The TMDB movie ID + * @return array|null Credits data or null on failure + */ + public function getMovieCredits(int $movieId): ?array + { + return $this->get('/movie/'.$movieId.'/credits'); + } + + /** + * Get movie external IDs (IMDB, etc.) + * + * @param int $movieId The TMDB movie ID + * @return array|null External IDs or null on failure + */ + public function getMovieExternalIds(int $movieId): ?array + { + return $this->get('/movie/'.$movieId.'/external_ids'); + } + + // ========================================================================= + // TV SHOW METHODS + // ========================================================================= + + /** + * Search for TV shows by title + * + * @param string $query The search query + * @param int $page Page number for pagination + * @param int|null $firstAirDateYear Filter by first air date year + * @return array|null Search results or null on failure + */ + public function searchTv(string $query, int $page = 1, ?int $firstAirDateYear = null): ?array + { + $params = [ + 'query' => $query, + 'page' => $page, + 'include_adult' => false, + ]; + + if ($firstAirDateYear !== null) { + $params['first_air_date_year'] = $firstAirDateYear; + } + + return $this->get('/search/tv', $params); + } + + /** + * Get TV show details by ID + * + * @param int|string $id The TMDB TV show ID + * @param array $appendToResponse Additional data to append + * @return array|null TV show data or null on failure + */ + public function getTvShow(int|string $id, array $appendToResponse = []): ?array + { + $params = []; + + if (! empty($appendToResponse)) { + $params['append_to_response'] = implode(',', $appendToResponse); + } + + return $this->get('/tv/'.$id, $params); + } + + /** + * Get TV show external IDs (IMDB, TVDB, etc.) + * + * @param int $tvId The TMDB TV show ID + * @return array|null External IDs or null on failure + */ + public function getTvExternalIds(int $tvId): ?array + { + return $this->get('/tv/'.$tvId.'/external_ids'); + } + + /** + * Get TV show alternative titles + * + * @param int $tvId The TMDB TV show ID + * @return array|null Alternative titles or null on failure + */ + public function getTvAlternativeTitles(int $tvId): ?array + { + return $this->get('/tv/'.$tvId.'/alternative_titles'); + } + + /** + * Get TV season details + * + * @param int $tvId The TMDB TV show ID + * @param int $seasonNumber The season number + * @return array|null Season data or null on failure + */ + public function getTvSeason(int $tvId, int $seasonNumber): ?array + { + return $this->get('/tv/'.$tvId.'/season/'.$seasonNumber); + } + + /** + * Get TV episode details + * + * @param int $tvId The TMDB TV show ID + * @param int $seasonNumber The season number + * @param int $episodeNumber The episode number + * @return array|null Episode data or null on failure + */ + public function getTvEpisode(int $tvId, int $seasonNumber, int $episodeNumber): ?array + { + return $this->get('/tv/'.$tvId.'/season/'.$seasonNumber.'/episode/'.$episodeNumber); + } + + // ========================================================================= + // HELPER METHODS FOR NULL-SAFE DATA EXTRACTION + // ========================================================================= + + /** + * Safely get a string value from an array + */ + public static function getString(array $data, string $key, string $default = ''): string + { + return isset($data[$key]) && is_string($data[$key]) ? $data[$key] : $default; + } + + /** + * Safely get an integer value from an array + */ + public static function getInt(array $data, string $key, int $default = 0): int + { + return isset($data[$key]) && is_numeric($data[$key]) ? (int) $data[$key] : $default; + } + + /** + * Safely get a float value from an array + */ + public static function getFloat(array $data, string $key, float $default = 0.0): float + { + return isset($data[$key]) && is_numeric($data[$key]) ? (float) $data[$key] : $default; + } + + /** + * Safely get an array value from an array + */ + public static function getArray(array $data, string $key, array $default = []): array + { + return isset($data[$key]) && is_array($data[$key]) ? $data[$key] : $default; + } + + /** + * Safely get a nested value from an array using dot notation + */ + public static function getNested(array $data, string $path, mixed $default = null): mixed + { + $keys = explode('.', $path); + $value = $data; + + foreach ($keys as $key) { + if (! is_array($value) || ! array_key_exists($key, $value)) { + return $default; + } + $value = $value[$key]; + } + + return $value ?? $default; + } +} + diff --git a/composer.json b/composer.json index 69fa85f27..f50533092 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,6 @@ "creativeorange/gravatar": "^1.0", "dariusiii/php-itunes-api": "^1.0", "dariusiii/rarinfo": "^2.7", - "dariusiii/tmdb-laravel": "^12.0.2", "dariusiii/tv-maze-php-api": "^2.0.0", "divineomega/php-cli-progress-bar": "^2.1", "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 1b763c7b2..7baca0be5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "51bf4d154595d4d6a5d5335d732d5be3", + "content-hash": "60f2d300dbd8d23493f46be6cea08fa4", "packages": [ { "name": "aharen/omdbapi", @@ -520,16 +520,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.9", + "version": "1.5.10", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "1905981ee626e6f852448b7aaa978f8666c5bc54" + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/1905981ee626e6f852448b7aaa978f8666c5bc54", - "reference": "1905981ee626e6f852448b7aaa978f8666c5bc54", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", "shasum": "" }, "require": { @@ -576,7 +576,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.9" + "source": "https://github.com/composer/ca-bundle/tree/1.5.10" }, "funding": [ { @@ -588,7 +588,7 @@ "type": "github" } ], - "time": "2025-11-06T11:46:17+00:00" + "time": "2025-12-08T15:06:51+00:00" }, { "name": "creativeorange/gravatar", @@ -761,82 +761,6 @@ }, "time": "2019-12-18T10:21:35+00:00" }, - { - "name": "dariusiii/tmdb-laravel", - "version": "12.1.2", - "source": { - "type": "git", - "url": "https://github.com/DariusIII/tmdb-laravel.git", - "reference": "fa7b3d5302d37101d64132f83dab05141ef6fef9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DariusIII/tmdb-laravel/zipball/fa7b3d5302d37101d64132f83dab05141ef6fef9", - "reference": "fa7b3d5302d37101d64132f83dab05141ef6fef9", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "^7.2", - "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", - "nyholm/psr7": "^1.8", - "php": ">=8.3", - "php-tmdb/api": "^5.0" - }, - "require-dev": { - "doctrine/cache": "^2.2", - "illuminate/events": "^8.0|^9.0|^10.0|^11.0|^12.0", - "monolog/monolog": "^3.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0|^10.0|^11.0", - "psr/event-dispatcher": "^1.0", - "symfony/event-dispatcher": "^6.0|^7.0" - }, - "suggest": { - "doctrine/cache": "Required if you want to make use of caching features.", - "monolog/monolog": "Required if you want to make use of logging features." - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Tmdb": "Tmdb\\Laravel\\Facades\\Tmdb" - }, - "providers": [ - "Tmdb\\Laravel\\TmdbServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Tmdb\\Laravel\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mark Redeman", - "email": "markredeman@gmail.com" - } - ], - "description": "Laravel Package for TMDB ( The Movie Database ) API. Provides easy access to the wtfzdotnet/php-tmdb-api library.", - "keywords": [ - "api", - "laravel", - "movie", - "php", - "tmdb", - "tv", - "tv show", - "tvdb", - "wrapper" - ], - "support": { - "source": "https://github.com/DariusIII/tmdb-laravel/tree/12.1.2" - }, - "time": "2025-12-08T11:13:48+00:00" - }, { "name": "dariusiii/tv-maze-php-api", "version": "2.0.5", @@ -4288,20 +4212,20 @@ }, { "name": "league/uri", - "version": "7.6.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "f625804987a0a9112d954f9209d91fec52182344" + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", - "reference": "f625804987a0a9112d954f9209d91fec52182344", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.6", + "league/uri-interfaces": "^7.7", "php": "^8.1", "psr/http-factory": "^1" }, @@ -4374,7 +4298,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.6.0" + "source": "https://github.com/thephpleague/uri/tree/7.7.0" }, "funding": [ { @@ -4382,20 +4306,20 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2025-12-07T16:02:06+00:00" }, { "name": "league/uri-interfaces", - "version": "7.6.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", "shasum": "" }, "require": { @@ -4458,7 +4382,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" }, "funding": [ { @@ -4466,7 +4390,7 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2025-12-07T16:03:21+00:00" }, { "name": "leventcz/laravel-top", @@ -6726,124 +6650,6 @@ }, "time": "2024-03-15T13:55:21+00:00" }, - { - "name": "php-tmdb/api", - "version": "5.0.1", - "source": { - "type": "git", - "url": "https://github.com/DariusIII/tmdbapi.git", - "reference": "7b93c23a7493f055b83e392d3dda9699d421bd62" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DariusIII/tmdbapi/zipball/7b93c23a7493f055b83e392d3dda9699d421bd62", - "reference": "7b93c23a7493f055b83e392d3dda9699d421bd62", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": "^7.3 || ^8.0", - "php-http/discovery": "^1.11", - "psr/cache": "^1 || ^2 || ^3", - "psr/event-dispatcher": "^1", - "psr/event-dispatcher-implementation": "^1", - "psr/http-client": "^1", - "psr/http-client-implementation": "^1", - "psr/http-factory": "^1", - "psr/http-factory-implementation": "^1", - "psr/http-message": "^2.0", - "psr/log": "^1 || ^2 || ^3", - "psr/simple-cache": "^1 || ^2 || ^3", - "symfony/options-resolver": "^4.4 || ^5 || ^6 || ^7" - }, - "require-dev": { - "jeroen/psr-log-test-doubles": "^2.1 || ^3", - "monolog/monolog": "^2.9.1 || ^3.0", - "nyholm/psr7": "^1.8", - "php-http/cache-plugin": "^1.7", - "php-http/guzzle7-adapter": "^1.0", - "php-http/mock-client": "^1.2", - "phpstan/phpstan": "^1.8.1", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpunit/phpunit": "^9.6.3", - "spaze/phpstan-disallowed-calls": "^2.11", - "symfony/cache": "^4.4 || ^5 || ^6 || ^7", - "symfony/event-dispatcher": "^4.4 || ^5 || ^6 || ^7" - }, - "suggest": { - "monolog/monolog": "Great logger to use, but you can pick any PSR-3 logger you wish.", - "php-http/cache-plugin": "When making use of cache, you need to install this plugin.", - "psr/cache-implementation": "If you wish to enable caching features, provide an PSR-6 cache implementation.", - "psr/log-implementation": "If you wish to enable logging features, provide an PSR-3 logger.", - "psr/simple-cache-implementation": "If you wish to enable caching features, provide an PSR-16 cache.", - "symfony/cache": "Great cache to use, but you can pick any PSR-6 cache you wish." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, - "autoload": { - "psr-4": { - "Tmdb\\": "lib/Tmdb" - } - }, - "scripts": { - "test": [ - "vendor/bin/phpunit" - ], - "test-ci": [ - "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml coverage" - ], - "test-cs": [ - "vendor/bin/phpcs" - ], - "test-phpstan": [ - "vendor/bin/phpstan analyse" - ] - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Roterman", - "homepage": "http://wtfz.net", - "email": "michael@wtfz.net" - } - ], - "description": "PHP wrapper for TMDB (TheMovieDatabase) API v3. Supports two types of approaches, one modelled with repositories, models and factories. And the other by simple array access to RAW data from The Movie Database.", - "homepage": "https://github.com/php-tmdb/api", - "keywords": [ - "api", - "movie", - "php", - "tmdb", - "tv", - "tv show", - "tvdb", - "wrapper" - ], - "support": { - "source": "https://github.com/DariusIII/tmdbapi/tree/5.0.1" - }, - "funding": [ - { - "type": "github", - "url": "https://github.com/php-tmdb" - }, - { - "type": "github", - "url": "https://github.com/wtfzdotnet" - }, - { - "type": "custom", - "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=SMLZ362KQ8K8W" - } - ], - "time": "2025-03-26T11:24:45+00:00" - }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -18610,16 +18416,16 @@ }, { "name": "theseer/tokenizer", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "d1dd771235a40694cb5cb7239e531cf9b9702682" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/d1dd771235a40694cb5cb7239e531cf9b9702682", - "reference": "d1dd771235a40694cb5cb7239e531cf9b9702682", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { @@ -18648,7 +18454,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/2.0.0" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -18656,7 +18462,7 @@ "type": "github" } ], - "time": "2025-12-06T10:20:26+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/config/tmdb.php b/config/tmdb.php index 7de4bd507..c9fc0682d 100644 --- a/config/tmdb.php +++ b/config/tmdb.php @@ -1,12 +1,29 @@ - * @copyright (c) 2014, Mark Redeman + * TMDB API Configuration + * + * This configuration is used by the custom TmdbClient service. */ return [ /* - * Api key + * API key for The Movie Database (TMDB) + * Get your key at: https://www.themoviedb.org/settings/api */ 'api_key' => env('TMDB_APIKEY', ''), + + /* + * Request timeout in seconds + */ + 'timeout' => env('TMDB_TIMEOUT', 30), + + /* + * Number of retry attempts for failed requests + */ + 'retry_times' => env('TMDB_RETRY_TIMES', 3), + + /* + * Delay between retry attempts in milliseconds + */ + 'retry_delay' => env('TMDB_RETRY_DELAY', 100), ]; diff --git a/misc/testing/Tests/test_tmdb_API.php b/misc/testing/Tests/test_tmdb_API.php index 6c25c137e..06039376a 100755 --- a/misc/testing/Tests/test_tmdb_API.php +++ b/misc/testing/Tests/test_tmdb_API.php @@ -2,8 +2,8 @@ require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php'; +use App\Services\TmdbClient; use Blacklight\ColorCLI; -use Tmdb\Laravel\Facades\Tmdb; $colorCli = new ColorCLI; @@ -11,39 +11,58 @@ if (! empty($argv[1]) && is_numeric($argv[2]) && is_numeric($argv[3])) { // Test if your TMDB API configuration is working // If it works you should get a var dumped array of the show/season/episode entered + $tmdbClient = app(TmdbClient::class); + + if (! $tmdbClient->isConfigured()) { + exit($colorCli->error('TMDB API key is not configured. Please set your API key in the configuration.')); + } + $season = (int) $argv[2]; $episode = (int) $argv[3]; // Search for a show - $series = Tmdb::getSearchApi()->searchTv((string) $argv[1]); - print_r($series); + $series = $tmdbClient->searchTv((string) $argv[1]); // Use the first show found (highest match) and get the requested season/episode from $argv - if (! empty($series) && $series['total_results'] > 0) { - $seriesAppends = [ - 'networks' => Tmdb::getTvApi()->getTvshow($series['results'][0]['id'])['networks'], - 'alternative_titles' => Tmdb::getTvApi()->getAlternativeTitles($series['results'][0]['id']), - 'external_ids' => Tmdb::getTvApi()->getExternalIds($series['results'][0]['id']), - ]; - print_r($seriesAppends); - if ($seriesAppends) { - $series['results'][0]['networks'] = $seriesAppends['networks']; - $series['results'][0]['alternative_titles'] = $seriesAppends['alternative_titles']; - $series['results'][0]['external_ids'] = $seriesAppends['external_ids']; + $totalResults = $series !== null ? TmdbClient::getInt($series, 'total_results') : 0; + $results = $series !== null ? TmdbClient::getArray($series, 'results') : []; + + if (! empty($results) && $totalResults > 0) { + $showId = TmdbClient::getInt($results[0], 'id'); + + if ($showId === 0) { + exit($colorCli->error('Invalid show ID returned from TMDB API.')); } - print_r($series['results'][0]); + + // Get TV show details with networks + $tvShowDetails = $tmdbClient->getTvShow($showId); + $networks = $tvShowDetails !== null ? TmdbClient::getArray($tvShowDetails, 'networks') : []; + + // Get alternative titles + $alternativeTitlesResponse = $tmdbClient->getTvAlternativeTitles($showId); + $alternativeTitles = $alternativeTitlesResponse !== null ? TmdbClient::getArray($alternativeTitlesResponse, 'results') : []; + + // Get external IDs + $externalIds = $tmdbClient->getTvExternalIds($showId); + + // Merge additional data into results + $results[0]['networks'] = $networks; + $results[0]['alternative_titles'] = $alternativeTitles; + $results[0]['external_ids'] = $externalIds ?? []; if ($season > 0 && $episode > 0) { - $episodeObj = Tmdb::getTvEpisodeApi()->getEpisode($series['results'][0]['id'], $season, $episode); - if ($episodeObj) { + $episodeObj = $tmdbClient->getTvEpisode($showId, $season, $episode); + if ($episodeObj !== null) { + echo "Show: ".TmdbClient::getString($results[0], 'name')."\n"; + echo "Season: $season, Episode: $episode\n"; print_r($episodeObj); + } else { + exit($colorCli->error('Episode not found on TMDB API.')); } } elseif ($season === 0 && $episode === 0) { - $episodeObj = Tmdb::getTvApi()->getTvshow($series['results'][0]['id']); - if (is_array($episodeObj)) { - foreach ($episodeObj as $ep) { - print_r($ep); - } + $tvShowFull = $tmdbClient->getTvShow($showId); + if ($tvShowFull !== null) { + print_r($tvShowFull); } } else { exit($colorCli->error('Invalid episode data returned from TMDB API.')); diff --git a/tests/Unit/TmdbClientTest.php b/tests/Unit/TmdbClientTest.php new file mode 100644 index 000000000..90ecb0d6d --- /dev/null +++ b/tests/Unit/TmdbClientTest.php @@ -0,0 +1,544 @@ + 'test_api_key']); + config(['tmdb.timeout' => 30]); + config(['tmdb.retry_times' => 3]); + config(['tmdb.retry_delay' => 100]); + config(['cache.default' => 'array']); + $this->client = new TmdbClient; + } + + // ========================================================================= + // CONFIGURATION TESTS + // ========================================================================= + + public function test_is_configured_returns_true_when_api_key_set(): void + { + $this->assertTrue($this->client->isConfigured()); + } + + public function test_is_configured_returns_false_when_api_key_empty(): void + { + config(['tmdb.api_key' => '']); + $client = new TmdbClient; + $this->assertFalse($client->isConfigured()); + } + + public function test_is_configured_returns_false_when_api_key_null(): void + { + // When config returns null, the (string) cast converts it to empty string + config(['tmdb.api_key' => null]); + $client = new TmdbClient; + // (string) null === '', so isConfigured returns false + $this->assertFalse($client->isConfigured()); + } + + // ========================================================================= + // IMAGE URL TESTS + // ========================================================================= + + public function test_get_image_url_returns_full_url(): void + { + $url = $this->client->getImageUrl('/abc123.jpg', 'w500'); + $this->assertSame('https://image.tmdb.org/t/p/w500/abc123.jpg', $url); + } + + public function test_get_image_url_returns_empty_for_null_path(): void + { + $url = $this->client->getImageUrl(null); + $this->assertSame('', $url); + } + + public function test_get_image_url_returns_empty_for_empty_path(): void + { + $url = $this->client->getImageUrl(''); + $this->assertSame('', $url); + } + + public function test_get_image_url_uses_default_size(): void + { + $url = $this->client->getImageUrl('/test.jpg'); + $this->assertSame('https://image.tmdb.org/t/p/w500/test.jpg', $url); + } + + public function test_get_image_url_with_original_size(): void + { + $url = $this->client->getImageUrl('/test.jpg', 'original'); + $this->assertSame('https://image.tmdb.org/t/p/original/test.jpg', $url); + } + + // ========================================================================= + // MOVIE SEARCH TESTS + // ========================================================================= + + public function test_search_movies_makes_correct_api_call(): void + { + Http::fake([ + 'api.themoviedb.org/3/search/movie*' => Http::response([ + 'page' => 1, + 'results' => [ + ['id' => 123, 'title' => 'Test Movie', 'release_date' => '2024-01-15'], + ], + 'total_results' => 1, + 'total_pages' => 1, + ]), + ]); + + $result = $this->client->searchMovies('Test Movie'); + + $this->assertNotNull($result); + $this->assertSame(1, $result['total_results']); + $this->assertCount(1, $result['results']); + $this->assertSame('Test Movie', $result['results'][0]['title']); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'api.themoviedb.org/3/search/movie') + && $request['query'] === 'Test Movie' + && $request['api_key'] === 'test_api_key'; + }); + } + + public function test_search_movies_with_year_filter(): void + { + Http::fake([ + 'api.themoviedb.org/3/search/movie*' => Http::response([ + 'page' => 1, + 'results' => [], + 'total_results' => 0, + ]), + ]); + + $this->client->searchMovies('Test', 1, '2024'); + + Http::assertSent(function ($request) { + return $request['year'] === '2024'; + }); + } + + public function test_search_movies_returns_null_on_api_failure(): void + { + Http::fake([ + 'api.themoviedb.org/3/search/movie*' => Http::response([], 500), + ]); + + $result = $this->client->searchMovies('Test'); + $this->assertNull($result); + } + + public function test_search_movies_returns_null_when_not_configured(): void + { + config(['tmdb.api_key' => '']); + $client = new TmdbClient; + + $result = $client->searchMovies('Test'); + $this->assertNull($result); + } + + // ========================================================================= + // GET MOVIE TESTS + // ========================================================================= + + public function test_get_movie_returns_movie_data(): void + { + Http::fake([ + 'api.themoviedb.org/3/movie/550*' => Http::response([ + 'id' => 550, + 'title' => 'Fight Club', + 'overview' => 'A ticking-Loss time bomb of a movie...', + 'release_date' => '1999-10-15', + 'vote_average' => 8.4, + 'poster_path' => '/poster.jpg', + 'backdrop_path' => '/backdrop.jpg', + 'imdb_id' => 'tt0137523', + ]), + ]); + + $result = $this->client->getMovie(550); + + $this->assertNotNull($result); + $this->assertSame(550, $result['id']); + $this->assertSame('Fight Club', $result['title']); + $this->assertSame('tt0137523', $result['imdb_id']); + } + + public function test_get_movie_with_append_to_response(): void + { + Http::fake([ + 'api.themoviedb.org/3/movie/550*' => Http::response([ + 'id' => 550, + 'title' => 'Fight Club', + 'credits' => [ + 'cast' => [['name' => 'Brad Pitt']], + 'crew' => [['name' => 'David Fincher', 'job' => 'Director']], + ], + ]), + ]); + + $result = $this->client->getMovie(550, ['credits']); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'append_to_response=credits'); + }); + + $this->assertNotNull($result); + $this->assertArrayHasKey('credits', $result); + } + + public function test_get_movie_with_imdb_id(): void + { + Http::fake([ + 'api.themoviedb.org/3/movie/tt0137523*' => Http::response([ + 'id' => 550, + 'title' => 'Fight Club', + ]), + ]); + + $result = $this->client->getMovie('tt0137523'); + $this->assertNotNull($result); + } + + public function test_get_movie_returns_null_on_404(): void + { + Http::fake([ + 'api.themoviedb.org/3/movie/*' => Http::response([], 404), + ]); + + $result = $this->client->getMovie(999999999); + $this->assertNull($result); + } + + // ========================================================================= + // TV SEARCH TESTS + // ========================================================================= + + public function test_search_tv_returns_results(): void + { + Http::fake([ + 'api.themoviedb.org/3/search/tv*' => Http::response([ + 'page' => 1, + 'results' => [ + ['id' => 1396, 'name' => 'Breaking Bad', 'first_air_date' => '2008-01-20'], + ], + 'total_results' => 1, + ]), + ]); + + $result = $this->client->searchTv('Breaking Bad'); + + $this->assertNotNull($result); + $this->assertCount(1, $result['results']); + $this->assertSame('Breaking Bad', $result['results'][0]['name']); + } + + public function test_search_tv_with_first_air_date_year(): void + { + Http::fake([ + 'api.themoviedb.org/3/search/tv*' => Http::response([ + 'results' => [], + 'total_results' => 0, + ]), + ]); + + $this->client->searchTv('Test Show', 1, 2020); + + Http::assertSent(function ($request) { + return $request['first_air_date_year'] === 2020; + }); + } + + // ========================================================================= + // TV SHOW DETAILS TESTS + // ========================================================================= + + public function test_get_tv_show_returns_show_data(): void + { + Http::fake([ + 'api.themoviedb.org/3/tv/1396*' => Http::response([ + 'id' => 1396, + 'name' => 'Breaking Bad', + 'overview' => 'A high school chemistry teacher...', + 'first_air_date' => '2008-01-20', + 'seasons' => [ + ['season_number' => 1, 'episode_count' => 7], + ], + ]), + ]); + + $result = $this->client->getTvShow(1396); + + $this->assertNotNull($result); + $this->assertSame(1396, $result['id']); + $this->assertSame('Breaking Bad', $result['name']); + } + + public function test_get_tv_external_ids(): void + { + Http::fake([ + 'api.themoviedb.org/3/tv/1396/external_ids*' => Http::response([ + 'imdb_id' => 'tt0903747', + 'tvdb_id' => 81189, + 'tvrage_id' => 18164, + ]), + ]); + + $result = $this->client->getTvExternalIds(1396); + + $this->assertNotNull($result); + $this->assertSame('tt0903747', $result['imdb_id']); + $this->assertSame(81189, $result['tvdb_id']); + } + + public function test_get_tv_alternative_titles(): void + { + Http::fake([ + 'api.themoviedb.org/3/tv/1396/alternative_titles*' => Http::response([ + 'id' => 1396, + 'results' => [ + ['title' => 'Breaking Bad - Reazioni collaterali', 'iso_3166_1' => 'IT'], + ], + ]), + ]); + + $result = $this->client->getTvAlternativeTitles(1396); + + $this->assertNotNull($result); + $this->assertCount(1, $result['results']); + } + + // ========================================================================= + // TV SEASON AND EPISODE TESTS + // ========================================================================= + + public function test_get_tv_season_returns_season_data(): void + { + Http::fake([ + 'api.themoviedb.org/3/tv/1396/season/1*' => Http::response([ + 'id' => 3572, + 'season_number' => 1, + 'episodes' => [ + [ + 'id' => 62085, + 'name' => 'Pilot', + 'episode_number' => 1, + 'season_number' => 1, + 'air_date' => '2008-01-20', + 'overview' => 'When an unassuming high school chemistry teacher...', + ], + ], + ]), + ]); + + $result = $this->client->getTvSeason(1396, 1); + + $this->assertNotNull($result); + $this->assertSame(1, $result['season_number']); + $this->assertCount(1, $result['episodes']); + } + + public function test_get_tv_episode_returns_episode_data(): void + { + Http::fake([ + 'api.themoviedb.org/3/tv/1396/season/1/episode/1*' => Http::response([ + 'id' => 62085, + 'name' => 'Pilot', + 'episode_number' => 1, + 'season_number' => 1, + 'air_date' => '2008-01-20', + 'overview' => 'When an unassuming high school chemistry teacher...', + ]), + ]); + + $result = $this->client->getTvEpisode(1396, 1, 1); + + $this->assertNotNull($result); + $this->assertSame('Pilot', $result['name']); + $this->assertSame(1, $result['episode_number']); + $this->assertSame(1, $result['season_number']); + } + + // ========================================================================= + // HELPER METHOD TESTS + // ========================================================================= + + public function test_get_string_returns_string_value(): void + { + $data = ['title' => 'Test Movie', 'count' => 5]; + $this->assertSame('Test Movie', TmdbClient::getString($data, 'title')); + } + + public function test_get_string_returns_default_for_missing_key(): void + { + $data = ['title' => 'Test']; + $this->assertSame('', TmdbClient::getString($data, 'missing')); + $this->assertSame('default', TmdbClient::getString($data, 'missing', 'default')); + } + + public function test_get_string_returns_default_for_non_string_value(): void + { + $data = ['count' => 123, 'list' => ['a', 'b']]; + $this->assertSame('', TmdbClient::getString($data, 'count')); + $this->assertSame('', TmdbClient::getString($data, 'list')); + } + + public function test_get_int_returns_integer_value(): void + { + $data = ['id' => 550, 'score' => '85', 'rating' => 7.5]; + $this->assertSame(550, TmdbClient::getInt($data, 'id')); + $this->assertSame(85, TmdbClient::getInt($data, 'score')); + $this->assertSame(7, TmdbClient::getInt($data, 'rating')); + } + + public function test_get_int_returns_default_for_missing_key(): void + { + $data = ['id' => 550]; + $this->assertSame(0, TmdbClient::getInt($data, 'missing')); + $this->assertSame(99, TmdbClient::getInt($data, 'missing', 99)); + } + + public function test_get_int_returns_default_for_non_numeric_value(): void + { + $data = ['title' => 'Test', 'list' => [1, 2]]; + $this->assertSame(0, TmdbClient::getInt($data, 'title')); + $this->assertSame(0, TmdbClient::getInt($data, 'list')); + } + + public function test_get_float_returns_float_value(): void + { + $data = ['rating' => 7.5, 'score' => '8.3', 'count' => 100]; + $this->assertSame(7.5, TmdbClient::getFloat($data, 'rating')); + $this->assertSame(8.3, TmdbClient::getFloat($data, 'score')); + $this->assertSame(100.0, TmdbClient::getFloat($data, 'count')); + } + + public function test_get_float_returns_default_for_missing_key(): void + { + $data = ['rating' => 7.5]; + $this->assertSame(0.0, TmdbClient::getFloat($data, 'missing')); + $this->assertSame(5.5, TmdbClient::getFloat($data, 'missing', 5.5)); + } + + public function test_get_array_returns_array_value(): void + { + $data = ['genres' => [['name' => 'Action'], ['name' => 'Drama']]]; + $result = TmdbClient::getArray($data, 'genres'); + $this->assertIsArray($result); + $this->assertCount(2, $result); + } + + public function test_get_array_returns_default_for_missing_key(): void + { + $data = ['title' => 'Test']; + $this->assertSame([], TmdbClient::getArray($data, 'genres')); + $this->assertSame(['default'], TmdbClient::getArray($data, 'genres', ['default'])); + } + + public function test_get_array_returns_default_for_non_array_value(): void + { + $data = ['title' => 'Test', 'count' => 5]; + $this->assertSame([], TmdbClient::getArray($data, 'title')); + $this->assertSame([], TmdbClient::getArray($data, 'count')); + } + + public function test_get_nested_returns_nested_value(): void + { + $data = [ + 'credits' => [ + 'cast' => [ + ['name' => 'Actor 1'], + ['name' => 'Actor 2'], + ], + ], + ]; + + $cast = TmdbClient::getNested($data, 'credits.cast'); + $this->assertIsArray($cast); + $this->assertCount(2, $cast); + } + + public function test_get_nested_returns_default_for_missing_path(): void + { + $data = ['title' => 'Test']; + $this->assertNull(TmdbClient::getNested($data, 'credits.cast')); + $this->assertSame([], TmdbClient::getNested($data, 'credits.cast', [])); + } + + public function test_get_nested_handles_deep_paths(): void + { + $data = [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'value' => 'deep', + ], + ], + ], + ]; + + $this->assertSame('deep', TmdbClient::getNested($data, 'level1.level2.level3.value')); + } + + public function test_get_nested_returns_default_when_path_breaks(): void + { + $data = [ + 'level1' => [ + 'level2' => 'not an array', + ], + ]; + + $this->assertSame('default', TmdbClient::getNested($data, 'level1.level2.level3', 'default')); + } + + // ========================================================================= + // ERROR HANDLING TESTS + // ========================================================================= + + public function test_handles_connection_timeout(): void + { + Http::fake([ + 'api.themoviedb.org/*' => function () { + throw new \Illuminate\Http\Client\ConnectionException('Connection timed out'); + }, + ]); + + $result = $this->client->searchMovies('Test'); + $this->assertNull($result); + } + + public function test_handles_invalid_json_response(): void + { + Http::fake([ + 'api.themoviedb.org/*' => Http::response('invalid json', 200), + ]); + + $result = $this->client->searchMovies('Test'); + // Laravel's Http client returns null for invalid JSON + $this->assertNull($result); + } + + public function test_handles_rate_limiting_response(): void + { + Http::fake([ + 'api.themoviedb.org/*' => Http::response([ + 'status_code' => 25, + 'status_message' => 'Your request count is over the allowed limit.', + ], 429), + ]); + + $result = $this->client->searchMovies('Test'); + $this->assertNull($result); + } +} +