Files
newznab-tmux/app/Services/TvProcessing/TvShowAdder.php
T
2026-05-30 23:31:00 +02:00

518 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TvProcessing;
use App\Models\Video;
use App\Services\TmdbClient;
use App\Services\TraktService;
use App\Services\TvProcessing\Providers\AbstractTvProvider;
use App\Services\TvProcessing\Providers\TmdbProvider;
use App\Services\TvProcessing\Providers\TraktProvider;
use App\Services\TvProcessing\Providers\TvdbProvider;
use App\Services\TvProcessing\Providers\TvMazeProvider;
use DariusIII\TVMaze\TVMaze as TVMazeClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use RuntimeException;
/**
* Resolve a TV show by external ID (tvdb, tvmaze, tmdb, trakt, imdb) and
* persist it into the local videos / tv_info / videos_aliases tables.
*
* Each branch reuses the existing provider's formatShowInfo() + add() so the
* data shape, dedupe, and observer-driven search-index inserts stay consistent
* with the rest of the TV processing pipeline.
*/
class TvShowAdder
{
public const SUPPORTED_SOURCES = ['tvdb', 'tvmaze', 'tmdb', 'trakt', 'imdb'];
/**
* Resolve + persist. Returns metadata about the operation.
*
* @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string}
*
* @throws InvalidArgumentException when the source is unsupported.
* @throws RuntimeException when no provider could resolve the id or the
* required provider is not configured.
*/
public function add(string $source, string $id, int $type = 0): array
{
$source = strtolower(trim($source));
$id = $this->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<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed> $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;
}
}