Files
newznab-tmux/app/Services/TvProcessing/Providers/TmdbProvider.php
T
2026-07-16 15:57:20 +02:00

613 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TvProcessing\Providers;
use App\Enums\ImageAssetProfile;
use App\Models\Video;
use App\Services\ReleaseImageService;
use App\Services\TmdbClient;
use App\Services\TraktService;
class TmdbProvider extends AbstractTvProvider
{
protected const MATCH_PROBABILITY = 75;
/**
* @string URL for show poster art
*/
public string $posterUrl = '';
/**
* Custom TMDB API client
*/
protected TmdbClient $tmdbClient;
/**
* Fetch banner from site.
*/
public function getBanner(mixed $videoId, mixed $siteId): bool
{
return false;
}
/**
* Main processing director function for TMDB
* Calls work query function and initiates processing.
*/
public function processSite(mixed $groupID, mixed $guidChar, mixed $process, bool $local = false): void
{
$res = $this->getTvReleases($groupID, $guidChar, $process, parent::PROCESS_TMDB);
$tvcount = \count($res);
$lookupSetting = true;
if ($tvcount === 0) {
return;
}
if ($res instanceof \Traversable) {
$this->titleCache = [];
$processed = 0;
$matched = 0;
$skipped = 0;
foreach ($res as $row) {
$processed++;
$siteId = false;
$this->posterUrl = '';
// Clean the show name for better match probability
$release = $this->parseInfo($row['searchname']);
if (is_array($release) && $release['name'] !== '') {
if (in_array($release['cleanname'], $this->titleCache, false)) {
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->alternateOver($this->truncateTitle($release['cleanname']));
cli()->primaryOver(' → ');
cli()->alternate('Skipped (previously failed)');
}
$this->setVideoNotFound(parent::PROCESS_TRAKT, $row['id']);
$skipped++;
continue;
}
// Find the Video ID if it already exists by checking the title against stored TMDB titles
$videoId = $this->getByTitle($release['cleanname'], parent::TYPE_TV, parent::SOURCE_TMDB);
// Force local lookup only
if ($local === true) {
$lookupSetting = false;
}
// If lookups are allowed lets try to get it.
if ($videoId === 0 && $lookupSetting) {
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->headerOver($this->truncateTitle($release['cleanname']));
cli()->primaryOver(' → ');
cli()->info('Searching TMDB...');
}
// Get the show from TMDB
$tmdbShow = $this->getShowInfo((string) $release['cleanname']);
if (is_array($tmdbShow)) {
// Check if we have the TMDB ID already, if we do use that Video ID
$dupeCheck = $this->getVideoIDFromSiteID('tvdb', (int) $tmdbShow['tvdb']);
if ($dupeCheck === false) {
$videoId = $this->add($tmdbShow);
$siteId = $tmdbShow['tmdb'];
} else {
$videoId = $dupeCheck;
// Update any missing fields and add site IDs
$this->update($videoId, $tmdbShow);
$siteId = $this->getSiteIDFromVideoID('tmdb', (int) $videoId);
}
}
} else {
if ($this->echooutput && $videoId > 0) {
cli()->primaryOver(' → ');
cli()->headerOver($this->truncateTitle($release['cleanname']));
cli()->primaryOver(' → ');
cli()->info('Found in DB');
}
$siteId = $this->getSiteIDFromVideoID('tmdb', (int) $videoId);
}
if (is_numeric($videoId) && $videoId > 0 && is_numeric($siteId) && $siteId > 0) {
// Now that we have valid video and tmdb ids, try to get the poster
$this->getPoster($videoId);
$seriesNo = preg_replace('/^S0*/i', '', (string) $release['season']);
$episodeNo = preg_replace('/^E0*/i', '', (string) $release['episode']);
$hasAirdate = ! empty($release['airdate']);
if ($episodeNo === 'all') {
// Set the video ID and leave episode 0
$this->setVideoIdFound($videoId, $row['id'], 0);
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->headerOver($this->truncateTitle($release['cleanname']));
cli()->primaryOver(' → ');
cli()->primary('Full Season matched');
}
$matched++;
continue;
}
// Download all episodes if new show to reduce API usage
if ($this->countEpsByVideoID($videoId) === false) {
$this->getEpisodeInfo($siteId, -1, -1, '', $videoId);
}
// Check if we have the episode for this video ID
$episode = $this->getBySeasonEp($videoId, $seriesNo, $episodeNo, $release['airdate']);
if ($episode === false) {
if ($seriesNo !== '' && $episodeNo !== '') {
// Send the request for the episode to TMDB with fallback to other IDs
$tmdbEpisode = $this->getEpisodeInfo(
$siteId,
$seriesNo,
$episodeNo,
$release['airdate'],
$videoId
);
if ($tmdbEpisode) {
$episode = $this->addEpisode($videoId, $tmdbEpisode);
}
}
if ($episode === false && $hasAirdate) {
// Refresh episode cache and attempt airdate match
$this->getEpisodeInfo($siteId, -1, -1, '', $videoId);
$episode = $this->getBySeasonEp($videoId, 0, 0, $release['airdate']);
}
}
if ($episode !== false && is_numeric($episode) && $episode > 0) {
// Mark the releases video and episode IDs
$this->setVideoIdFound($videoId, $row['id'], $episode);
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->headerOver($this->truncateTitle($release['cleanname']));
if ($seriesNo !== '' && $episodeNo !== '') {
cli()->primaryOver(' S');
cli()->warningOver(sprintf('%02d', $seriesNo));
cli()->primaryOver('E');
cli()->warningOver(sprintf('%02d', $episodeNo));
} elseif ($hasAirdate) {
cli()->primaryOver(' | ');
cli()->warningOver($release['airdate']);
}
cli()->primaryOver(' ✓ ');
cli()->primary('MATCHED (TMDB)');
}
$matched++;
} else {
// Processing failed, set the episode ID to the next processing group
$this->setVideoIdFound($videoId, $row['id'], 0);
$this->setVideoNotFound(parent::PROCESS_TRAKT, $row['id']);
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->alternateOver($this->truncateTitle($release['cleanname']));
if ($hasAirdate) {
cli()->primaryOver(' | ');
cli()->warningOver($release['airdate']);
}
cli()->primaryOver(' → ');
cli()->warning('Episode not found');
}
}
} else {
// Processing failed, set the episode ID to the next processing group
$this->setVideoNotFound(parent::PROCESS_TRAKT, $row['id']);
$this->titleCache[] = $release['cleanname'] ?? null;
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->alternateOver($this->truncateTitle($release['cleanname']));
cli()->primaryOver(' → ');
cli()->warning('Not found');
}
}
} else {
// Processing failed, set the episode ID to the next processing group
$this->setVideoNotFound(parent::PROCESS_TRAKT, $row['id']);
$this->titleCache[] = $release['cleanname'] ?? null;
if ($this->echooutput) {
cli()->primaryOver(' → ');
cli()->alternateOver(mb_substr($row['searchname'], 0, 50));
cli()->primaryOver(' → ');
cli()->error('Parse failed');
}
}
}
// Display summary
if ($this->echooutput && $matched > 0) {
echo "\n";
cli()->primaryOver(' ✓ TMDB: ');
cli()->primary(sprintf('%d matched, %d skipped', $matched, $skipped));
}
}
}
/**
* Truncate title for display purposes.
*/
protected function truncateTitle(string $title, int $maxLength = 45): string
{
if (mb_strlen($title) <= $maxLength) {
return $title;
}
return mb_substr($title, 0, $maxLength - 3).'...';
}
/**
* Calls the API to perform initial show name match to TMDB title
* Returns a formatted array of show data or false if no match.
*
* @return array<string, mixed>|false
*/
public function getShowInfo(string $name): bool|array
{
$return = false;
$this->tmdbClient = app(TmdbClient::class);
if (! $this->tmdbClient->isConfigured()) {
return false;
}
$response = $this->tmdbClient->searchTv($name);
sleep(1);
if ($response !== null && ! empty($response['results']) && is_array($response['results'])) {
$return = $this->matchShowInfo($response['results'], $name);
}
return $return;
}
/**
* @param array<string, mixed> $shows
* @return array<string, mixed>|false
*/
private function matchShowInfo(array $shows, string $cleanName): bool|array
{
$return = false;
$highestMatch = 0;
$highest = null;
foreach ($shows as $show) {
if (! is_array($show) || ! $this->checkRequiredAttr($show, 'tmdbS')) {
continue;
}
$showName = TmdbClient::getString($show, 'name');
if (empty($showName)) {
continue;
}
// Check for exact title match first and then terminate if found
if (strtolower($showName) === strtolower($cleanName)) {
$highest = $show;
break;
}
// Check each show title for similarity and then find the highest similar value
$matchPercent = $this->checkMatch(strtolower($showName), strtolower($cleanName), self::MATCH_PROBABILITY);
// If new match has a higher percentage, set as new matched title
if ($matchPercent > $highestMatch) {
$highestMatch = $matchPercent;
$highest = $show;
}
}
if ($highest !== null) {
$showId = TmdbClient::getInt($highest, 'id');
if ($showId === 0) {
return false;
}
$showAlternativeTitles = $this->tmdbClient->getTvAlternativeTitles($showId);
$showExternalIds = $this->tmdbClient->getTvExternalIds($showId);
if ($showAlternativeTitles === null || $showExternalIds === null) {
return false;
}
$alternativeTitles = [];
$results = TmdbClient::getArray($showAlternativeTitles, 'results');
foreach ($results as $aka) {
if (is_array($aka) && isset($aka['title'])) {
$alternativeTitles[] = $aka['title'];
}
}
$highest['alternative_titles'] = $alternativeTitles;
// Use available network info if present
$networks = TmdbClient::getArray($highest, 'networks');
$highest['network'] = ! empty($networks[0]['name']) ? $networks[0]['name'] : ''; // @phpstan-ignore offsetAccess.notFound
$highest['external_ids'] = $showExternalIds;
$return = $this->formatShowInfo($highest);
}
return $return;
}
/**
* Retrieves the poster art for the processed show.
*
* @param int $videoId -- the local Video ID
*/
public function getPoster(int $videoId): int
{
$ri = new ReleaseImageService;
$hascover = 0;
// Try to get the Poster
if (! empty($this->posterUrl)) {
$hascover = (int) $ri->saveRemoteImage(
(string) $videoId,
$this->posterUrl,
$this->imgSavePath,
ImageAssetProfile::Original,
)->success;
// Mark it retrieved if we saved an image
if ($hascover === 1) {
$this->setCoverFound($videoId);
}
}
return $hascover;
}
/**
* Get all external IDs for a video from the database.
*
* @param int $videoId The local video ID
* @return array<string, mixed> Array of external IDs: ['tmdb' => X, 'tvdb' => Y, 'imdb' => Z]
*/
protected function getAllSiteIdsFromVideoID(int $videoId): array
{
$result = Video::query()
->where('id', $videoId)
->first(['tmdb', 'tvdb', 'imdb']);
if ($result === null) {
return ['tmdb' => 0, 'tvdb' => 0, 'imdb' => ''];
}
return [
'tmdb' => (int) ($result->tmdb ?? 0),
'tvdb' => (int) ($result->tvdb ?? 0),
'imdb' => (string) ($result->imdb ?? ''),
];
}
/**
* Gets the specific episode info for the parsed release after match
* Returns a formatted array of episode data or false if no match.
*
* @return array<string, mixed>|false
*/
public function getEpisodeInfo(int|string $siteId, int|string $series, int|string $episode, string $airdate = '', int $videoId = 0): bool|array
{
$return = false;
if (! isset($this->tmdbClient)) {
$this->tmdbClient = app(TmdbClient::class);
}
if (! $this->tmdbClient->isConfigured()) {
return false;
}
// Bulk fetch all episodes for all seasons and insert
if ($videoId > 0 && (int) $series === -1 && (int) $episode === -1) {
$tvDetails = $this->tmdbClient->getTvShow((int) $siteId);
if ($tvDetails === null) {
return false;
}
$seasons = TmdbClient::getArray($tvDetails, 'seasons');
if (empty($seasons)) {
return false;
}
foreach ($seasons as $seriesInfo) {
if (! is_array($seriesInfo)) {
continue;
}
$seasonNumber = TmdbClient::getInt($seriesInfo, 'season_number');
if ($seasonNumber <= 0) {
continue;
}
$seriesData = $this->tmdbClient->getTvSeason((int) $siteId, $seasonNumber);
sleep(1);
if ($seriesData === null) {
continue;
}
$episodes = TmdbClient::getArray($seriesData, 'episodes');
foreach ($episodes as $ep) {
if (is_array($ep) && $this->checkRequiredAttr($ep, 'tmdbE')) {
$this->addEpisode($videoId, $this->formatEpisodeInfo($ep));
}
}
}
return false;
}
// Single episode lookup - try with fallback to other IDs if we have a videoId
$response = null;
if ($videoId > 0) {
$ids = $this->getAllSiteIdsFromVideoID($videoId);
// Ensure the provided siteId is used as the TMDB ID if available
if ((int) $siteId > 0) {
$ids['tmdb'] = (int) $siteId;
}
$response = $this->tmdbClient->getTvEpisodeWithFallback($ids, (int) $series, (int) $episode);
} else {
// Legacy behavior: just use the provided site ID
$response = $this->tmdbClient->getTvEpisode((int) $siteId, (int) $series, (int) $episode);
}
sleep(1);
// Handle Single Episode Lookups
if ($response !== null && is_array($response) && $this->checkRequiredAttr($response, 'tmdbE')) {
$return = $this->formatEpisodeInfo($response);
}
return $return;
}
/**
* Assigns API show response values to a formatted array for insertion
* Returns the formatted array.
*
* @return array<string, mixed>
*/
public function formatShowInfo(mixed $show): array
{
if (! is_array($show)) {
return [];
}
$posterPath = TmdbClient::getString($show, 'poster_path');
// Prefer a reasonable default size for posters if we only have a path
$this->posterUrl = ! empty($posterPath)
? 'https://image.tmdb.org/t/p/w500'.$posterPath
: '';
$imdbId = '';
$externalIds = TmdbClient::getArray($show, 'external_ids');
if (! empty($externalIds['imdb_id'])) {
preg_match('/tt(?P<imdbid>\d{6,})$/i', (string) $externalIds['imdb_id'], $imdb);
$imdbId = (string) ($imdb['imdbid'] ?? '');
}
$originCountry = TmdbClient::getArray($show, 'origin_country');
$alternativeTitles = TmdbClient::getArray($show, 'alternative_titles');
// Try to get Trakt ID by looking up via TMDB ID
$traktId = 0;
$tmdbId = TmdbClient::getInt($show, 'id');
if ($tmdbId > 0) {
$traktId = $this->lookupTraktId($tmdbId, $imdbId, TmdbClient::getInt($externalIds, 'tvdb_id'));
}
return [
'type' => parent::TYPE_TV,
'title' => TmdbClient::getString($show, 'name'),
'summary' => TmdbClient::getString($show, 'overview'),
'started' => TmdbClient::getString($show, 'first_air_date'),
'publisher' => TmdbClient::getString($show, 'network'),
'country' => ! empty($originCountry[0]) ? $originCountry[0] : '', // @phpstan-ignore offsetAccess.notFound
'source' => parent::SOURCE_TMDB,
'imdb' => $imdbId,
'tvdb' => TmdbClient::getInt($externalIds, 'tvdb_id'),
'trakt' => $traktId,
'tvrage' => TmdbClient::getInt($externalIds, 'tvrage_id'),
'tvmaze' => 0,
'tmdb' => $tmdbId,
'aliases' => ! empty($alternativeTitles) ? $alternativeTitles : '',
'localzone' => "''",
];
}
/**
* Look up Trakt ID using available external IDs.
* Tries TMDB ID first, then IMDB, then TVDB.
*
* @param int $tmdbId TMDB show ID
* @param int|string $imdbId IMDB ID (numeric, without 'tt' prefix)
* @param int $tvdbId TVDB show ID
* @return int Trakt ID or 0 if not found
*/
protected function lookupTraktId(int $tmdbId, int|string $imdbId, int $tvdbId): int
{
try {
$traktService = app(TraktService::class);
if (! $traktService->isConfigured()) {
return 0;
}
// Try TMDB ID first
if ($tmdbId > 0) {
$ids = $traktService->lookupShowIds($tmdbId, 'tmdb');
if ($ids !== null && ! empty($ids['trakt'])) {
return (int) $ids['trakt'];
}
}
// Try IMDB ID
if (! empty($imdbId) && imdb_id_is_valid($imdbId)) {
$ids = $traktService->lookupShowIds($imdbId, 'imdb');
if ($ids !== null && ! empty($ids['trakt'])) {
return (int) $ids['trakt'];
}
}
// Try TVDB ID
if ($tvdbId > 0) {
$ids = $traktService->lookupShowIds($tvdbId, 'tvdb');
if ($ids !== null && ! empty($ids['trakt'])) {
return (int) $ids['trakt'];
}
}
} catch (\Throwable $e) {
// Silently fail - Trakt ID lookup is optional enrichment
}
return 0;
}
/**
* Assigns API episode response values to a formatted array for insertion
* Returns the formatted array.
*
* @return array<string, mixed>
*/
public function formatEpisodeInfo(mixed $episode): array
{
if (! is_array($episode)) {
return [];
}
$seasonNumber = TmdbClient::getInt($episode, 'season_number');
$episodeNumber = TmdbClient::getInt($episode, 'episode_number');
return [
'title' => TmdbClient::getString($episode, 'name'),
'series' => $seasonNumber,
'episode' => $episodeNumber,
'se_complete' => 'S'.sprintf('%02d', $seasonNumber).'E'.sprintf('%02d', $episodeNumber),
'firstaired' => TmdbClient::getString($episode, 'air_date'),
'summary' => TmdbClient::getString($episode, 'overview'),
];
}
}