normalizeId($source, $id); if (! in_array($source, self::SUPPORTED_SOURCES, true)) { throw new InvalidArgumentException("Unsupported external source: {$source}"); } // Short-circuit if we already have this video by external id. $column = $this->videoColumnForSource($source); $existing = Video::query()->where($column, $id)->first(['id', 'title']); if ($existing !== null) { return [ 'videoId' => (int) $existing->id, 'existed' => true, 'source' => $source, 'externalId' => (string) $id, 'title' => (string) $existing->title, ]; } return DB::transaction(function () use ($source, $id, $type) { return $this->dispatch($source, $id, $type); }); } /** * Dry-run resolve. Returns the formatted show array (without persisting) * for preview UIs. Returns null when nothing was found. * * @return array|null */ public function preview(string $source, string $id): ?array { $source = strtolower(trim($source)); $id = $this->normalizeId($source, $id); switch ($source) { case 'tvdb': $provider = new TvdbProvider; $show = $this->fetchTvdb($provider, (int) $id); return $show ? $provider->formatShowInfo($show) : null; case 'tvmaze': $provider = new TvMazeProvider; $show = $this->fetchTvMaze((int) $id, 'tvmaze'); return $show ? $provider->formatShowInfo($show) : null; case 'tmdb': $provider = new TmdbProvider; $show = $this->fetchTmdb((int) $id); return $show ? $provider->formatShowInfo($show) : null; case 'trakt': $provider = new TraktProvider; $show = $this->fetchTrakt($id); return $show ? $provider->formatShowInfo($show) : null; case 'imdb': return $this->previewImdb($id); } return null; } /** * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function dispatch(string $source, string $id, int $type): array { switch ($source) { case 'tvdb': return $this->addViaTvdb((int) $id, $type); case 'tvmaze': return $this->addViaTvMaze((int) $id, $type); case 'tmdb': return $this->addViaTmdb((int) $id, $type); case 'trakt': return $this->addViaTrakt($id, $type); case 'imdb': return $this->addViaImdb($id, $type); } throw new InvalidArgumentException("Unsupported external source: {$source}"); } // ------------------------------------------------------------------------- // TVDB // ------------------------------------------------------------------------- private function fetchTvdb(TvdbProvider $provider, int $tvdbId): ?object { try { $extended = $provider->client->series()->extended($tvdbId); } catch (\Throwable $e) { Log::warning('TvShowAdder: TVDB lookup failed', ['id' => $tvdbId, 'error' => $e->getMessage()]); return null; } // TvdbProvider::formatShowInfo() expects fields shaped like SearchResult // (tvdb_id, name, overview, first_air_time, aliases). The extended // endpoint returns SeriesExtendedRecord (camelCase). Adapt it. $aliases = []; if (! empty($extended->aliases) && is_array($extended->aliases)) { foreach ($extended->aliases as $alias) { if (is_object($alias) && isset($alias->name)) { $aliases[] = (string) $alias->name; } elseif (is_string($alias)) { $aliases[] = $alias; } } } return (object) [ 'tvdb_id' => (string) ($extended->id ?? $tvdbId), 'name' => (string) ($extended->name ?? ''), 'overview' => (string) ($extended->overview ?? ''), 'first_air_time' => (string) ($extended->firstAired ?? ''), 'aliases' => $aliases, ]; } /** * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function addViaTvdb(int $tvdbId, int $type): array { $provider = new TvdbProvider; $show = $this->fetchTvdb($provider, $tvdbId); if ($show === null) { throw new RuntimeException("No TVDB show found for ID {$tvdbId}."); } $data = $provider->formatShowInfo($show); $data['type'] = $type; return $this->persist($provider, $data, 'tvdb', (string) $tvdbId); } // ------------------------------------------------------------------------- // TVMaze // ------------------------------------------------------------------------- /** * @return array|null */ private function fetchTvMaze(int|string $id, string $kind): ?array { try { $client = new TVMazeClient; if ($kind === 'tvmaze') { $show = $client->getShowByShowID((int) $id); } else { // 'tvrage' | 'thetvdb' | 'imdb' $show = $client->getShowBySiteID($kind, $id); } } catch (\Throwable $e) { Log::warning('TvShowAdder: TVMaze lookup failed', ['id' => $id, 'kind' => $kind, 'error' => $e->getMessage()]); return null; } return is_array($show) ? $show : null; } /** * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function addViaTvMaze(int $tvmazeId, int $type): array { $provider = new TvMazeProvider; $show = $this->fetchTvMaze($tvmazeId, 'tvmaze'); if ($show === null) { throw new RuntimeException("No TVMaze show found for ID {$tvmazeId}."); } $data = $provider->formatShowInfo($show); $data['type'] = $type; return $this->persist($provider, $data, 'tvmaze', (string) $tvmazeId); } // ------------------------------------------------------------------------- // TMDB // ------------------------------------------------------------------------- /** * @return array|null */ private function fetchTmdb(int $tmdbId): ?array { $client = app(TmdbClient::class); if (! $client->isConfigured()) { throw new RuntimeException('TMDB API is not configured.'); } $show = $client->getTvShow($tmdbId, ['external_ids', 'alternative_titles']); if ($show === null) { return null; } // Mirror the enrichment that TmdbProvider::matchShowInfo() does so // formatShowInfo() has the keys it expects. $alternativeTitles = []; $altRoot = TmdbClient::getArray($show, 'alternative_titles'); $results = TmdbClient::getArray($altRoot, 'results'); foreach ($results as $aka) { if (is_array($aka) && isset($aka['title'])) { $alternativeTitles[] = $aka['title']; } } $show['alternative_titles'] = $alternativeTitles; $networks = array_values(TmdbClient::getArray($show, 'networks')); $firstNetwork = $networks[0] ?? null; $show['network'] = is_array($firstNetwork) && ! empty($firstNetwork['name']) ? (string) $firstNetwork['name'] : ''; return $show; } /** * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function addViaTmdb(int $tmdbId, int $type): array { $provider = new TmdbProvider; $show = $this->fetchTmdb($tmdbId); if ($show === null) { throw new RuntimeException("No TMDB show found for ID {$tmdbId}."); } $data = $provider->formatShowInfo($show); $data['type'] = $type; return $this->persist($provider, $data, 'tmdb', (string) $tmdbId); } // ------------------------------------------------------------------------- // Trakt // ------------------------------------------------------------------------- /** * @return array|null */ private function fetchTrakt(int|string $id): ?array { $client = app(TraktService::class); if (! $client->isConfigured()) { throw new RuntimeException('Trakt API is not configured.'); } $show = $client->getShowSummary((string) $id, 'full'); return is_array($show) && ! empty($show['ids']) ? $show : null; } /** * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function addViaTrakt(int|string $traktId, int $type): array { $provider = new TraktProvider; $show = $this->fetchTrakt($traktId); if ($show === null) { throw new RuntimeException("No Trakt show found for ID {$traktId}."); } $data = $provider->formatShowInfo($show); $data['type'] = $type; return $this->persist($provider, $data, 'trakt', (string) $traktId); } // ------------------------------------------------------------------------- // IMDB (fallback chain: TMDB → Trakt → TVMaze) // ------------------------------------------------------------------------- /** * @return array|null */ private function previewImdb(string $imdbId): ?array { // Try TMDB first try { $client = app(TmdbClient::class); if ($client->isConfigured()) { $found = $client->findTvByExternalId('tt'.$imdbId, 'imdb_id'); if ($found !== null) { $tmdbId = TmdbClient::getInt($found, 'id'); if ($tmdbId > 0) { $provider = new TmdbProvider; $show = $this->fetchTmdb($tmdbId); if ($show !== null) { return $provider->formatShowInfo($show); } } } } } catch (\Throwable $e) { Log::info('TvShowAdder: IMDB→TMDB preview failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); } // Then Trakt try { $trakt = app(TraktService::class); if ($trakt->isConfigured()) { $results = $trakt->searchById($imdbId, 'imdb', 'show'); $resultRows = is_array($results) ? array_values($results) : []; $firstResult = $resultRows[0] ?? null; if (is_array($firstResult) && ! empty($firstResult['show']['ids']['trakt'])) { $traktId = (int) $firstResult['show']['ids']['trakt']; $provider = new TraktProvider; $show = $this->fetchTrakt($traktId); if ($show !== null) { return $provider->formatShowInfo($show); } } } } catch (\Throwable $e) { Log::info('TvShowAdder: IMDB→Trakt preview failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); } // Then TVMaze try { $provider = new TvMazeProvider; $show = $this->fetchTvMaze('tt'.$imdbId, 'imdb'); if ($show !== null) { return $provider->formatShowInfo($show); } } catch (\Throwable $e) { Log::info('TvShowAdder: IMDB→TVMaze preview failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); } return null; } /** * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function addViaImdb(string $imdbId, int $type): array { // TMDB try { $client = app(TmdbClient::class); if ($client->isConfigured()) { $found = $client->findTvByExternalId('tt'.$imdbId, 'imdb_id'); if ($found !== null) { $tmdbId = TmdbClient::getInt($found, 'id'); if ($tmdbId > 0) { return $this->addViaTmdb($tmdbId, $type); } } } } catch (\Throwable $e) { Log::info('TvShowAdder: IMDB→TMDB add failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); } // Trakt try { $trakt = app(TraktService::class); if ($trakt->isConfigured()) { $results = $trakt->searchById($imdbId, 'imdb', 'show'); $resultRows = is_array($results) ? array_values($results) : []; $firstResult = $resultRows[0] ?? null; if (is_array($firstResult) && ! empty($firstResult['show']['ids']['trakt'])) { return $this->addViaTrakt((int) $firstResult['show']['ids']['trakt'], $type); } } } catch (\Throwable $e) { Log::info('TvShowAdder: IMDB→Trakt add failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); } // TVMaze (last resort, no extra API key required) $provider = new TvMazeProvider; $show = $this->fetchTvMaze('tt'.$imdbId, 'imdb'); if ($show !== null) { $data = $provider->formatShowInfo($show); $data['type'] = $type; return $this->persist($provider, $data, 'imdb', $imdbId); } throw new RuntimeException("No show found for IMDB ID tt{$imdbId} via TMDB, Trakt, or TVMaze."); } // ------------------------------------------------------------------------- // Persistence helper // ------------------------------------------------------------------------- /** * @param array $data * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} */ private function persist(AbstractTvProvider $provider, array $data, string $source, string $externalId): array { // Ensure required keys formatShowInfo callers might omit $data += [ 'country' => '', 'aliases' => '', 'localzone' => "''", 'imdb' => '', 'tvdb' => 0, 'trakt' => 0, 'tvrage' => 0, 'tvmaze' => 0, 'tmdb' => 0, 'summary' => '', 'publisher' => '', 'started' => null, 'type' => 0, 'source' => 0, 'title' => '', ]; if (empty($data['title'])) { throw new RuntimeException('Provider returned a show without a title.'); } $videoId = $provider->add($data); if ($videoId > 0) { try { $provider->getPoster($videoId); } catch (\Throwable $e) { Log::info('TvShowAdder: poster fetch failed', ['videoId' => $videoId, 'error' => $e->getMessage()]); } } return [ 'videoId' => (int) $videoId, 'existed' => false, 'source' => $source, 'externalId' => $externalId, 'title' => (string) $data['title'], ]; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private function videoColumnForSource(string $source): string { return match ($source) { 'tvdb' => 'tvdb', 'tvmaze' => 'tvmaze', 'tmdb' => 'tmdb', 'trakt' => 'trakt', 'imdb' => 'imdb', default => throw new InvalidArgumentException("Unsupported source: {$source}"), }; } private function normalizeId(string $source, string $id): string { $id = trim($id); if ($source === 'imdb') { // Accept "tt0944947" or "0944947" — store/lookup as the numeric portion. if (preg_match('/^tt?(\d{5,10})$/i', $id, $m)) { return $m[1]; } if (ctype_digit($id)) { return $id; } throw new InvalidArgumentException('IMDB ID must look like "tt1234567" or its numeric part.'); } if (! ctype_digit($id) || (int) $id <= 0) { throw new InvalidArgumentException(strtoupper($source).' ID must be a positive integer.'); } return $id; } }