1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Blacklight\processing\tv; |
4
|
|
|
|
5
|
|
|
use Blacklight\ColorCLI; |
6
|
|
|
use Adrenth\Thetvdb\Client; |
7
|
|
|
use Blacklight\ReleaseImage; |
8
|
|
|
use Adrenth\Thetvdb\Exception\UnauthorizedException; |
9
|
|
|
use Adrenth\Thetvdb\Exception\CouldNotLoginException; |
10
|
|
|
use Adrenth\Thetvdb\Exception\RequestFailedException; |
11
|
|
|
use Adrenth\Thetvdb\Exception\InvalidArgumentException; |
12
|
|
|
use Adrenth\Thetvdb\Exception\InvalidJsonInResponseException; |
13
|
|
|
|
14
|
|
|
/** |
15
|
|
|
* Class TVDB -- functions used to post process releases against TVDB. |
16
|
|
|
*/ |
17
|
|
|
class TVDB extends TV |
18
|
|
|
{ |
19
|
|
|
private const TVDB_URL = 'https://api.thetvdb.com'; |
20
|
|
|
private const TVDB_API_KEY = '31740C28BAC74DEF'; |
21
|
|
|
private const MATCH_PROBABILITY = 75; |
22
|
|
|
|
23
|
|
|
/** |
24
|
|
|
* @var \Adrenth\Thetvdb\Client |
25
|
|
|
*/ |
26
|
|
|
public $client; |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* @var string Authorization token for TVDB v2 API |
30
|
|
|
*/ |
31
|
|
|
public $token; |
32
|
|
|
|
33
|
|
|
/** |
34
|
|
|
* @string URL for show poster art |
35
|
|
|
*/ |
36
|
|
|
public $posterUrl; |
37
|
|
|
|
38
|
|
|
/** |
39
|
|
|
* @var string URL for show fanart |
40
|
|
|
*/ |
41
|
|
|
public $fanartUrl; |
42
|
|
|
|
43
|
|
|
/** |
44
|
|
|
* @bool Do a local lookup only if server is down |
45
|
|
|
*/ |
46
|
|
|
private $local; |
47
|
|
|
|
48
|
|
|
/** |
49
|
|
|
* TVDB constructor. |
50
|
|
|
* |
51
|
|
|
* @param array $options |
52
|
|
|
* @throws \Exception |
53
|
|
|
*/ |
54
|
|
|
public function __construct(array $options = []) |
55
|
|
|
{ |
56
|
|
|
parent::__construct($options); |
57
|
|
|
$this->client = new Client(); |
58
|
|
|
$this->client->setLanguage('en'); |
59
|
|
|
$this->posterUrl = self::TVDB_URL.DS.'graphical/%s-g.jpg'; |
|
|
|
|
60
|
|
|
$this->fanartUrl = self::TVDB_URL.DS.'_cache/fanart/original/%s-3.jpg'; |
61
|
|
|
$this->local = false; |
62
|
|
|
|
63
|
|
|
// Check if we can get the time for API status |
64
|
|
|
// If we can't then we set local to true |
65
|
|
|
try { |
66
|
|
|
$this->token = $this->client->authentication()->login(self::TVDB_API_KEY); |
67
|
|
|
} catch (CouldNotLoginException $error) { |
68
|
|
|
echo ColorCLI::warning('Could not reach TVDB API. Running in local mode only!'); |
69
|
|
|
$this->local = true; |
70
|
|
|
} catch (UnauthorizedException $error) { |
71
|
|
|
echo ColorCLI::warning('Bad response from TVDB API. Running in local mode only!'); |
72
|
|
|
$this->local = true; |
73
|
|
|
} |
74
|
|
|
|
75
|
|
|
if (\strlen($this->token) > 0) { |
76
|
|
|
$this->client->setToken($this->token); |
77
|
|
|
} |
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
/** |
81
|
|
|
* Main processing director function for scrapers |
82
|
|
|
* Calls work query function and initiates processing. |
83
|
|
|
* |
84
|
|
|
* @param $groupID |
85
|
|
|
* @param $guidChar |
86
|
|
|
* @param $process |
87
|
|
|
* @param bool $local |
88
|
|
|
*/ |
89
|
|
|
public function processSite($groupID, $guidChar, $process, $local = false): void |
90
|
|
|
{ |
91
|
|
|
$res = $this->getTvReleases($groupID, $guidChar, $process, parent::PROCESS_TVDB); |
92
|
|
|
|
93
|
|
|
$tvCount = \count($res); |
|
|
|
|
94
|
|
|
|
95
|
|
|
if ($this->echooutput && $tvCount > 0) { |
96
|
|
|
echo ColorCLI::header('Processing TVDB lookup for '.number_format($tvCount).' release(s).'); |
97
|
|
|
} |
98
|
|
|
|
99
|
|
|
if ($res instanceof \Traversable) { |
|
|
|
|
100
|
|
|
$this->titleCache = []; |
101
|
|
|
|
102
|
|
|
foreach ($res as $row) { |
103
|
|
|
$tvDbId = false; |
104
|
|
|
|
105
|
|
|
// Clean the show name for better match probability |
106
|
|
|
$release = $this->parseInfo($row['searchname']); |
107
|
|
|
if (\is_array($release) && $release['name'] !== '') { |
108
|
|
|
if (\in_array($release['cleanname'], $this->titleCache, false)) { |
109
|
|
|
if ($this->echooutput) { |
110
|
|
|
echo ColorCLI::headerOver('Title: '). |
111
|
|
|
ColorCLI::warningOver($release['cleanname']). |
112
|
|
|
ColorCLI::header(' already failed lookup for this site. Skipping.'); |
113
|
|
|
} |
114
|
|
|
$this->setVideoNotFound(parent::PROCESS_TVMAZE, $row['id']); |
115
|
|
|
continue; |
116
|
|
|
} |
117
|
|
|
|
118
|
|
|
// Find the Video ID if it already exists by checking the title. |
119
|
|
|
$videoId = $this->getByTitle($release['cleanname'], parent::TYPE_TV); |
120
|
|
|
|
121
|
|
|
if ($videoId !== false) { |
122
|
|
|
$tvDbId = $this->getSiteByID('tvdb', $videoId); |
123
|
|
|
} |
124
|
|
|
|
125
|
|
|
// Force local lookup only |
126
|
|
|
$lookupSetting = true; |
127
|
|
|
if ($local === true || $this->local === true) { |
128
|
|
|
$lookupSetting = false; |
129
|
|
|
} |
130
|
|
|
|
131
|
|
|
if ($tvDbId === false && $lookupSetting) { |
132
|
|
|
|
133
|
|
|
// If it doesnt exist locally and lookups are allowed lets try to get it. |
134
|
|
|
if ($this->echooutput) { |
135
|
|
|
echo ColorCLI::primaryOver('Video ID for '). |
136
|
|
|
ColorCLI::headerOver($release['cleanname']). |
137
|
|
|
ColorCLI::primary(' not found in local db, checking web.'); |
138
|
|
|
} |
139
|
|
|
|
140
|
|
|
// Check if we have a valid country and set it in the array |
141
|
|
|
$country = ( |
142
|
|
|
isset($release['country']) && \strlen($release['country']) === 2 |
143
|
|
|
? (string) $release['country'] |
144
|
|
|
: '' |
145
|
|
|
); |
146
|
|
|
|
147
|
|
|
// Get the show from TVDB |
148
|
|
|
$tvdbShow = $this->getShowInfo((string) $release['cleanname'], $country); |
149
|
|
|
|
150
|
|
|
if (\is_array($tvdbShow)) { |
151
|
|
|
$tvdbShow['country'] = $country; |
152
|
|
|
$videoId = $this->add($tvdbShow); |
153
|
|
|
$tvDbId = (int) $tvdbShow['tvdb']; |
154
|
|
|
} |
155
|
|
|
} elseif ($this->echooutput && $tvDbId !== false) { |
156
|
|
|
echo ColorCLI::primaryOver('Video ID for '). |
157
|
|
|
ColorCLI::headerOver($release['cleanname']). |
158
|
|
|
ColorCLI::primary(' found in local db, attempting episode match.'); |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
if (is_numeric($videoId) && $videoId > 0 && is_numeric($tvDbId) && $tvDbId > 0) { |
162
|
|
|
// Now that we have valid video and tvdb ids, try to get the poster |
163
|
|
|
$this->getPoster($videoId, $tvDbId); |
164
|
|
|
|
165
|
|
|
$seasonNo = (! empty($release['season']) ? preg_replace('/^S0*/i', '', $release['season']) : ''); |
166
|
|
|
$episodeNo = (! empty($release['episode']) ? preg_replace('/^E0*/i', '', $release['episode']) : ''); |
167
|
|
|
|
168
|
|
|
if ($episodeNo === 'all') { |
169
|
|
|
// Set the video ID and leave episode 0 |
170
|
|
|
$this->setVideoIdFound($videoId, $row['id'], 0); |
171
|
|
|
echo ColorCLI::primary('Found TVDB Match for Full Season!'); |
172
|
|
|
continue; |
173
|
|
|
} |
174
|
|
|
|
175
|
|
|
// Download all episodes if new show to reduce API/bandwidth usage |
176
|
|
|
if ($this->countEpsByVideoID($videoId) === false) { |
177
|
|
|
$this->getEpisodeInfo($tvDbId, -1, -1, '', $videoId); |
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
// Check if we have the episode for this video ID |
181
|
|
|
$episode = $this->getBySeasonEp($videoId, $seasonNo, $episodeNo, $release['airdate']); |
182
|
|
|
|
183
|
|
|
if ($episode === false && $lookupSetting) { |
184
|
|
|
// Send the request for the episode to TVDB |
185
|
|
|
$tvdbEpisode = $this->getEpisodeInfo( |
186
|
|
|
$tvDbId, |
187
|
|
|
$seasonNo, |
188
|
|
|
$episodeNo, |
189
|
|
|
$release['airdate'] |
190
|
|
|
); |
191
|
|
|
|
192
|
|
|
if ($tvdbEpisode) { |
193
|
|
|
$episode = $this->addEpisode($videoId, $tvdbEpisode); |
194
|
|
|
} |
195
|
|
|
} |
196
|
|
|
|
197
|
|
|
if ($episode !== false && is_numeric($episode) && $episode > 0) { |
198
|
|
|
// Mark the releases video and episode IDs |
199
|
|
|
$this->setVideoIdFound($videoId, $row['id'], $episode); |
200
|
|
|
if ($this->echooutput) { |
201
|
|
|
echo ColorCLI::primary('Found TVDB Match!'); |
202
|
|
|
} |
203
|
|
|
} else { |
204
|
|
|
//Processing failed, set the episode ID to the next processing group |
205
|
|
|
$this->setVideoNotFound(parent::PROCESS_TVMAZE, $row['id']); |
206
|
|
|
} |
207
|
|
|
} else { |
208
|
|
|
//Processing failed, set the episode ID to the next processing group |
209
|
|
|
$this->setVideoNotFound(parent::PROCESS_TVMAZE, $row['id']); |
210
|
|
|
$this->titleCache[] = $release['cleanname']; |
211
|
|
|
} |
212
|
|
|
} else { |
213
|
|
|
//Parsing failed, take it out of the queue for examination |
214
|
|
|
$this->setVideoNotFound(parent::FAILED_PARSE, $row['id']); |
215
|
|
|
$this->titleCache[] = $release['cleanname']; |
216
|
|
|
} |
217
|
|
|
} |
218
|
|
|
} |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
/** |
222
|
|
|
* Placeholder for Videos getBanner. |
223
|
|
|
* |
224
|
|
|
* @param $videoID |
225
|
|
|
* @param $siteId |
226
|
|
|
* |
227
|
|
|
* @return bool |
228
|
|
|
*/ |
229
|
|
|
protected function getBanner($videoID, $siteId): bool |
230
|
|
|
{ |
231
|
|
|
return false; |
232
|
|
|
} |
233
|
|
|
|
234
|
|
|
/** |
235
|
|
|
* Calls the API to perform initial show name match to TVDB title |
236
|
|
|
* Returns a formatted array of show data or false if no match. |
237
|
|
|
* |
238
|
|
|
* @param string $cleanName |
239
|
|
|
* |
240
|
|
|
* @param string $country |
241
|
|
|
* |
242
|
|
|
* @return array|false |
243
|
|
|
*/ |
244
|
|
|
protected function getShowInfo($cleanName, $country = '') |
245
|
|
|
{ |
246
|
|
|
$return = $response = false; |
|
|
|
|
247
|
|
|
$highestMatch = 0; |
248
|
|
|
try { |
249
|
|
|
$response = $this->client->search()->seriesByName($cleanName); |
250
|
|
|
} catch (InvalidArgumentException $error) { |
251
|
|
|
return false; |
252
|
|
|
} catch (InvalidJsonInResponseException $error) { |
253
|
|
|
if (strpos($error->getMessage(), 'Could not decode JSON data') === 0 || strpos($error->getMessage(), 'Incorrect data structure') === 0) { |
254
|
|
|
return false; |
255
|
|
|
} |
256
|
|
|
} catch (RequestFailedException $error) { |
257
|
|
|
return false; |
258
|
|
|
} catch (UnauthorizedException $error) { |
259
|
|
|
if (strpos($error->getMessage(), 'Unauthorized') === 0) { |
260
|
|
|
return false; |
261
|
|
|
} |
262
|
|
|
} |
263
|
|
|
|
264
|
|
|
if ($response === false && $country !== '') { |
|
|
|
|
265
|
|
|
try { |
266
|
|
|
$response = $this->client->search()->seriesByName(rtrim(str_replace($country, '', $cleanName))); |
267
|
|
|
} catch (InvalidArgumentException $error) { |
268
|
|
|
return false; |
269
|
|
|
} catch (InvalidJsonInResponseException $error) { |
270
|
|
|
if (strpos($error->getMessage(), 'Could not decode JSON data') === 0 || strpos($error->getMessage(), 'Incorrect data structure') === 0) { |
271
|
|
|
return false; |
272
|
|
|
} |
273
|
|
|
} catch (RequestFailedException $error) { |
274
|
|
|
return false; |
275
|
|
|
} catch (UnauthorizedException $error) { |
276
|
|
|
if (strpos($error->getMessage(), 'Unauthorized') === 0) { |
277
|
|
|
return false; |
278
|
|
|
} |
279
|
|
|
} |
280
|
|
|
} |
281
|
|
|
|
282
|
|
|
sleep(1); |
283
|
|
|
|
284
|
|
|
if (\is_array($response)) { |
|
|
|
|
285
|
|
|
foreach ($response->getData() as $show) { |
286
|
|
|
if ($this->checkRequiredAttr($show, 'tvdbS')) { |
287
|
|
|
// Check for exact title match first and then terminate if found |
288
|
|
|
if (strtolower($show->getSeriesName()) === strtolower($cleanName)) { |
289
|
|
|
$highest = $show; |
290
|
|
|
break; |
291
|
|
|
} |
292
|
|
|
|
293
|
|
|
// Check each show title for similarity and then find the highest similar value |
294
|
|
|
$matchPercent = $this->checkMatch(strtolower($show->getSeriesName()), strtolower($cleanName), self::MATCH_PROBABILITY); |
295
|
|
|
|
296
|
|
|
// If new match has a higher percentage, set as new matched title |
297
|
|
|
if ($matchPercent > $highestMatch) { |
298
|
|
|
$highestMatch = $matchPercent; |
299
|
|
|
$highest = $show; |
300
|
|
|
} |
301
|
|
|
|
302
|
|
|
// Check for show aliases and try match those too |
303
|
|
|
if (! empty($show->getAliases())) { |
304
|
|
|
foreach ($show->getAliases() as $key => $name) { |
305
|
|
|
$matchPercent = $this->checkMatch(strtolower($name), strtolower($cleanName), $matchPercent); |
306
|
|
|
if ($matchPercent > $highestMatch) { |
307
|
|
|
$highestMatch = $matchPercent; |
308
|
|
|
$highest = $show; |
309
|
|
|
} |
310
|
|
|
} |
311
|
|
|
} |
312
|
|
|
} |
313
|
|
|
} |
314
|
|
|
if (! empty($highest)) { |
315
|
|
|
$return = $this->formatShowInfo($highest); |
316
|
|
|
} |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
return $return; |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
/** |
323
|
|
|
* Retrieves the poster art for the processed show. |
324
|
|
|
* |
325
|
|
|
* @param int $videoId -- the local Video ID |
326
|
|
|
* @param int $showId -- the TVDB ID |
327
|
|
|
* |
328
|
|
|
* @return int |
329
|
|
|
*/ |
330
|
|
|
public function getPoster($videoId, $showId): int |
331
|
|
|
{ |
332
|
|
|
$ri = new ReleaseImage(); |
333
|
|
|
|
334
|
|
|
// Try to get the Poster |
335
|
|
|
$hasCover = $ri->saveImage($videoId, sprintf($this->posterUrl, $showId), $this->imgSavePath); |
336
|
|
|
|
337
|
|
|
// Couldn't get poster, try fan art instead |
338
|
|
|
if ($hasCover !== 1) { |
339
|
|
|
$hasCover = $ri->saveImage($videoId, sprintf($this->fanartUrl, $showId), $this->imgSavePath); |
340
|
|
|
} |
341
|
|
|
// Mark it retrieved if we saved an image |
342
|
|
|
if ($hasCover === 1) { |
343
|
|
|
$this->setCoverFound($videoId); |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
return $hasCover; |
347
|
|
|
} |
348
|
|
|
|
349
|
|
|
/** |
350
|
|
|
* Gets the specific episode info for the parsed release after match |
351
|
|
|
* Returns a formatted array of episode data or false if no match. |
352
|
|
|
* |
353
|
|
|
* @param int $tvDbId |
354
|
|
|
* @param int $season |
355
|
|
|
* @param int $episode |
356
|
|
|
* @param string $airDate |
357
|
|
|
* @param int $videoId |
358
|
|
|
* |
359
|
|
|
* @return array|false |
360
|
|
|
*/ |
361
|
|
|
protected function getEpisodeInfo($tvDbId, $season, $episode, $airDate = '', $videoId = 0) |
362
|
|
|
{ |
363
|
|
|
$return = $response = false; |
364
|
|
|
|
365
|
|
|
if ($airDate !== '') { |
366
|
|
|
try { |
367
|
|
|
$response = $this->client->series()->getEpisodesWithQuery($tvDbId, ['firstAired' => $airDate]); |
368
|
|
|
} catch (InvalidArgumentException $error) { |
369
|
|
|
return false; |
370
|
|
|
} catch (InvalidJsonInResponseException $error) { |
371
|
|
|
if (strpos($error->getMessage(), 'Could not decode JSON data') === 0 || strpos($error->getMessage(), 'Incorrect data structure') === 0) { |
372
|
|
|
return false; |
373
|
|
|
} |
374
|
|
|
} catch (RequestFailedException $error) { |
375
|
|
|
return false; |
376
|
|
|
} catch (UnauthorizedException $error) { |
377
|
|
|
if (strpos($error->getMessage(), 'Unauthorized') === 0) { |
378
|
|
|
return false; |
379
|
|
|
} |
380
|
|
|
} |
381
|
|
|
} elseif ($videoId > 0) { |
382
|
|
|
try { |
383
|
|
|
$response = $this->client->series()->getEpisodes($tvDbId); |
384
|
|
|
} catch (InvalidArgumentException $error) { |
385
|
|
|
return false; |
386
|
|
|
} catch (InvalidJsonInResponseException $error) { |
387
|
|
|
if (strpos($error->getMessage(), 'Could not decode JSON data') === 0 || strpos($error->getMessage(), 'Incorrect data structure') === 0) { |
388
|
|
|
return false; |
389
|
|
|
} |
390
|
|
|
} catch (RequestFailedException $error) { |
391
|
|
|
return false; |
392
|
|
|
} catch (UnauthorizedException $error) { |
393
|
|
|
if (strpos($error->getMessage(), 'Unauthorized') === 0) { |
394
|
|
|
return false; |
395
|
|
|
} |
396
|
|
|
} |
397
|
|
|
} else { |
398
|
|
|
try { |
399
|
|
|
$response = $this->client->series()->getEpisodesWithQuery($tvDbId, ['airedSeason' => $season, 'airedEpisode' => $episode]); |
400
|
|
|
} catch (InvalidArgumentException $error) { |
401
|
|
|
return false; |
402
|
|
|
} catch (InvalidJsonInResponseException $error) { |
403
|
|
|
if (strpos($error->getMessage(), 'Could not decode JSON data') === 0 || strpos($error->getMessage(), 'Incorrect data structure') === 0) { |
404
|
|
|
return false; |
405
|
|
|
} |
406
|
|
|
} catch (RequestFailedException $error) { |
407
|
|
|
return false; |
408
|
|
|
} catch (UnauthorizedException $error) { |
409
|
|
|
if (strpos($error->getMessage(), 'Unauthorized') === 0) { |
410
|
|
|
return false; |
411
|
|
|
} |
412
|
|
|
} |
413
|
|
|
} |
414
|
|
|
|
415
|
|
|
sleep(1); |
416
|
|
|
|
417
|
|
|
if (\is_object($response->getData())) { |
418
|
|
|
if ($this->checkRequiredAttr($response->getData(), 'tvdbE')) { |
|
|
|
|
419
|
|
|
$return = $this->formatEpisodeInfo($response); |
420
|
|
|
} |
421
|
|
|
} elseif ($videoId > 0 && \is_array($response->getData())) { |
422
|
|
|
foreach ($response->getData() as $singleEpisode) { |
423
|
|
|
if ($this->checkRequiredAttr($singleEpisode, 'tvdbE')) { |
424
|
|
|
$this->addEpisode($videoId, $this->formatEpisodeInfo($singleEpisode)); |
425
|
|
|
} |
426
|
|
|
} |
427
|
|
|
} |
428
|
|
|
|
429
|
|
|
return $return; |
430
|
|
|
} |
431
|
|
|
|
432
|
|
|
/** |
433
|
|
|
* Assigns API show response values to a formatted array for insertion |
434
|
|
|
* Returns the formatted array. |
435
|
|
|
* |
436
|
|
|
* @param $show |
437
|
|
|
* |
438
|
|
|
* @return array |
439
|
|
|
*/ |
440
|
|
|
protected function formatShowInfo($show): array |
441
|
|
|
{ |
442
|
|
|
preg_match('/tt(?P<imdbid>\d{6,7})$/i', $show->imdbId, $imdb); |
443
|
|
|
|
444
|
|
|
return [ |
445
|
|
|
'type' => parent::TYPE_TV, |
446
|
|
|
'title' => (string) $show->getSeriesName(), |
447
|
|
|
'summary' => (string) $show->getOverview(), |
448
|
|
|
'started' => $show->firstAired->format('Y-m-d'), |
449
|
|
|
'publisher' => (string) $show->getNetwork(), |
450
|
|
|
'source' => parent::SOURCE_TVDB, |
451
|
|
|
'imdb' => (int) ($imdb['imdbid'] ?? 0), |
452
|
|
|
'tvdb' => (int) $show->getid(), |
453
|
|
|
'trakt' => 0, |
454
|
|
|
'tvrage' => 0, |
455
|
|
|
'tvmaze' => 0, |
456
|
|
|
'tmdb' => 0, |
457
|
|
|
'aliases' => ! empty($show->getAliases()) ? $show->getAliases() : '', |
458
|
|
|
'localzone' => "''", |
459
|
|
|
]; |
460
|
|
|
} |
461
|
|
|
|
462
|
|
|
/** |
463
|
|
|
* Assigns API episode response values to a formatted array for insertion |
464
|
|
|
* Returns the formatted array. |
465
|
|
|
* |
466
|
|
|
* @param $episode |
467
|
|
|
* |
468
|
|
|
* @return array |
469
|
|
|
*/ |
470
|
|
|
protected function formatEpisodeInfo($episode): array |
471
|
|
|
{ |
472
|
|
|
return [ |
473
|
|
|
'title' => (string) $episode->name, |
474
|
|
|
'series' => (int) $episode->season, |
475
|
|
|
'episode' => (int) $episode->number, |
476
|
|
|
'se_complete' => 'S'.sprintf('%02d', $episode->season).'E'.sprintf('%02d', $episode->number), |
477
|
|
|
'firstaired' => $episode->firstAired->format('Y-m-d'), |
478
|
|
|
'summary' => (string) $episode->overview, |
479
|
|
|
]; |
480
|
|
|
} |
481
|
|
|
} |
482
|
|
|
|