Files
newznab-tmux/app/Services/TmdbClient.php
T
2026-05-30 23:31:00 +02:00

554 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Custom TMDB (The Movie Database) API Client
*
* This service provides methods to interact with The Movie Database API
* for fetching movie and TV show information.
*/
class TmdbClient
{
protected const BASE_URL = 'https://api.themoviedb.org/3';
protected const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
protected string $apiKey;
protected int $timeout;
protected int $retryTimes;
protected int $retryDelay;
public function __construct()
{
$this->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<string, mixed> $params Additional query parameters
* @return array<string, mixed>|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, function (\Throwable $exception, PendingRequest $request, ?string $key = null) {
// Don't retry on 404 errors - resource simply doesn't exist
if ($exception instanceof RequestException) {
return $exception->response->status() !== 404;
}
return true;
}, throw: false)
->get(self::BASE_URL.$endpoint, $params);
if ($response->successful()) {
return $response->json();
}
// Handle specific error codes - 404 is normal (resource not found)
if ($response->status() === 404) {
Log::debug('TMDB: Resource not found', ['endpoint' => $endpoint]);
return null;
}
Log::warning('TMDB 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('TMDB: Resource not found', ['endpoint' => $endpoint]);
return null;
}
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<string, mixed>|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<string, mixed> $appendToResponse Additional data to append (e.g., ['credits', 'external_ids'])
* @return array<string, mixed>|null Movie data or null on failure
*/
public function getMovie(int|string $id, array $appendToResponse = []): ?array
{
if (is_string($id) && str_starts_with($id, 'tt')) {
$movie = $this->findMovieByExternalId($id, 'imdb_id');
if ($movie === null) {
return null;
}
$tmdbId = self::getInt($movie, 'id');
if ($tmdbId <= 0) {
return null;
}
return $this->getMovie($tmdbId, $appendToResponse);
}
$params = [];
if (! empty($appendToResponse)) {
$params['append_to_response'] = implode(',', $appendToResponse);
}
return $this->get('/movie/'.$id, $params);
}
/**
* Find a movie by external ID (IMDb, etc.)
*
* @param string $externalId The external ID value
* @param string $source The source: 'imdb_id'
* @return array<string, mixed>|null The TMDB movie data or null if not found
*/
public function findMovieByExternalId(string $externalId, string $source = 'imdb_id'): ?array
{
if (empty($externalId)) {
return null;
}
if ($source !== 'imdb_id') {
return null;
}
$result = $this->get('/find/'.$externalId, [
'external_source' => $source,
]);
if ($result === null || empty($result['movie_results'])) {
return null;
}
return $result['movie_results'][0] ?? null;
}
/**
* Get movie credits (cast and crew)
*
* @param int $movieId The TMDB movie ID
* @return array<string, mixed>|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<string, mixed>|null External IDs or null on failure
*/
public function getMovieExternalIds(int $movieId): ?array
{
return $this->get('/movie/'.$movieId.'/external_ids');
}
/**
* Get alternative titles for a movie (including international / alternate names).
*
* @param int $movieId The TMDB movie ID
* @return array<string, mixed>|null Response with 'titles' array (each has 'title', 'iso_3166_1', 'type') or null on failure
*/
public function getMovieAlternativeTitles(int $movieId): ?array
{
return $this->get('/movie/'.$movieId.'/alternative_titles');
}
// =========================================================================
// 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<string, mixed>|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 list<string> $appendToResponse Additional data to append
* @return array<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed>|null Season data or null on failure
*/
public function getTvSeason(int $tvId, int $seasonNumber): ?array
{
// Validate parameters to avoid unnecessary API calls with invalid IDs
if ($tvId <= 0 || $seasonNumber < 0) {
return null;
}
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<string, mixed>|null Episode data or null on failure
*/
public function getTvEpisode(int $tvId, int $seasonNumber, int $episodeNumber): ?array
{
// Validate parameters to avoid unnecessary API calls with invalid IDs
if ($tvId <= 0 || $seasonNumber < 0 || $episodeNumber < 0) {
return null;
}
return $this->get('/tv/'.$tvId.'/season/'.$seasonNumber.'/episode/'.$episodeNumber);
}
/**
* Find a TV show by external ID (IMDB, TVDB, etc.)
*
* @param string $externalId The external ID value
* @param string $source The source: 'imdb_id', 'tvdb_id', 'tvrage_id'
* @return array<string, mixed>|null The TMDB TV show data or null if not found
*/
public function findTvByExternalId(string $externalId, string $source = 'tvdb_id'): ?array
{
if (empty($externalId)) {
return null;
}
$validSources = ['imdb_id', 'tvdb_id', 'tvrage_id'];
if (! in_array($source, $validSources, true)) {
return null;
}
$result = $this->get('/find/'.$externalId, [
'external_source' => $source,
]);
if ($result === null || empty($result['tv_results'])) {
return null;
}
// Return the first TV result
return $result['tv_results'][0] ?? null;
}
/**
* Get TV episode with fallback using multiple external IDs.
* Tries TMDB ID first, then looks up by TVDB or IMDB if needed.
*
* @param array<string, mixed> $ids Array of IDs: ['tmdb' => X, 'tvdb' => Y, 'imdb' => Z]
* @param int $seasonNumber The season number
* @param int $episodeNumber The episode number
* @return array<string, mixed>|null Episode data or null on failure
*/
public function getTvEpisodeWithFallback(array $ids, int $seasonNumber, int $episodeNumber): ?array
{
// First try with TMDB ID if available
$tmdbId = (int) ($ids['tmdb'] ?? 0);
if ($tmdbId > 0) {
$result = $this->getTvEpisode($tmdbId, $seasonNumber, $episodeNumber);
if ($result !== null) {
return $result;
}
}
// Try to find the show by TVDB ID
$tvdbId = (int) ($ids['tvdb'] ?? 0);
if ($tvdbId > 0) {
$show = $this->findTvByExternalId((string) $tvdbId, 'tvdb_id');
if ($show !== null && isset($show['id'])) {
$result = $this->getTvEpisode((int) $show['id'], $seasonNumber, $episodeNumber);
if ($result !== null) {
return $result;
}
}
}
// Try to find the show by IMDB ID
$imdbId = $ids['imdb'] ?? '';
if (! empty($imdbId)) {
$imdbFormatted = str_starts_with((string) $imdbId, 'tt')
? (string) $imdbId
: 'tt'.(string) $imdbId;
$show = $this->findTvByExternalId($imdbFormatted, 'imdb_id');
if ($show !== null && isset($show['id'])) {
$result = $this->getTvEpisode((int) $show['id'], $seasonNumber, $episodeNumber);
if ($result !== null) {
return $result;
}
}
}
return null;
}
/**
* Look up a TV show and get all its external IDs.
* Can be used to cross-reference between providers.
*
* @param int|string $id The external ID
* @param string $source The ID source: 'tmdb', 'tvdb', 'imdb'
* @return array<string, mixed>|null Array with all IDs: ['tmdb' => X, 'imdb' => Y, 'tvdb' => Z] or null
*/
public function lookupTvShowIds(int|string $id, string $source = 'tmdb'): ?array
{
$show = null;
if ($source === 'tmdb' && is_numeric($id) && (int) $id > 0) {
// Direct TMDB lookup
$show = $this->getTvShow((int) $id);
if ($show !== null) {
$externalIds = $this->getTvExternalIds((int) $id);
if ($externalIds !== null) {
$show['external_ids'] = $externalIds;
}
}
} elseif ($source === 'tvdb' && is_numeric($id) && (int) $id > 0) {
$show = $this->findTvByExternalId((string) $id, 'tvdb_id');
} elseif ($source === 'imdb') {
$imdbFormatted = str_starts_with((string) $id, 'tt')
? (string) $id
: 'tt'.(string) $id;
$show = $this->findTvByExternalId($imdbFormatted, 'imdb_id');
}
if ($show === null) {
return null;
}
// If we found by external ID, we need to fetch external IDs for full data
$tmdbId = self::getInt($show, 'id');
if ($tmdbId > 0 && ! isset($show['external_ids'])) {
$externalIds = $this->getTvExternalIds($tmdbId);
$show['external_ids'] = $externalIds ?? [];
}
$externalIds = self::getArray($show, 'external_ids');
// Parse IMDB ID to raw numeric string
$imdbId = '';
if (! empty($externalIds['imdb_id'])) {
preg_match('/tt(?P<imdbid>\d{6,})$/i', (string) $externalIds['imdb_id'], $imdb);
$imdbId = (string) ($imdb['imdbid'] ?? '');
}
return [
'tmdb' => $tmdbId,
'imdb' => $imdbId,
'tvdb' => self::getInt($externalIds, 'tvdb_id'),
'tvrage' => self::getInt($externalIds, 'tvrage_id'),
];
}
// =========================================================================
// HELPER METHODS FOR NULL-SAFE DATA EXTRACTION
// =========================================================================
/**
* Safely get a string value from an array
*
* @param array<string, mixed> $data
*/
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
*
* @param array<string, mixed> $data
*/
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
*
* @param array<string, mixed> $data
*/
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
*
* @param array<string, mixed> $data
* @param array<string, mixed> $default
* @return array<string, mixed>
*/
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
*
* @param array<string, mixed> $data
*/
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;
}
}