Passed
Push — master ( 14af2f...574fad )
by Darko
10:47
created

TmdbClient::getImageUrl()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 7
rs 10
cc 2
nc 2
nop 2
1
<?php
2
3
namespace App\Services;
4
5
use Illuminate\Support\Facades\Cache;
6
use Illuminate\Support\Facades\Http;
7
use Illuminate\Support\Facades\Log;
8
9
/**
10
 * Custom TMDB (The Movie Database) API Client
11
 *
12
 * This service provides methods to interact with The Movie Database API
13
 * for fetching movie and TV show information.
14
 */
15
class TmdbClient
16
{
17
    protected const BASE_URL = 'https://api.themoviedb.org/3';
18
19
    protected const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
20
21
    protected string $apiKey;
22
23
    protected int $timeout;
24
25
    protected int $retryTimes;
26
27
    protected int $retryDelay;
28
29
    public function __construct()
30
    {
31
        $this->apiKey = (string) config('tmdb.api_key', '');
32
        $this->timeout = (int) config('tmdb.timeout', 30);
33
        $this->retryTimes = (int) config('tmdb.retry_times', 3);
34
        $this->retryDelay = (int) config('tmdb.retry_delay', 100);
35
    }
36
37
    /**
38
     * Check if the API key is configured
39
     */
40
    public function isConfigured(): bool
41
    {
42
        return ! empty($this->apiKey);
43
    }
44
45
    /**
46
     * Make a GET request to the TMDB API
47
     *
48
     * @param  string  $endpoint  The API endpoint
49
     * @param  array  $params  Additional query parameters
50
     * @return array|null Response data or null on failure
51
     */
52
    protected function get(string $endpoint, array $params = []): ?array
53
    {
54
        if (! $this->isConfigured()) {
55
            Log::warning('TMDB API key is not configured');
56
57
            return null;
58
        }
59
60
        $params['api_key'] = $this->apiKey;
61
62
        try {
63
            $response = Http::timeout($this->timeout)
64
                ->retry($this->retryTimes, $this->retryDelay, function (\Exception $exception, $request) {
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

64
                ->retry($this->retryTimes, $this->retryDelay, function (\Exception $exception, /** @scrutinizer ignore-unused */ $request) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
65
                    // Don't retry on 404 errors - resource simply doesn't exist
66
                    if ($exception instanceof \Illuminate\Http\Client\RequestException) {
67
                        return $exception->response->status() !== 404;
68
                    }
69
70
                    return true;
71
                }, throw: false)
72
                ->get(self::BASE_URL.$endpoint, $params);
73
74
            if ($response->successful()) {
0 ignored issues
show
Bug introduced by
The method successful() does not exist on Illuminate\Http\Client\Promises\LazyPromise. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

74
            if ($response->/** @scrutinizer ignore-call */ successful()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
75
                return $response->json();
0 ignored issues
show
Bug introduced by
The method json() does not exist on Illuminate\Http\Client\Promises\LazyPromise. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

75
                return $response->/** @scrutinizer ignore-call */ json();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
76
            }
77
78
            // Handle specific error codes - 404 is normal (resource not found)
79
            if ($response->status() === 404) {
0 ignored issues
show
Bug introduced by
The method status() does not exist on Illuminate\Http\Client\Promises\LazyPromise. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

79
            if ($response->/** @scrutinizer ignore-call */ status() === 404) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
80
                Log::debug('TMDB: Resource not found', ['endpoint' => $endpoint]);
81
82
                return null;
83
            }
84
85
            Log::warning('TMDB API request failed', [
86
                'endpoint' => $endpoint,
87
                'status' => $response->status(),
88
                'body' => $response->body(),
0 ignored issues
show
Bug introduced by
The method body() does not exist on Illuminate\Http\Client\Promises\LazyPromise. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

88
                'body' => $response->/** @scrutinizer ignore-call */ body(),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
89
            ]);
90
91
            return null;
92
        } catch (\Throwable $e) {
93
            // Check if this is a 404 wrapped in an exception
94
            if ($e instanceof \Illuminate\Http\Client\RequestException && $e->response->status() === 404) {
95
                Log::debug('TMDB: Resource not found', ['endpoint' => $endpoint]);
96
97
                return null;
98
            }
99
100
            Log::warning('TMDB API request exception', [
101
                'endpoint' => $endpoint,
102
                'message' => $e->getMessage(),
103
            ]);
104
105
            return null;
106
        }
107
    }
108
109
    /**
110
     * Get the full image URL
111
     *
112
     * @param  string|null  $path  The image path from TMDB
113
     * @param  string  $size  The image size (w92, w154, w185, w342, w500, w780, original)
114
     */
115
    public function getImageUrl(?string $path, string $size = 'w500'): string
116
    {
117
        if (empty($path)) {
118
            return '';
119
        }
120
121
        return self::IMAGE_BASE_URL.'/'.$size.$path;
122
    }
123
124
    // =========================================================================
125
    // MOVIE METHODS
126
    // =========================================================================
127
128
    /**
129
     * Search for movies by title
130
     *
131
     * @param  string  $query  The search query
132
     * @param  int  $page  Page number for pagination
133
     * @param  string|null  $year  Filter by release year
134
     * @return array|null Search results or null on failure
135
     */
136
    public function searchMovies(string $query, int $page = 1, ?string $year = null): ?array
137
    {
138
        $params = [
139
            'query' => $query,
140
            'page' => $page,
141
            'include_adult' => false,
142
        ];
143
144
        if ($year !== null) {
145
            $params['year'] = $year;
146
        }
147
148
        return $this->get('/search/movie', $params);
149
    }
150
151
    /**
152
     * Get movie details by TMDB ID or IMDB ID
153
     *
154
     * @param  int|string  $id  The TMDB ID or IMDB ID (with 'tt' prefix)
155
     * @param  array  $appendToResponse  Additional data to append (e.g., ['credits', 'external_ids'])
156
     * @return array|null Movie data or null on failure
157
     */
158
    public function getMovie(int|string $id, array $appendToResponse = []): ?array
159
    {
160
        $params = [];
161
162
        if (! empty($appendToResponse)) {
163
            $params['append_to_response'] = implode(',', $appendToResponse);
164
        }
165
166
        return $this->get('/movie/'.$id, $params);
167
    }
168
169
    /**
170
     * Get movie credits (cast and crew)
171
     *
172
     * @param  int  $movieId  The TMDB movie ID
173
     * @return array|null Credits data or null on failure
174
     */
175
    public function getMovieCredits(int $movieId): ?array
176
    {
177
        return $this->get('/movie/'.$movieId.'/credits');
178
    }
179
180
    /**
181
     * Get movie external IDs (IMDB, etc.)
182
     *
183
     * @param  int  $movieId  The TMDB movie ID
184
     * @return array|null External IDs or null on failure
185
     */
186
    public function getMovieExternalIds(int $movieId): ?array
187
    {
188
        return $this->get('/movie/'.$movieId.'/external_ids');
189
    }
190
191
    // =========================================================================
192
    // TV SHOW METHODS
193
    // =========================================================================
194
195
    /**
196
     * Search for TV shows by title
197
     *
198
     * @param  string  $query  The search query
199
     * @param  int  $page  Page number for pagination
200
     * @param  int|null  $firstAirDateYear  Filter by first air date year
201
     * @return array|null Search results or null on failure
202
     */
203
    public function searchTv(string $query, int $page = 1, ?int $firstAirDateYear = null): ?array
204
    {
205
        $params = [
206
            'query' => $query,
207
            'page' => $page,
208
            'include_adult' => false,
209
        ];
210
211
        if ($firstAirDateYear !== null) {
212
            $params['first_air_date_year'] = $firstAirDateYear;
213
        }
214
215
        return $this->get('/search/tv', $params);
216
    }
217
218
    /**
219
     * Get TV show details by ID
220
     *
221
     * @param  int|string  $id  The TMDB TV show ID
222
     * @param  array  $appendToResponse  Additional data to append
223
     * @return array|null TV show data or null on failure
224
     */
225
    public function getTvShow(int|string $id, array $appendToResponse = []): ?array
226
    {
227
        $params = [];
228
229
        if (! empty($appendToResponse)) {
230
            $params['append_to_response'] = implode(',', $appendToResponse);
231
        }
232
233
        return $this->get('/tv/'.$id, $params);
234
    }
235
236
    /**
237
     * Get TV show external IDs (IMDB, TVDB, etc.)
238
     *
239
     * @param  int  $tvId  The TMDB TV show ID
240
     * @return array|null External IDs or null on failure
241
     */
242
    public function getTvExternalIds(int $tvId): ?array
243
    {
244
        return $this->get('/tv/'.$tvId.'/external_ids');
245
    }
246
247
    /**
248
     * Get TV show alternative titles
249
     *
250
     * @param  int  $tvId  The TMDB TV show ID
251
     * @return array|null Alternative titles or null on failure
252
     */
253
    public function getTvAlternativeTitles(int $tvId): ?array
254
    {
255
        return $this->get('/tv/'.$tvId.'/alternative_titles');
256
    }
257
258
    /**
259
     * Get TV season details
260
     *
261
     * @param  int  $tvId  The TMDB TV show ID
262
     * @param  int  $seasonNumber  The season number
263
     * @return array|null Season data or null on failure
264
     */
265
    public function getTvSeason(int $tvId, int $seasonNumber): ?array
266
    {
267
        // Validate parameters to avoid unnecessary API calls with invalid IDs
268
        if ($tvId <= 0 || $seasonNumber < 0) {
269
            return null;
270
        }
271
272
        return $this->get('/tv/'.$tvId.'/season/'.$seasonNumber);
273
    }
274
275
    /**
276
     * Get TV episode details
277
     *
278
     * @param  int  $tvId  The TMDB TV show ID
279
     * @param  int  $seasonNumber  The season number
280
     * @param  int  $episodeNumber  The episode number
281
     * @return array|null Episode data or null on failure
282
     */
283
    public function getTvEpisode(int $tvId, int $seasonNumber, int $episodeNumber): ?array
284
    {
285
        // Validate parameters to avoid unnecessary API calls with invalid IDs
286
        if ($tvId <= 0 || $seasonNumber < 0 || $episodeNumber < 0) {
287
            return null;
288
        }
289
290
        return $this->get('/tv/'.$tvId.'/season/'.$seasonNumber.'/episode/'.$episodeNumber);
291
    }
292
293
    /**
294
     * Find a TV show by external ID (IMDB, TVDB, etc.)
295
     *
296
     * @param  string  $externalId  The external ID value
297
     * @param  string  $source  The source: 'imdb_id', 'tvdb_id', 'tvrage_id'
298
     * @return array|null The TMDB TV show data or null if not found
299
     */
300
    public function findTvByExternalId(string $externalId, string $source = 'tvdb_id'): ?array
301
    {
302
        if (empty($externalId)) {
303
            return null;
304
        }
305
306
        $validSources = ['imdb_id', 'tvdb_id', 'tvrage_id'];
307
        if (! in_array($source, $validSources, true)) {
308
            return null;
309
        }
310
311
        $result = $this->get('/find/'.$externalId, [
312
            'external_source' => $source,
313
        ]);
314
315
        if ($result === null || empty($result['tv_results'])) {
316
            return null;
317
        }
318
319
        // Return the first TV result
320
        return $result['tv_results'][0] ?? null;
321
    }
322
323
    /**
324
     * Get TV episode with fallback using multiple external IDs.
325
     * Tries TMDB ID first, then looks up by TVDB or IMDB if needed.
326
     *
327
     * @param  array  $ids  Array of IDs: ['tmdb' => X, 'tvdb' => Y, 'imdb' => Z]
328
     * @param  int  $seasonNumber  The season number
329
     * @param  int  $episodeNumber  The episode number
330
     * @return array|null Episode data or null on failure
331
     */
332
    public function getTvEpisodeWithFallback(array $ids, int $seasonNumber, int $episodeNumber): ?array
333
    {
334
        // First try with TMDB ID if available
335
        $tmdbId = (int) ($ids['tmdb'] ?? 0);
336
        if ($tmdbId > 0) {
337
            $result = $this->getTvEpisode($tmdbId, $seasonNumber, $episodeNumber);
338
            if ($result !== null) {
339
                return $result;
340
            }
341
        }
342
343
        // Try to find the show by TVDB ID
344
        $tvdbId = (int) ($ids['tvdb'] ?? 0);
345
        if ($tvdbId > 0) {
346
            $show = $this->findTvByExternalId((string) $tvdbId, 'tvdb_id');
347
            if ($show !== null && isset($show['id'])) {
348
                $result = $this->getTvEpisode((int) $show['id'], $seasonNumber, $episodeNumber);
349
                if ($result !== null) {
350
                    return $result;
351
                }
352
            }
353
        }
354
355
        // Try to find the show by IMDB ID
356
        $imdbId = $ids['imdb'] ?? 0;
357
        if (! empty($imdbId)) {
358
            // Format IMDB ID with tt prefix if it's numeric
359
            $imdbFormatted = is_numeric($imdbId)
360
                ? 'tt' . str_pad((string) $imdbId, 7, '0', STR_PAD_LEFT)
361
                : (string) $imdbId;
362
363
            $show = $this->findTvByExternalId($imdbFormatted, 'imdb_id');
364
            if ($show !== null && isset($show['id'])) {
365
                $result = $this->getTvEpisode((int) $show['id'], $seasonNumber, $episodeNumber);
366
                if ($result !== null) {
367
                    return $result;
368
                }
369
            }
370
        }
371
372
        return null;
373
    }
374
375
    /**
376
     * Look up a TV show and get all its external IDs.
377
     * Can be used to cross-reference between providers.
378
     *
379
     * @param  int|string  $id  The external ID
380
     * @param  string  $source  The ID source: 'tmdb', 'tvdb', 'imdb'
381
     * @return array|null Array with all IDs: ['tmdb' => X, 'imdb' => Y, 'tvdb' => Z] or null
382
     */
383
    public function lookupTvShowIds(int|string $id, string $source = 'tmdb'): ?array
384
    {
385
        $show = null;
386
387
        if ($source === 'tmdb' && is_numeric($id) && (int) $id > 0) {
388
            // Direct TMDB lookup
389
            $show = $this->getTvShow((int) $id);
390
            if ($show !== null) {
391
                $externalIds = $this->getTvExternalIds((int) $id);
392
                if ($externalIds !== null) {
393
                    $show['external_ids'] = $externalIds;
394
                }
395
            }
396
        } elseif ($source === 'tvdb' && is_numeric($id) && (int) $id > 0) {
397
            $show = $this->findTvByExternalId((string) $id, 'tvdb_id');
398
        } elseif ($source === 'imdb') {
399
            $imdbFormatted = is_numeric($id)
400
                ? 'tt' . str_pad((string) $id, 7, '0', STR_PAD_LEFT)
401
                : (string) $id;
402
            $show = $this->findTvByExternalId($imdbFormatted, 'imdb_id');
403
        }
404
405
        if ($show === null) {
406
            return null;
407
        }
408
409
        // If we found by external ID, we need to fetch external IDs for full data
410
        $tmdbId = self::getInt($show, 'id');
411
        if ($tmdbId > 0 && ! isset($show['external_ids'])) {
412
            $externalIds = $this->getTvExternalIds($tmdbId);
413
            $show['external_ids'] = $externalIds ?? [];
414
        }
415
416
        $externalIds = self::getArray($show, 'external_ids');
417
418
        // Parse IMDB ID to numeric
419
        $imdbId = 0;
420
        if (! empty($externalIds['imdb_id'])) {
421
            preg_match('/tt(?P<imdbid>\d{6,7})$/i', $externalIds['imdb_id'], $imdb);
422
            $imdbId = (int) ($imdb['imdbid'] ?? 0);
423
        }
424
425
        return [
426
            'tmdb' => $tmdbId,
427
            'imdb' => $imdbId,
428
            'tvdb' => self::getInt($externalIds, 'tvdb_id'),
429
            'tvrage' => self::getInt($externalIds, 'tvrage_id'),
430
        ];
431
    }
432
433
    // =========================================================================
434
    // HELPER METHODS FOR NULL-SAFE DATA EXTRACTION
435
    // =========================================================================
436
437
    /**
438
     * Safely get a string value from an array
439
     */
440
    public static function getString(array $data, string $key, string $default = ''): string
441
    {
442
        return isset($data[$key]) && is_string($data[$key]) ? $data[$key] : $default;
443
    }
444
445
    /**
446
     * Safely get an integer value from an array
447
     */
448
    public static function getInt(array $data, string $key, int $default = 0): int
449
    {
450
        return isset($data[$key]) && is_numeric($data[$key]) ? (int) $data[$key] : $default;
451
    }
452
453
    /**
454
     * Safely get a float value from an array
455
     */
456
    public static function getFloat(array $data, string $key, float $default = 0.0): float
457
    {
458
        return isset($data[$key]) && is_numeric($data[$key]) ? (float) $data[$key] : $default;
459
    }
460
461
    /**
462
     * Safely get an array value from an array
463
     */
464
    public static function getArray(array $data, string $key, array $default = []): array
465
    {
466
        return isset($data[$key]) && is_array($data[$key]) ? $data[$key] : $default;
467
    }
468
469
    /**
470
     * Safely get a nested value from an array using dot notation
471
     */
472
    public static function getNested(array $data, string $path, mixed $default = null): mixed
473
    {
474
        $keys = explode('.', $path);
475
        $value = $data;
476
477
        foreach ($keys as $key) {
478
            if (! is_array($value) || ! array_key_exists($key, $value)) {
479
                return $default;
480
            }
481
            $value = $value[$key];
482
        }
483
484
        return $value ?? $default;
485
    }
486
}
487
488