TraktProvider::getEpisodeInfo()   A
last analyzed

Complexity

Conditions 5
Paths 9

Size

Total Lines 26
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 13
dl 0
loc 26
rs 9.5222
c 0
b 0
f 0
cc 5
nc 9
nop 4
1
<?php
2
3
namespace App\Services\TvProcessing\Providers;
4
5
use App\Services\ReleaseImageService;
6
use App\Services\TraktService;
7
use Illuminate\Support\Carbon;
8
9
/**
10
 * Class TraktProvider.
11
 *
12
 * Process information retrieved from the Trakt API.
13
 */
14
class TraktProvider extends AbstractTvProvider
15
{
16
    private const MATCH_PROBABILITY = 75;
17
18
    public TraktService $client;
19
20
    public $time;
21
22
    /**
23
     * @string URL for show poster art
24
     */
25
    public string $posterUrl = '';
26
27
    /**
28
     * The URL to grab the TV fanart.
29
     */
30
    public string $fanartUrl;
31
32
    /**
33
     * The localized (network airing) timezone of the show.
34
     */
35
    private string $localizedTZ;
36
37
    /**
38
     * @throws \Exception
39
     */
40
    public function __construct()
41
    {
42
        parent::__construct();
43
        $this->client = new TraktService();
44
    }
45
46
    /**
47
     * Main processing director function for scrapers
48
     * Calls work query function and initiates processing.
49
     */
50
    public function processSite($groupID, $guidChar, int $process, bool $local = false): void
51
    {
52
        $res = $this->getTvReleases($groupID, $guidChar, $process, parent::PROCESS_TRAKT);
53
54
        $tvcount = \count($res);
55
56
        if ($tvcount === 0) {
57
58
            return;
59
        }
60
61
        if ($res instanceof \Traversable) {
0 ignored issues
show
introduced by
$res is always a sub-type of Traversable.
Loading history...
62
            $processed = 0;
63
            $matched = 0;
64
            $skipped = 0;
65
66
            foreach ($res as $row) {
67
                $processed++;
68
                $traktid = false;
69
                $this->posterUrl = $this->fanartUrl = $this->localizedTZ = '';
70
71
                // Clean the show name for better match probability
72
                $release = $this->parseInfo($row['searchname']);
73
                if (\is_array($release) && $release['name'] !== '') {
74
                    if (\in_array($release['cleanname'], $this->titleCache, false)) {
75
                        if ($this->echooutput) {
76
                            cli()->primaryOver('    → ');
77
                            cli()->alternateOver($this->truncateTitle($release['cleanname']));
78
                            cli()->primaryOver(' → ');
79
                            cli()->alternate('Skipped (previously failed)');
80
                        }
81
                        $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']);
82
                        $skipped++;
83
84
                        continue;
85
                    }
86
87
                    // Find the Video ID if it already exists by checking the title.
88
                    $videoId = $this->getByTitle($release['cleanname'], parent::TYPE_TV, parent::SOURCE_TRAKT);
89
90
                    // Force local lookup only
91
                    if ($local === true) {
92
                        $lookupSetting = false;
93
                    } else {
94
                        $lookupSetting = true;
95
                    }
96
97
                    if ($videoId === 0 && $lookupSetting) {
98
                        // If it doesn't exist locally and lookups are allowed lets try to get it.
99
                        if ($this->echooutput) {
100
                            cli()->primaryOver('    → ');
101
                            cli()->headerOver($this->truncateTitle($release['cleanname']));
102
                            cli()->primaryOver(' → ');
103
                            cli()->info('Searching Trakt...');
104
                        }
105
106
                        // Get the show from TRAKT
107
                        $traktShow = $this->getShowInfo((string) $release['cleanname']);
108
109
                        if (\is_array($traktShow)) {
110
                            $videoId = $this->add($traktShow);
111
                            $traktid = (int) $traktShow['trakt'];
112
                        }
113
                    } else {
114
                        if ($this->echooutput && $videoId > 0) {
115
                            cli()->primaryOver('    → ');
116
                            cli()->headerOver($this->truncateTitle($release['cleanname']));
117
                            cli()->primaryOver(' → ');
118
                            cli()->info('Found in DB');
119
                        }
120
                        $traktid = $this->getSiteIDFromVideoID('trakt', $videoId);
121
                        $this->localizedTZ = $this->getLocalZoneFromVideoID($videoId);
122
                    }
123
124
                    if ((int) $videoId > 0 && (int) $traktid > 0) {
125
                        // Now that we have valid video and trakt ids, try to get the poster
126
                        // $this->getPoster($videoId, $traktid);
127
128
                        $seasonNo = preg_replace('/^S0*/i', '', $release['season']);
129
                        $episodeNo = preg_replace('/^E0*/i', '', $release['episode']);
130
131
                        if ($episodeNo === 'all') {
132
                            // Set the video ID and leave episode 0
133
                            $this->setVideoIdFound($videoId, $row['id'], 0);
134
                            if ($this->echooutput) {
135
                                cli()->primaryOver('    → ');
136
                                cli()->headerOver($this->truncateTitle($release['cleanname']));
137
                                cli()->primaryOver(' → ');
138
                                cli()->primary('Full Season matched');
139
                            }
140
                            $matched++;
141
142
                            continue;
143
                        }
144
145
                        // Check if we have the episode for this video ID
146
                        $episode = $this->getBySeasonEp($videoId, $seasonNo, $episodeNo, $release['airdate']);
147
148
                        if ($episode === false && $lookupSetting) {
149
                            // Send the request for the episode to TRAKT with fallback to other IDs
150
                            $traktEpisode = $this->getEpisodeInfo(
151
                                $traktid,
0 ignored issues
show
Bug introduced by
It seems like $traktid can also be of type false; however, parameter $siteId of App\Services\TvProcessin...vider::getEpisodeInfo() does only seem to accept integer|string, maybe add an additional type check? ( Ignorable by Annotation )

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

151
                                /** @scrutinizer ignore-type */ $traktid,
Loading history...
152
                                $seasonNo,
153
                                $episodeNo,
154
                                $videoId
155
                            );
156
157
                            if ($traktEpisode) {
158
                                $episode = $this->addEpisode($videoId, $traktEpisode);
159
                            }
160
                        }
161
162
                        if ($episode !== false && is_numeric($episode) && $episode > 0) {
163
                            // Mark the releases video and episode IDs
164
                            $this->setVideoIdFound($videoId, $row['id'], $episode);
165
                            if ($this->echooutput) {
166
                                cli()->primaryOver('    → ');
167
                                cli()->headerOver($this->truncateTitle($release['cleanname']));
168
                                cli()->primaryOver(' S');
169
                                cli()->warningOver(sprintf('%02d', $seasonNo));
170
                                cli()->primaryOver('E');
171
                                cli()->warningOver(sprintf('%02d', $episodeNo));
172
                                cli()->primaryOver(' ✓ ');
173
                                cli()->primary('MATCHED (Trakt)');
174
                            }
175
                            $matched++;
176
                        } else {
177
                            // Processing failed, set the episode ID to the next processing group
178
                            $this->setVideoIdFound($videoId, $row['id'], 0);
179
                            $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']);
180
                            if ($this->echooutput) {
181
                                cli()->primaryOver('    → ');
182
                                cli()->alternateOver($this->truncateTitle($release['cleanname']));
183
                                cli()->primaryOver(' → ');
184
                                cli()->warning('Episode not found');
185
                            }
186
                        }
187
                    } else {
188
                        // Processing failed, set the episode ID to the next processing group
189
                        $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']);
190
                        $this->titleCache[] = $release['cleanname'] ?? null;
191
                        if ($this->echooutput) {
192
                            cli()->primaryOver('    → ');
193
                            cli()->alternateOver($this->truncateTitle($release['cleanname']));
194
                            cli()->primaryOver(' → ');
195
                            cli()->warning('Not found');
196
                        }
197
                    }
198
                } else {
199
                    // Processing failed, set the episode ID to the next processing group
200
                    $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']);
201
                    $this->titleCache[] = $release['cleanname'] ?? null;
202
                    if ($this->echooutput) {
203
                        cli()->primaryOver('    → ');
204
                        cli()->alternateOver(mb_substr($row['searchname'], 0, 50));
205
                        cli()->primaryOver(' → ');
206
                        cli()->error('Parse failed');
207
                    }
208
                }
209
            }
210
211
            // Display summary
212
            if ($this->echooutput && $matched > 0) {
213
                echo "\n";
214
                cli()->primaryOver('  ✓ Trakt: ');
215
                cli()->primary(sprintf('%d matched, %d skipped', $matched, $skipped));
216
            }
217
        }
218
    }
219
220
    /**
221
     * Truncate title for display purposes.
222
     */
223
    protected function truncateTitle(string $title, int $maxLength = 45): string
224
    {
225
        if (mb_strlen($title) <= $maxLength) {
226
            return $title;
227
        }
228
229
        return mb_substr($title, 0, $maxLength - 3).'...';
230
    }
231
232
    /**
233
     * Fetch banner from site.
234
     */
235
    public function getBanner($videoID, $siteId): bool
236
    {
237
        return false;
238
    }
239
240
    /**
241
     * Get all external IDs for a video from the database.
242
     *
243
     * @param  int  $videoId  The local video ID
244
     * @return array Array of external IDs: ['trakt' => X, 'tmdb' => Y, 'tvdb' => Z, 'imdb' => W]
245
     */
246
    protected function getAllSiteIdsFromVideoID(int $videoId): array
247
    {
248
        $result = \App\Models\Video::query()
249
            ->where('id', $videoId)
250
            ->first(['trakt', 'tmdb', 'tvdb', 'imdb']);
251
252
        if ($result === null) {
253
            return ['trakt' => 0, 'tmdb' => 0, 'tvdb' => 0, 'imdb' => 0];
254
        }
255
256
        return [
257
            'trakt' => (int) ($result->trakt ?? 0),
258
            'tmdb' => (int) ($result->tmdb ?? 0),
259
            'tvdb' => (int) ($result->tvdb ?? 0),
260
            'imdb' => (int) ($result->imdb ?? 0),
261
        ];
262
    }
263
264
    /**
265
     * Get episode information from Trakt using all available IDs with fallback.
266
     *
267
     * @param  int|string  $siteId  The primary site ID (Trakt)
268
     * @param  int|string  $series  The season number
269
     * @param  int|string  $episode  The episode number
270
     * @param  int  $videoId  Optional video ID to fetch all external IDs for fallback
271
     * @return array|bool Episode data or false on failure
272
     */
273
    public function getEpisodeInfo(int|string $siteId, int|string $series, int|string $episode, int $videoId = 0): array|bool
274
    {
275
        $return = false;
276
277
        // If we have a video ID, get all available external IDs for fallback
278
        if ($videoId > 0) {
279
            $ids = $this->getAllSiteIdsFromVideoID($videoId);
280
            // Ensure the provided siteId is used as the Trakt ID if available
281
            if ((int) $siteId > 0) {
282
                $ids['trakt'] = (int) $siteId;
283
            }
284
            $response = $this->client->getEpisodeSummaryWithFallback($ids, $series, $episode);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $response is correct as $this->client->getEpisod...ids, $series, $episode) targeting App\Services\TraktServic...deSummaryWithFallback() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
285
        } else {
286
            // Legacy behavior: just use the provided site ID as Trakt ID
287
            $response = $this->client->getEpisodeSummary($siteId, $series, $episode);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $response is correct as $this->client->getEpisod...eId, $series, $episode) targeting App\Services\TraktService::getEpisodeSummary() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
288
        }
289
290
        sleep(1);
291
292
        if (\is_array($response)) {
0 ignored issues
show
introduced by
The condition is_array($response) is always false.
Loading history...
293
            if ($this->checkRequiredAttr($response, 'traktE')) {
294
                $return = $this->formatEpisodeInfo($response);
295
            }
296
        }
297
298
        return $return;
299
    }
300
301
    public function getMovieInfo(): void {}
302
303
    /**
304
     * Retrieve poster image for TV episode from site using its API.
305
     *
306
     * @param  int  $videoId  ID from videos table.
307
     */
308
    public function getPoster(int $videoId): int
309
    {
310
        $hasCover = 0;
311
        $ri = new ReleaseImageService;
312
313
        if ($this->posterUrl !== '') {
314
            // Try to get the Poster
315
            $hasCover = $ri->saveImage($videoId, $this->posterUrl, $this->imgSavePath);
316
        }
317
318
        // Couldn't get poster, try fan art instead
319
        if ($hasCover !== 1 && $this->fanartUrl !== '') {
320
            $hasCover = $ri->saveImage($videoId, $this->fanartUrl, $this->imgSavePath);
321
        }
322
323
        // Mark it retrieved if we saved an image
324
        if ($hasCover === 1) {
325
            $this->setCoverFound($videoId);
326
        }
327
328
        return $hasCover;
329
    }
330
331
    /**
332
     * Get show information from Trakt by name.
333
     *
334
     * @return array|false
335
     */
336
    public function getShowInfo(string $name): array|bool
337
    {
338
        $return = $response = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $response is dead and can be removed.
Loading history...
339
        $highestMatch = 0;
340
        $highest = null;
341
342
        // Trakt does NOT like shows with the year in them even without the parentheses
343
        // Do this for the API Search only as a local lookup should require it
344
        $name = preg_replace('# \((19|20)\d{2}\)$#', '', $name);
345
346
        $response = (array) $this->client->searchShows($name);
347
348
        sleep(1);
349
350
        if (\is_array($response)) {
0 ignored issues
show
introduced by
The condition is_array($response) is always true.
Loading history...
351
            foreach ($response as $show) {
352
                if (! is_bool($show)) {
353
                    // Check for exact title match first and then terminate if found
354
                    if ($show['show']['title'] === $name) {
355
                        $highest = $show;
356
                        break;
357
                    }
358
359
                    // Check each show title for similarity and then find the highest similar value
360
                    $matchPercent = $this->checkMatch($show['show']['title'], $name, self::MATCH_PROBABILITY);
361
362
                    // If new match has a higher percentage, set as new matched title
363
                    if ($matchPercent > $highestMatch) {
364
                        $highestMatch = $matchPercent;
365
                        $highest = $show;
366
                    }
367
                }
368
            }
369
            if ($highest !== null) {
370
                $fullShow = $this->client->getShowSummary($highest['show']['ids']['trakt']);
371
                if ($this->checkRequiredAttr($fullShow, 'traktS')) {
372
                    $return = $this->formatShowInfo($fullShow);
373
                }
374
            }
375
        }
376
377
        return $return;
378
    }
379
380
    /**
381
     * Assigns API show response values to a formatted array for insertion
382
     * Returns the formatted array.
383
     */
384
    public function formatShowInfo($show): array
385
    {
386
        preg_match('/tt(?P<imdbid>\d{6,7})$/i', $show['ids']['imdb'], $imdb);
387
        $this->posterUrl = $show['images']['poster']['thumb'] ?? '';
388
        $this->fanartUrl = $show['images']['fanart']['thumb'] ?? '';
389
        $this->localizedTZ = $show['airs']['timezone'] ?? '';
390
391
        $imdbId = $imdb['imdbid'] ?? 0;
392
        $tvdbId = $show['ids']['tvdb'] ?? 0;
393
394
        // Look up TVMaze ID using TVDB or IMDB
395
        $tvmazeId = $this->lookupTvMazeId($tvdbId, $imdbId);
396
397
        return [
398
            'type' => parent::TYPE_TV,
399
            'title' => $show['title'],
400
            'summary' => $show['overview'],
401
            'started' => Carbon::parse($show['first_aired'], $this->localizedTZ)->format('Y-m-d'),
402
            'publisher' => $show['network'],
403
            'country' => $show['country'],
404
            'source' => parent::SOURCE_TRAKT,
405
            'imdb' => $imdbId,
406
            'tvdb' => $tvdbId,
407
            'trakt' => $show['ids']['trakt'],
408
            'tvrage' => $show['ids']['tvrage'] ?? 0,
409
            'tvmaze' => $tvmazeId,
410
            'tmdb' => $show['ids']['tmdb'] ?? 0,
411
            'aliases' => isset($show['aliases']) && ! empty($show['aliases']) ? $show['aliases'] : '',
412
            'localzone' => $this->localizedTZ,
413
        ];
414
    }
415
416
    /**
417
     * Look up TVMaze ID using TVDB ID or IMDB ID.
418
     *
419
     * @param  int  $tvdbId  TVDB show ID
420
     * @param  int|string  $imdbId  IMDB ID (numeric, without 'tt' prefix)
421
     * @return int TVMaze ID or 0 if not found
422
     */
423
    protected function lookupTvMazeId(int $tvdbId, int|string $imdbId): int
424
    {
425
        try {
426
            $tvmazeClient = new \DariusIII\TVMaze\TVMaze();
427
428
            // Try TVDB ID first
429
            if ($tvdbId > 0) {
430
                $result = $tvmazeClient->getShowBySiteID('thetvdb', $tvdbId);
431
                if ($result !== null && isset($result->id)) {
432
                    return (int) $result->id;
433
                }
434
            }
435
436
            // Try IMDB ID as fallback
437
            if (! empty($imdbId) && $imdbId > 0) {
438
                $imdbFormatted = 'tt' . str_pad((string) $imdbId, 7, '0', STR_PAD_LEFT);
439
                $result = $tvmazeClient->getShowBySiteID('imdb', $imdbFormatted);
440
                if ($result !== null && isset($result->id)) {
441
                    return (int) $result->id;
442
                }
443
            }
444
        } catch (\Throwable $e) {
445
            // Silently fail - TVMaze ID lookup is optional enrichment
446
        }
447
448
        return 0;
449
    }
450
451
    /**
452
     * Assigns API episode response values to a formatted array for insertion
453
     * Returns the formatted array.
454
     */
455
    public function formatEpisodeInfo($episode): array
456
    {
457
        return [
458
            'title' => (string) $episode['title'],
459
            'series' => (int) $episode['season'],
460
            // Prefer the Trakt 'number' field for episode number, fall back to 'episode' if present
461
            'episode' => (int) ($episode['number'] ?? ($episode['episode'] ?? 0)),
462
            'se_complete' => 'S'.sprintf('%02d', $episode['season']).'E'.sprintf('%02d', ($episode['number'] ?? ($episode['episode'] ?? 0))),
463
            'firstaired' => Carbon::parse($episode['first_aired'], $this->localizedTZ)->format('Y-m-d'),
464
            'summary' => (string) $episode['overview'],
465
        ];
466
    }
467
}
468