Issues (867)

app/Services/TraktService.php (4 issues)

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