Passed
Push — master ( ebb8ab...14af2f )
by Darko
12:35 queued 01:20
created

TraktService::isConfigured()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
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
use Illuminate\Support\Str;
9
10
/**
11
 * Trakt.tv Service
12
 *
13
 * A modern service wrapper for the Trakt.tv API.
14
 * Provides methods to fetch movie and TV show information.
15
 *
16
 * API Documentation: https://trakt.docs.apiary.io/
17
 */
18
class TraktService
19
{
20
    protected const BASE_URL = 'https://api.trakt.tv';
21
22
    protected const CACHE_TTL_HOURS = 24;
23
24
    protected const API_VERSION = 2;
25
26
    protected const ID_TYPES = ['imdb', 'tmdb', 'trakt', 'tvdb'];
27
28
    protected string $clientId;
29
30
    protected int $timeout;
31
32
    protected int $retryTimes;
33
34
    protected int $retryDelay;
35
36
    public function __construct(?string $clientId = null)
37
    {
38
        $this->clientId = $clientId ?? (string) config('nntmux_api.trakttv_api_key', '');
39
        $this->timeout = (int) config('nntmux_api.trakttv_timeout', 30);
40
        $this->retryTimes = (int) config('nntmux_api.trakttv_retry_times', 3);
41
        $this->retryDelay = (int) config('nntmux_api.trakttv_retry_delay', 100);
42
    }
43
44
    /**
45
     * Check if the API key is configured.
46
     */
47
    public function isConfigured(): bool
48
    {
49
        return ! empty($this->clientId);
50
    }
51
52
    /**
53
     * Get the request headers for Trakt API.
54
     */
55
    protected function getHeaders(): array
56
    {
57
        return [
58
            'Content-Type' => 'application/json',
59
            'trakt-api-version' => self::API_VERSION,
60
            'trakt-api-key' => $this->clientId,
61
        ];
62
    }
63
64
    /**
65
     * Make a GET request to the Trakt API.
66
     *
67
     * @param  string  $endpoint  The API endpoint
68
     * @param  array  $params  Query parameters
69
     * @return array|null Response data or null on failure
70
     */
71
    protected function get(string $endpoint, array $params = []): ?array
72
    {
73
        if (! $this->isConfigured()) {
74
            Log::debug('Trakt API key is not configured');
75
76
            return null;
77
        }
78
79
        $url = self::BASE_URL.'/'.ltrim($endpoint, '/');
80
81
        try {
82
            $response = Http::timeout($this->timeout)
83
                ->retry($this->retryTimes, $this->retryDelay)
84
                ->withHeaders($this->getHeaders())
85
                ->get($url, $params);
86
87
            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

87
            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...
88
                $data = $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

88
                /** @scrutinizer ignore-call */ 
89
                $data = $response->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...
89
90
                // Check for API error responses
91
                if (isset($data['status']) && $data['status'] === 'failure') {
92
                    Log::debug('Trakt API returned failure status', [
93
                        'endpoint' => $endpoint,
94
                    ]);
95
96
                    return null;
97
                }
98
99
                return $data;
100
            }
101
102
            // Handle specific error codes
103
            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

103
            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...
104
                Log::debug('Trakt: Resource not found', ['endpoint' => $endpoint]);
105
106
                return null;
107
            }
108
109
            Log::warning('Trakt API request failed', [
110
                'endpoint' => $endpoint,
111
                'status' => $response->status(),
112
                '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

112
                '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...
113
            ]);
114
115
            return null;
116
        } catch (\Throwable $e) {
117
            Log::warning('Trakt API request exception', [
118
                'endpoint' => $endpoint,
119
                'message' => $e->getMessage(),
120
            ]);
121
122
            return null;
123
        }
124
    }
125
126
    /**
127
     * Get episode summary.
128
     *
129
     * @param  int|string  $showId  The show ID (Trakt, IMDB, TMDB, or TVDB)
130
     * @param  int|string  $season  The season number
131
     * @param  int|string  $episode  The episode number
132
     * @param  string  $extended  Extended info level: 'min', 'full', 'aliases', 'full,aliases'
133
     * @return array|null Episode data or null on failure
134
     */
135
    public function getEpisodeSummary(
136
        int|string $showId,
137
        int|string $season,
138
        int|string $episode,
139
        string $extended = 'min'
140
    ): ?array {
141
        // Validate parameters to avoid unnecessary API calls with invalid IDs
142
        $showIdInt = is_numeric($showId) ? (int) $showId : 0;
143
        $seasonInt = is_numeric($season) ? (int) $season : -1;
144
        $episodeInt = is_numeric($episode) ? (int) $episode : -1;
145
146
        // For numeric show IDs, validate they are positive
147
        // Season and episode must be non-negative (season 0 can be specials)
148
        if (($showIdInt <= 0 && is_numeric($showId)) || $seasonInt < 0 || $episodeInt < 0) {
149
            return null;
150
        }
151
152
        $extended = match ($extended) {
153
            'aliases', 'full', 'full,aliases' => $extended,
154
            default => 'min',
155
        };
156
157
        $cacheKey = "trakt_episode_{$showId}_{$season}_{$episode}_{$extended}";
158
159
        return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($showId, $season, $episode, $extended) {
160
            return $this->get("shows/{$showId}/seasons/{$season}/episodes/{$episode}", [
161
                'extended' => $extended,
162
            ]);
163
        });
164
    }
165
166
    /**
167
     * Get current box office movies.
168
     *
169
     * @return array|null Box office data or null on failure
170
     */
171
    public function getBoxOffice(): ?array
172
    {
173
        $cacheKey = 'trakt_boxoffice_'.date('Y-m-d');
174
175
        return Cache::remember($cacheKey, now()->addHours(6), function () {
176
            return $this->get('movies/boxoffice');
177
        });
178
    }
179
180
    /**
181
     * Get TV show calendar.
182
     *
183
     * @param  string  $startDate  Start date (YYYY-MM-DD format, defaults to today)
184
     * @param  int  $days  Number of days to retrieve (default: 7)
185
     * @return array|null Calendar data or null on failure
186
     */
187
    public function getCalendar(string $startDate = '', int $days = 7): ?array
188
    {
189
        if (empty($startDate)) {
190
            $startDate = date('Y-m-d');
191
        }
192
193
        $cacheKey = "trakt_calendar_{$startDate}_{$days}";
194
195
        return Cache::remember($cacheKey, now()->addHours(6), function () use ($startDate, $days) {
196
            return $this->get("calendars/all/shows/{$startDate}/{$days}");
197
        });
198
    }
199
200
    /**
201
     * Get movie summary.
202
     *
203
     * @param  string  $movie  Movie slug or ID
204
     * @param  string  $extended  Extended info level: 'min' or 'full'
205
     * @return array|null Movie data or null on failure
206
     */
207
    public function getMovieSummary(string $movie, string $extended = 'min'): ?array
208
    {
209
        if (empty($movie)) {
210
            return null;
211
        }
212
213
        $extended = $extended === 'full' ? 'full' : 'min';
214
        $slug = Str::slug($movie);
215
        $cacheKey = "trakt_movie_{$slug}_{$extended}";
216
217
        return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) {
218
            return $this->get("movies/{$slug}", ['extended' => $extended]);
219
        });
220
    }
221
222
    /**
223
     * Get IMDB ID for a movie.
224
     *
225
     * @param  string  $movie  Movie slug or ID
226
     * @return string|null IMDB ID or null on failure
227
     */
228
    public function getMovieImdbId(string $movie): ?string
229
    {
230
        $data = $this->getMovieSummary($movie, 'min');
231
232
        return $data['ids']['imdb'] ?? null;
233
    }
234
235
    /**
236
     * Search by external ID.
237
     *
238
     * @param  int|string  $id  The ID to search for
239
     * @param  string  $idType  The ID type: 'imdb', 'tmdb', 'trakt', 'tvdb'
240
     * @param  string  $mediaType  Media type: 'movie', 'show', 'episode', or empty for all
241
     * @return array|null Search results or null on failure
242
     */
243
    public function searchById(int|string $id, string $idType = 'trakt', string $mediaType = ''): ?array
244
    {
245
        if (! in_array($idType, self::ID_TYPES, true)) {
246
            Log::warning('Trakt: Invalid ID type', ['idType' => $idType]);
247
248
            return null;
249
        }
250
251
        // Format IMDB ID with 'tt' prefix if needed
252
        if ($idType === 'imdb' && is_numeric($id)) {
253
            $id = 'tt'.$id;
254
        }
255
256
        $params = [
257
            'id_type' => $idType,
258
            'id' => $id,
259
        ];
260
261
        if (! empty($mediaType)) {
262
            $params['type'] = $mediaType;
263
        }
264
265
        // Don't cache ID searches as they're typically one-off lookups
266
        return $this->get('search/'.$idType.'/'.$id, ['type' => $mediaType ?: null]);
267
    }
268
269
    /**
270
     * Search for a show by name.
271
     *
272
     * @param  string  $query  Search query
273
     * @param  string  $type  Search type: 'show', 'movie', 'episode', 'person', 'list'
274
     * @return array|null Search results or null on failure
275
     */
276
    public function searchShows(string $query, string $type = 'show'): ?array
277
    {
278
        if (empty($query)) {
279
            return null;
280
        }
281
282
        $slug = Str::slug($query);
283
        $cacheKey = "trakt_search_{$type}_{$slug}";
284
285
        return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($query, $type) {
286
            return $this->get('search/'.$type, ['query' => $query]);
287
        });
288
    }
289
290
    /**
291
     * Get show summary.
292
     *
293
     * @param  string  $show  Show slug or ID
294
     * @param  string  $extended  Extended info level: 'min' or 'full'
295
     * @return array|null Show data or null on failure
296
     */
297
    public function getShowSummary(string $show, string $extended = 'full'): ?array
298
    {
299
        if (empty($show)) {
300
            return null;
301
        }
302
303
        $extended = $extended === 'full' ? 'full' : 'min';
304
        $slug = Str::slug($show);
305
        $cacheKey = "trakt_show_{$slug}_{$extended}";
306
307
        return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) {
308
            return $this->get("shows/{$slug}", ['extended' => $extended]);
309
        });
310
    }
311
312
    /**
313
     * Get show seasons.
314
     *
315
     * @param  string  $show  Show slug or ID
316
     * @param  string  $extended  Extended info level
317
     * @return array|null Seasons data or null on failure
318
     */
319
    public function getShowSeasons(string $show, string $extended = 'full'): ?array
320
    {
321
        if (empty($show)) {
322
            return null;
323
        }
324
325
        $slug = Str::slug($show);
326
        $cacheKey = "trakt_seasons_{$slug}_{$extended}";
327
328
        return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $extended) {
329
            return $this->get("shows/{$slug}/seasons", ['extended' => $extended]);
330
        });
331
    }
332
333
    /**
334
     * Get season episodes.
335
     *
336
     * @param  string  $show  Show slug or ID
337
     * @param  int  $season  Season number
338
     * @param  string  $extended  Extended info level
339
     * @return array|null Episodes data or null on failure
340
     */
341
    public function getSeasonEpisodes(string $show, int $season, string $extended = 'full'): ?array
342
    {
343
        if (empty($show)) {
344
            return null;
345
        }
346
347
        $slug = Str::slug($show);
348
        $cacheKey = "trakt_season_episodes_{$slug}_{$season}_{$extended}";
349
350
        return Cache::remember($cacheKey, now()->addHours(self::CACHE_TTL_HOURS), function () use ($slug, $season, $extended) {
351
            return $this->get("shows/{$slug}/seasons/{$season}", ['extended' => $extended]);
352
        });
353
    }
354
355
    /**
356
     * Get trending shows.
357
     *
358
     * @param  int  $limit  Number of results to return
359
     * @param  string  $extended  Extended info level
360
     * @return array|null Trending shows or null on failure
361
     */
362
    public function getTrendingShows(int $limit = 10, string $extended = 'full'): ?array
363
    {
364
        $cacheKey = "trakt_trending_shows_{$limit}_{$extended}";
365
366
        return Cache::remember($cacheKey, now()->addHours(1), function () use ($limit, $extended) {
367
            return $this->get('shows/trending', [
368
                'limit' => $limit,
369
                'extended' => $extended,
370
            ]);
371
        });
372
    }
373
374
    /**
375
     * Get trending movies.
376
     *
377
     * @param  int  $limit  Number of results to return
378
     * @param  string  $extended  Extended info level
379
     * @return array|null Trending movies or null on failure
380
     */
381
    public function getTrendingMovies(int $limit = 10, string $extended = 'full'): ?array
382
    {
383
        $cacheKey = "trakt_trending_movies_{$limit}_{$extended}";
384
385
        return Cache::remember($cacheKey, now()->addHours(1), function () use ($limit, $extended) {
386
            return $this->get('movies/trending', [
387
                'limit' => $limit,
388
                'extended' => $extended,
389
            ]);
390
        });
391
    }
392
393
    /**
394
     * Get popular shows.
395
     *
396
     * @param  int  $limit  Number of results to return
397
     * @param  string  $extended  Extended info level
398
     * @return array|null Popular shows or null on failure
399
     */
400
    public function getPopularShows(int $limit = 10, string $extended = 'full'): ?array
401
    {
402
        $cacheKey = "trakt_popular_shows_{$limit}_{$extended}";
403
404
        return Cache::remember($cacheKey, now()->addHours(6), function () use ($limit, $extended) {
405
            return $this->get('shows/popular', [
406
                'limit' => $limit,
407
                'extended' => $extended,
408
            ]);
409
        });
410
    }
411
412
    /**
413
     * Get popular movies.
414
     *
415
     * @param  int  $limit  Number of results to return
416
     * @param  string  $extended  Extended info level
417
     * @return array|null Popular movies or null on failure
418
     */
419
    public function getPopularMovies(int $limit = 10, string $extended = 'full'): ?array
420
    {
421
        $cacheKey = "trakt_popular_movies_{$limit}_{$extended}";
422
423
        return Cache::remember($cacheKey, now()->addHours(6), function () use ($limit, $extended) {
424
            return $this->get('movies/popular', [
425
                'limit' => $limit,
426
                'extended' => $extended,
427
            ]);
428
        });
429
    }
430
431
    /**
432
     * Clear cached data for a specific show.
433
     */
434
    public function clearShowCache(string $show): void
435
    {
436
        $slug = Str::slug($show);
437
        Cache::forget("trakt_show_{$slug}_min");
438
        Cache::forget("trakt_show_{$slug}_full");
439
        Cache::forget("trakt_seasons_{$slug}_full");
440
        Cache::forget("trakt_seasons_{$slug}_min");
441
    }
442
443
    /**
444
     * Clear cached data for a specific movie.
445
     */
446
    public function clearMovieCache(string $movie): void
447
    {
448
        $slug = Str::slug($movie);
449
        Cache::forget("trakt_movie_{$slug}_min");
450
        Cache::forget("trakt_movie_{$slug}_full");
451
    }
452
}
453
454