TvMazeProvider::getBanner()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 2
1
<?php
2
3
namespace App\Services\TvProcessing\Providers;
4
5
use App\Services\ReleaseImageService;
6
use DariusIII\TVMaze\TVMaze as Client;
7
8
/**
9
 * Class TvMazeProvider.
10
 *
11
 * Process information retrieved from the TVMaze API.
12
 */
13
class TvMazeProvider extends AbstractTvProvider
14
{
15
    private const MATCH_PROBABILITY = 75;
16
17
    /**
18
     * Client for TVMaze API.
19
     */
20
    public Client $client;
21
22
    /**
23
     * @string URL for show poster art
24
     */
25
    public string $posterUrl = '';
26
27
    /**
28
     * TVMaze constructor.
29
     *
30
     *
31
     * @throws \Exception
32
     */
33
    public function __construct()
34
    {
35
        parent::__construct();
36
        $this->client = new Client;
37
    }
38
39
    /**
40
     * Fetch banner from site.
41
     */
42
    public function getBanner($videoID, $siteId): bool
43
    {
44
        return false;
45
    }
46
47
    /**
48
     * Main processing director function for scrapers
49
     * Calls work query function and initiates processing.
50
     */
51
    public function processSite($groupID, $guidChar, $process, bool $local = false): void
52
    {
53
        $res = $this->getTvReleases($groupID, $guidChar, $process, parent::PROCESS_TVMAZE);
54
55
        $tvCount = \count($res);
56
57
        if ($tvCount === 0) {
58
59
            return;
60
        }
61
62
        if ($res instanceof \Traversable) {
0 ignored issues
show
introduced by
$res is always a sub-type of Traversable.
Loading history...
63
            $this->titleCache = [];
64
            $processed = 0;
65
            $matched = 0;
66
            $skipped = 0;
67
68
            foreach ($res as $row) {
69
                $processed++;
70
                $siteId = false;
71
                $this->posterUrl = '';
72
73
                // Clean the show name for better match probability
74
                $release = $this->parseInfo($row['searchname']);
75
                if (\is_array($release) && $release['name'] !== '') {
76
                    if (\in_array($release['cleanname'], $this->titleCache, false)) {
77
                        if ($this->echooutput) {
78
                            cli()->primaryOver('    → ');
79
                            cli()->alternateOver($this->truncateTitle($release['cleanname']));
80
                            cli()->primaryOver(' → ');
81
                            cli()->alternate('Skipped (previously failed)');
82
                        }
83
                        $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']);
84
                        $skipped++;
85
86
                        continue;
87
                    }
88
89
                    // Find the Video ID if it already exists by checking the title against stored TVMaze titles
90
                    $videoId = $this->getByTitle($release['cleanname'], parent::TYPE_TV, parent::SOURCE_TVMAZE);
91
92
                    // Force local lookup only
93
                    // $local = true, $lookupsetting = false and vice versa
94
                    $lookupSetting = $local !== true;
95
96
                    if ($videoId === 0 && $lookupSetting) {
97
                        // If lookups are allowed lets try to get it.
98
                        if ($this->echooutput) {
99
                            cli()->primaryOver('    → ');
100
                            cli()->headerOver($this->truncateTitle($release['cleanname']));
101
                            cli()->primaryOver(' → ');
102
                            cli()->info('Searching TVMaze...');
103
                        }
104
105
                        // Get the show from TVMaze
106
                        $tvMazeShow = $this->getShowInfo((string) $release['cleanname']);
107
108
                        $dupeCheck = false;
109
110
                        if (\is_array($tvMazeShow)) {
111
                            $siteId = (int) $tvMazeShow['tvmaze'];
112
                            // Check if we have the TVDB ID already, if we do use that Video ID, unless it is 0
113
                            if ((int) $tvMazeShow['tvdb'] !== 0) {
114
                                $dupeCheck = $this->getVideoIDFromSiteID('tvdb', $tvMazeShow['tvdb']);
115
                            }
116
                            if ($dupeCheck === false) {
117
                                $videoId = $this->add($tvMazeShow);
118
                            } else {
119
                                $videoId = $dupeCheck;
120
                                // Update any missing fields and add site IDs
121
                                $this->update($videoId, $tvMazeShow);
0 ignored issues
show
Bug introduced by
It seems like $videoId can also be of type true; however, parameter $videoId of App\Services\TvProcessin...actTvProvider::update() does only seem to accept integer, 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

121
                                $this->update(/** @scrutinizer ignore-type */ $videoId, $tvMazeShow);
Loading history...
122
                            }
123
                        } else {
124
                            $videoId = $dupeCheck;
125
                        }
126
                    } else {
127
                        if ($this->echooutput && $videoId > 0) {
128
                            cli()->primaryOver('    → ');
129
                            cli()->headerOver($this->truncateTitle($release['cleanname']));
130
                            cli()->primaryOver(' → ');
131
                            cli()->info('Found in DB');
132
                        }
133
                        $siteId = $this->getSiteIDFromVideoID('tvmaze', $videoId);
134
                    }
135
136
                    if (is_numeric($videoId) && $videoId > 0 && is_numeric($siteId) && $siteId > 0) {
137
                        // Now that we have valid video and tvmaze ids, try to get the poster
138
                        $this->getPoster($videoId);
139
140
                        $seriesNo = preg_replace('/^S0*/i', '', $release['season']);
141
                        $episodeNo = preg_replace('/^E0*/i', '', $release['episode']);
142
143
                        if ($episodeNo === 'all') {
144
                            // Set the video ID and leave episode 0
145
                            $this->setVideoIdFound($videoId, $row['id'], 0);
146
                            if ($this->echooutput) {
147
                                cli()->primaryOver('    → ');
148
                                cli()->headerOver($this->truncateTitle($release['cleanname']));
149
                                cli()->primaryOver(' → ');
150
                                cli()->primary('Full Season matched');
151
                            }
152
                            $matched++;
153
154
                            continue;
155
                        }
156
157
                        // Download all episodes if new show to reduce API usage
158
                        if ($this->countEpsByVideoID($videoId) === false) {
159
                            $this->getEpisodeInfo($siteId, -1, -1, '', $videoId);
160
                        }
161
162
                        // Check if we have the episode for this video ID
163
                        $episode = $this->getBySeasonEp($videoId, $seriesNo, $episodeNo, $release['airdate']);
164
165
                        if ($episode === false) {
166
                            // Send the request for the episode to TVMaze
167
                            $tvMazeEpisode = $this->getEpisodeInfo(
168
                                $siteId,
169
                                (int) $seriesNo,
170
                                (int) $episodeNo,
171
                                $release['airdate']
172
                            );
173
174
                            if ($tvMazeEpisode) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tvMazeEpisode of type array<string,integer|string> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
175
                                $episode = $this->addEpisode($videoId, $tvMazeEpisode);
176
                            }
177
                        }
178
179
                        if ($episode !== false && is_numeric($episode) && $episode > 0) {
180
                            // Mark the releases video and episode IDs
181
                            $this->setVideoIdFound($videoId, $row['id'], $episode);
182
                            if ($this->echooutput) {
183
                                cli()->primaryOver('    → ');
184
                                cli()->headerOver($this->truncateTitle($release['cleanname']));
185
                                cli()->primaryOver(' S');
186
                                cli()->warningOver(sprintf('%02d', $seriesNo));
187
                                cli()->primaryOver('E');
188
                                cli()->warningOver(sprintf('%02d', $episodeNo));
189
                                cli()->primaryOver(' ✓ ');
190
                                cli()->primary('MATCHED (TVMaze)');
191
                            }
192
                            $matched++;
193
                        } else {
194
                            // Processing failed, set the episode ID to the next processing group
195
                            $this->setVideoIdFound($videoId, $row['id'], 0);
196
                            $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']);
197
                            if ($this->echooutput) {
198
                                cli()->primaryOver('    → ');
199
                                cli()->alternateOver($this->truncateTitle($release['cleanname']));
200
                                cli()->primaryOver(' → ');
201
                                cli()->warning('Episode not found');
202
                            }
203
                        }
204
                    } else {
205
                        // Processing failed, set the episode ID to the next processing group
206
                        $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']);
207
                        $this->titleCache[] = $release['cleanname'] ?? null;
208
                        if ($this->echooutput) {
209
                            cli()->primaryOver('    → ');
210
                            cli()->alternateOver($this->truncateTitle($release['cleanname']));
211
                            cli()->primaryOver(' → ');
212
                            cli()->warning('Not found');
213
                        }
214
                    }
215
                } else {
216
                    // Processing failed, set the episode ID to the next processing group
217
                    $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']);
218
                    $this->titleCache[] = $release['cleanname'] ?? null;
219
                    if ($this->echooutput) {
220
                        cli()->primaryOver('    → ');
221
                        cli()->alternateOver(mb_substr($row['searchname'], 0, 50));
222
                        cli()->primaryOver(' → ');
223
                        cli()->error('Parse failed');
224
                    }
225
                }
226
            }
227
228
            // Display summary
229
            if ($this->echooutput && $matched > 0) {
230
                echo "\n";
231
                cli()->primaryOver('  ✓ TVMaze: ');
232
                cli()->primary(sprintf('%d matched, %d skipped', $matched, $skipped));
233
            }
234
        }
235
    }
236
237
    /**
238
     * Truncate title for display purposes.
239
     */
240
    protected function truncateTitle(string $title, int $maxLength = 45): string
241
    {
242
        if (mb_strlen($title) <= $maxLength) {
243
            return $title;
244
        }
245
246
        return mb_substr($title, 0, $maxLength - 3).'...';
247
    }
248
249
    /**
250
     * Calls the API to lookup the TvMaze info for a given TVDB or TVRage ID
251
     * Returns a formatted array of show data or false if no match.
252
     *
253
     * @return array|false
254
     */
255
    protected function getShowInfoBySiteID($site, $siteId)
256
    {
257
        $return = $response = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $response is dead and can be removed.
Loading history...
258
259
        // Try for the best match with AKAs embedded
260
        $response = $this->client->getShowBySiteID($site, $siteId);
261
262
        sleep(1);
263
264
        if (\is_array($response)) {
0 ignored issues
show
introduced by
The condition is_array($response) is always false.
Loading history...
265
            $return = $this->formatShowInfo($response);
266
        }
267
268
        return $return;
269
    }
270
271
    /**
272
     * Calls the API to perform initial show name match to TVDB title
273
     * Returns a formatted array of show data or false if no match.
274
     */
275
    public function getShowInfo(string $name): array|bool
276
    {
277
        $return = $response = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $response is dead and can be removed.
Loading history...
278
279
        // TVMaze does NOT like shows with the year in them even without the parentheses
280
        // Do this for the API Search only as a local lookup should require it
281
        $name = preg_replace('# \((19|20)\d{2}\)$#', '', $name);
282
283
        // Try for the best match with AKAs embedded
284
        $response = $this->client->singleSearchAkas($name);
285
286
        sleep(1);
287
288
        if (\is_array($response)) {
0 ignored issues
show
introduced by
The condition is_array($response) is always true.
Loading history...
289
            $return = $this->matchShowInfo($response, $name);
290
        }
291
        if ($return === false) {
292
            // Try for the best match via full search (no AKAs can be returned but the search is better)
293
            $response = $this->client->search($name);
294
            if (\is_array($response)) {
0 ignored issues
show
introduced by
The condition is_array($response) is always true.
Loading history...
295
                $return = $this->matchShowInfo($response, $name);
296
            }
297
        }
298
        // If we didn't get any aliases do a direct alias lookup
299
        if (\is_array($return) && empty($return['aliases']) && is_numeric($return['tvmaze'])) {
300
            $return['aliases'] = $this->client->getShowAKAs($return['tvmaze']);
301
        }
302
303
        return $return;
304
    }
305
306
    /**
307
     * Attempts to find the best matching show by title or aliases.
308
     */
309
    private function matchShowInfo(array $shows, string $cleanName): false|array
310
    {
311
        $return = false;
312
        $highestMatch = 0;
313
        $highest = null;
314
315
        foreach ($shows as $show) {
316
            if ($this->checkRequiredAttr($show, 'tvmazeS')) {
317
                // Exact title match
318
                if (strcasecmp($show->name, $cleanName) === 0) {
319
                    $highest = $show;
320
                    $highestMatch = 100;
0 ignored issues
show
Unused Code introduced by
The assignment to $highestMatch is dead and can be removed.
Loading history...
321
                    break;
322
                }
323
324
                // Title similarity
325
                $matchPercent = $this->checkMatch(strtolower($show->name), strtolower($cleanName), self::MATCH_PROBABILITY);
326
                if ($matchPercent > $highestMatch) {
327
                    $highestMatch = $matchPercent;
328
                    $highest = $show;
329
                }
330
331
                // Alias matches
332
                if (is_array($show->akas) && ! empty($show->akas)) {
333
                    foreach ($show->akas as $aka) {
334
                        if (! isset($aka['name'])) {
335
                            continue;
336
                        }
337
338
                        // Exact alias match
339
                        if (strcasecmp($aka['name'], $cleanName) === 0) {
340
                            $highest = $show;
341
                            $highestMatch = 100;
342
                            break 2;
343
                        }
344
345
                        // Alias similarity
346
                        $aliasPercent = $this->checkMatch(strtolower($aka['name']), strtolower($cleanName), self::MATCH_PROBABILITY);
347
                        if ($aliasPercent > $highestMatch) {
348
                            $highestMatch = $aliasPercent;
349
                            $highest = $show;
350
                        }
351
                    }
352
                }
353
            }
354
        }
355
356
        if ($highest !== null) {
357
            $return = $this->formatShowInfo($highest);
358
        }
359
360
        return $return;
361
    }
362
363
    /**
364
     * Retrieves the poster art for the processed show.
365
     *
366
     * @param  int  $videoId  -- the local Video ID
367
     */
368
    public function getPoster(int $videoId): int
369
    {
370
        $ri = new ReleaseImageService;
371
372
        $hasCover = 0;
373
374
        // Try to get the Poster
375
        if (! empty($this->posterUrl)) {
376
            $hasCover = $ri->saveImage($videoId, $this->posterUrl, $this->imgSavePath);
377
378
            // Mark it retrieved if we saved an image
379
            if ($hasCover === 1) {
380
                $this->setCoverFound($videoId);
381
            }
382
        }
383
384
        return $hasCover;
385
    }
386
387
    public function getEpisodeInfo(int|string $siteId, int|string $series, int|string $episode, string $airDate = '', int $videoId = 0): array|bool
388
    {
389
        $return = $response = false;
390
391
        if ($airDate !== '') {
392
            $response = $this->client->getEpisodesByAirdate($siteId, $airDate);
393
        } elseif ($videoId > 0) {
394
            $response = $this->client->getEpisodesByShowID($siteId);
395
        } else {
396
            $response = $this->client->getEpisodeByNumber($siteId, $series, $episode);
397
        }
398
399
        sleep(1);
400
401
        // Handle Single Episode Lookups
402
        if (\is_object($response)) {
403
            if ($this->checkRequiredAttr($response, 'tvmazeE')) {
404
                $return = $this->formatEpisodeInfo($response);
405
            }
406
        } elseif (\is_array($response)) {
407
            // Handle new show/all episodes and airdate lookups
408
            foreach ($response as $singleEpisode) {
409
                if ($this->checkRequiredAttr($singleEpisode, 'tvmazeE')) {
410
                    // If this is an airdate lookup and it matches the airdate, set a return
411
                    if ($airDate !== '' && $airDate === $singleEpisode->airdate) {
412
                        $return = $this->formatEpisodeInfo($singleEpisode);
413
                    } else {
414
                        // Insert the episode
415
                        $this->addEpisode($videoId, $this->formatEpisodeInfo($singleEpisode));
416
                    }
417
                }
418
            }
419
        }
420
421
        return $return;
422
    }
423
424
    /**
425
     * Assigns API show response values to a formatted array for insertion
426
     * Returns the formatted array.
427
     */
428
    public function formatShowInfo($show): array
429
    {
430
        $this->posterUrl = (string) ($show->mediumImage ?? '');
431
432
        $tvdbId = (int) ($show->externalIDs['thetvdb'] ?? 0);
433
        $imdbId = 0;
434
435
        // Extract IMDB ID if available
436
        if (! empty($show->externalIDs['imdb'])) {
437
            preg_match('/tt(?P<imdbid>\d{6,9})$/i', $show->externalIDs['imdb'], $imdb);
438
            $imdbId = (int) ($imdb['imdbid'] ?? 0);
439
        }
440
441
        // Look up TMDB and Trakt IDs using available external IDs
442
        $externalIds = $this->lookupExternalIds($tvdbId, $imdbId);
443
444
        return [
445
            'type' => parent::TYPE_TV,
446
            'title' => (string) $show->name,
447
            'summary' => (string) $show->summary,
448
            'started' => (string) $show->premiered,
449
            'publisher' => (string) $show->network,
450
            'country' => (string) $show->country,
451
            'source' => parent::SOURCE_TVMAZE,
452
            'imdb' => $imdbId,
453
            'tvdb' => $tvdbId,
454
            'tvmaze' => (int) $show->id,
455
            'trakt' => $externalIds['trakt'],
456
            'tvrage' => (int) ($show->externalIDs['tvrage'] ?? 0),
457
            'tmdb' => $externalIds['tmdb'],
458
            'aliases' => ! empty($show->akas) ? (array) $show->akas : '',
459
            'localzone' => "''",
460
        ];
461
    }
462
463
    /**
464
     * Look up TMDB and Trakt IDs using TVDB ID and IMDB ID.
465
     *
466
     * @param  int  $tvdbId  TVDB show ID
467
     * @param  int|string  $imdbId  IMDB ID (numeric, without 'tt' prefix)
468
     * @return array ['tmdb' => int, 'trakt' => int]
469
     */
470
    protected function lookupExternalIds(int $tvdbId, int|string $imdbId): array
471
    {
472
        $result = ['tmdb' => 0, 'trakt' => 0];
473
474
        try {
475
            // Try to get TMDB ID via TMDB's find endpoint
476
            $tmdbClient = app(\App\Services\TmdbClient::class);
477
            if ($tmdbClient->isConfigured()) {
478
                // Try TVDB ID first
479
                if ($tvdbId > 0) {
480
                    $tmdbIds = $tmdbClient->lookupTvShowIds($tvdbId, 'tvdb');
481
                    if ($tmdbIds !== null) {
482
                        $result['tmdb'] = $tmdbIds['tmdb'] ?? 0;
483
                    }
484
                }
485
                // Try IMDB ID if TMDB not found
486
                if ($result['tmdb'] === 0 && ! empty($imdbId) && $imdbId > 0) {
487
                    $tmdbIds = $tmdbClient->lookupTvShowIds($imdbId, 'imdb');
488
                    if ($tmdbIds !== null) {
489
                        $result['tmdb'] = $tmdbIds['tmdb'] ?? 0;
490
                    }
491
                }
492
            }
493
494
            // Try to get Trakt ID via Trakt's search endpoint
495
            $traktService = app(\App\Services\TraktService::class);
496
            if ($traktService->isConfigured()) {
497
                // Try TVDB ID first
498
                if ($tvdbId > 0) {
499
                    $traktIds = $traktService->lookupShowIds($tvdbId, 'tvdb');
500
                    if ($traktIds !== null && ! empty($traktIds['trakt'])) {
501
                        $result['trakt'] = (int) $traktIds['trakt'];
502
                        // Also get TMDB if we didn't find it above
503
                        if ($result['tmdb'] === 0 && ! empty($traktIds['tmdb'])) {
504
                            $result['tmdb'] = (int) $traktIds['tmdb'];
505
                        }
506
                        return $result;
507
                    }
508
                }
509
510
                // Try IMDB ID as fallback
511
                if (! empty($imdbId) && $imdbId > 0) {
512
                    $traktIds = $traktService->lookupShowIds($imdbId, 'imdb');
513
                    if ($traktIds !== null && ! empty($traktIds['trakt'])) {
514
                        $result['trakt'] = (int) $traktIds['trakt'];
515
                        if ($result['tmdb'] === 0 && ! empty($traktIds['tmdb'])) {
516
                            $result['tmdb'] = (int) $traktIds['tmdb'];
517
                        }
518
                    }
519
                }
520
            }
521
        } catch (\Throwable $e) {
522
            // Silently fail - external ID lookup is optional enrichment
523
        }
524
525
        return $result;
526
    }
527
528
    /**
529
     * Assigns API episode response values to a formatted array for insertion
530
     * Returns the formatted array.
531
     */
532
    public function formatEpisodeInfo($episode): array
533
    {
534
        return [
535
            'title' => (string) $episode->name,
536
            'series' => (int) $episode->season,
537
            'episode' => (int) $episode->number,
538
            'se_complete' => 'S'.sprintf('%02d', $episode->season).'E'.sprintf('%02d', $episode->number),
539
            'firstaired' => (string) $episode->airdate,
540
            'summary' => (string) $episode->summary,
541
        ];
542
    }
543
}
544
545