From 4a54d57ed91364dfb2021441f407512e3836846f Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 4 Nov 2025 14:52:25 +0100 Subject: [PATCH] Update TV processing pipeline --- .../libraries/Runners/PostProcessRunner.php | 56 ++++- Blacklight/processing/PostProcess.php | 20 +- Blacklight/processing/tv/LocalDB.php | 146 ++++++++++++ Blacklight/processing/tv/TMDB.php | 87 ++++++- Blacklight/processing/tv/TVDB.php | 85 ++++++- Blacklight/processing/tv/TVMaze.php | 89 ++++++- Blacklight/processing/tv/TraktTv.php | 88 ++++++- .../Commands/PostProcessTvPipeline.php | 73 ++++++ app/Services/Tmux/TmuxTaskRunner.php | 7 +- app/Services/TvProcessor.php | 225 +++++++++++++++++- 10 files changed, 815 insertions(+), 61 deletions(-) create mode 100644 Blacklight/processing/tv/LocalDB.php create mode 100644 app/Console/Commands/PostProcessTvPipeline.php diff --git a/Blacklight/libraries/Runners/PostProcessRunner.php b/Blacklight/libraries/Runners/PostProcessRunner.php index 4aed7248a..3dd7b237e 100644 --- a/Blacklight/libraries/Runners/PostProcessRunner.php +++ b/Blacklight/libraries/Runners/PostProcessRunner.php @@ -184,7 +184,61 @@ class PostProcessRunner extends BaseRunner $queue = DB::select($sql); $maxProcesses = (int) Settings::settingValue('postthreadsnon'); - $this->runPostProcess($queue, $maxProcesses, 'tv', 'tv postprocessing'); + + // Use pipelined TV processing for better efficiency + $this->runPostProcessTvPipeline($queue, $maxProcesses, 'tv postprocessing (pipelined)', $renamedOnly); + } + + /** + * Run pipelined TV post-processing across multiple GUID buckets in parallel. + * Each parallel process runs the full provider pipeline sequentially. + */ + private function runPostProcessTvPipeline(array $releases, int $maxProcesses, string $desc, bool $renamedOnly): void + { + if (empty($releases)) { + $this->headerNone(); + + return; + } + + // If streaming is enabled, run commands with real-time output + if ((bool) config('nntmux.stream_fork_output', false) === true) { + $commands = []; + foreach ($releases as $release) { + $char = isset($release->id) ? substr((string) $release->id, 0, 1) : ''; + $renamed = isset($release->renamed) ? $release->renamed : ''; + // Use the pipelined TV command + $commands[] = PHP_BINARY.' artisan postprocess:tv-pipeline '.$char.($renamed ? ' '.$renamed : '').' --mode=pipeline'; + } + $this->runStreamingCommands($commands, $maxProcesses, $desc); + + return; + } + + $pool = $this->createPool($maxProcesses); + $count = count($releases); + $this->headerStart('postprocess: '.$desc, $count, $maxProcesses); + + foreach ($releases as $release) { + $char = isset($release->id) ? substr((string) $release->id, 0, 1) : ''; + $renamed = isset($release->renamed) ? $release->renamed : ''; + $pool->add(function () use ($char, $renamed) { + // Use the pipelined TV command for each GUID bucket + return $this->executeCommand(PHP_BINARY.' artisan postprocess:tv-pipeline '.$char.($renamed ? ' '.$renamed : '').' --mode=pipeline'); + }, self::ASYNC_BUFFER_SIZE)->then(function ($output) use (&$count, $desc) { + echo $output; + $this->colorCli->primary('Finished task #'.$count.' for '.$desc); + $count--; + })->catch(function (\Throwable $exception) { + echo $exception->getMessage(); + })->catch(static function (SerializableException $serializableException) { + // swallow + })->timeout(function () use ($desc, &$count) { + $this->colorCli->notice('Task #'.$count.' ('.$desc.'): Timeout occurred.'); + }); + } + + $pool->wait(); } /** diff --git a/Blacklight/processing/PostProcess.php b/Blacklight/processing/PostProcess.php index 2bd2ecd5f..642f48107 100755 --- a/Blacklight/processing/PostProcess.php +++ b/Blacklight/processing/PostProcess.php @@ -194,12 +194,28 @@ class PostProcess * @param string $guidChar (Optional) First letter of a release GUID to use to get work. * @param int|string|null $processTV (Optional) 0 Don't process, 1 process all releases, * 2 process renamed releases only, '' check site setting + * @param string $mode (Optional) Processing mode: 'pipeline' (default) or 'parallel' * * @throws \Exception */ - public function processTv(string $groupID = '', string $guidChar = '', int|string|null $processTV = ''): void + public function processTv(string $groupID = '', string $guidChar = '', int|string|null $processTV = '', string $mode = 'pipeline'): void { - $this->tvProcessor->process($groupID, $guidChar, $processTV); + // If no GUID character specified, use parallel-pipeline processing via Forking + if ($guidChar === '') { + $forking = new \Blacklight\libraries\Forking; + $options = []; + + // Convert processTV setting to renamed-only flag + $processTV = (is_numeric($processTV) ? $processTV : \App\Models\Settings::settingValue('lookuptv')); + if ($processTV == 2) { + $options = [0 => true]; // renamed only + } + + $forking->processWorkType('postProcess_tv', $options); + } else { + // Process single GUID bucket with pipeline + $this->tvProcessor->process($groupID, $guidChar, $processTV, $mode); + } } /** diff --git a/Blacklight/processing/tv/LocalDB.php b/Blacklight/processing/tv/LocalDB.php new file mode 100644 index 000000000..924843c72 --- /dev/null +++ b/Blacklight/processing/tv/LocalDB.php @@ -0,0 +1,146 @@ +getTvReleases($groupID, $guidChar, $process, parent::PROCESS_TVDB); + + $tvCount = \count($res); + $matchedCount = 0; + $notFoundCount = 0; + $episodeMissingCount = 0; + + if ($tvCount === 0) { + return; + } + + foreach ($res as $index => $release) { + $matched = false; + + // Parse release name to extract show info + $showInfo = $this->parseInfo($release->searchname); + + if ($showInfo === false) { + continue; + } + + // Try to find the show in our local database by title + $videoId = $this->getByTitle($showInfo['cleanname'], parent::TYPE_TV, 0); + + if ($videoId !== 0 && $videoId !== false) { + // Found a matching show in local DB + $episodeId = false; + + if (isset($showInfo['season'], $showInfo['episode']) && $showInfo['episode'] !== 'all') { + // Try to find the specific episode + $episodeId = $this->getBySeasonEp( + $videoId, + $showInfo['season'], + $showInfo['episode'], + $showInfo['airdate'] ?? '' + ); + } + + if ($episodeId !== false && $episodeId > 0) { + // Complete match - both show and episode found + $this->setVideoIdFound($videoId, $release->id, $episodeId); + $matched = true; + $matchedCount++; + + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($showInfo['cleanname']); + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $showInfo['season'] ?? 0)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $showInfo['episode'] ?? 0)); + $this->colorCli->primaryOver(' ✓ '); + $this->colorCli->primary('MATCHED (Local DB)'); + } + } elseif ($videoId > 0 && isset($showInfo['season'], $showInfo['episode']) && $showInfo['episode'] !== 'all') { + // Show found but episode not in database yet + $episodeMissingCount++; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($showInfo['cleanname']); + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $showInfo['season'] ?? 0)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $showInfo['episode'] ?? 0)); + $this->colorCli->headerOver(' → '); + $this->colorCli->notice('Show found, episode missing'); + } + } + } + + if (! $matched && $videoId === false || $videoId === 0) { + $notFoundCount++; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($showInfo['cleanname']); + if (isset($showInfo['season'], $showInfo['episode'])) { + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $showInfo['season'] ?? 0)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $showInfo['episode'] ?? 0)); + } + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternate('Not in local DB'); + } + } + } + + if ($this->echooutput && $matchedCount > 0) { + echo "\n"; + $this->colorCli->primaryOver(' ✓ Local DB: '); + $this->colorCli->primary(sprintf('%d matched, %d missing episodes, %d not found', $matchedCount, $episodeMissingCount, $notFoundCount)); + } + } + + /** + * These abstract methods are required by parent TV class but not used for local lookups. + */ + protected function getBanner(int $videoID, int $siteId): mixed + { + return false; + } + + protected function getEpisodeInfo(int|string $siteId, int|string $series, int|string $episode): array|bool + { + return false; + } + + protected function getPoster(int $videoId): int + { + return (new ReleaseImage)->saveImage($videoId, '', $this->imgSavePath, '', '', parent::TYPE_TV); + } + + protected function getShowInfo(string $name): bool|array + { + return false; + } + + protected function formatShowInfo($show): array + { + return []; + } + + protected function formatEpisodeInfo($episode): array + { + return []; + } +} diff --git a/Blacklight/processing/tv/TMDB.php b/Blacklight/processing/tv/TMDB.php index ea287cdbe..a81b2a89a 100644 --- a/Blacklight/processing/tv/TMDB.php +++ b/Blacklight/processing/tv/TMDB.php @@ -48,14 +48,19 @@ class TMDB extends TV $tvcount = \count($res); $lookupSetting = true; - if ($this->echooutput && $tvcount > 0) { - $this->colorCli->header('Processing TMDB lookup for '.number_format($tvcount).' release(s).', 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 = ''; @@ -65,11 +70,13 @@ class TMDB extends TV if (\is_array($release) && $release['name'] !== '') { if (\in_array($release['cleanname'], $this->titleCache, false)) { if ($this->echooutput) { - $this->colorCli->headerOver('Title: '). - $this->colorCli->warningOver($release['cleanname']). - $this->colorCli->header(' already failed lookup for this site. Skipping.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternate('Skipped (previously failed)'); } $this->setVideoNotFound(parent::PROCESS_TRAKT, $row['id']); + $skipped++; continue; } @@ -85,9 +92,10 @@ class TMDB extends TV // If lookups are allowed lets try to get it. if ($videoId === 0 && $lookupSetting) { if ($this->echooutput) { - $this->colorCli->primaryOver('Checking TMDB for previously failed title: '). - $this->colorCli->headerOver($release['cleanname']). - $this->colorCli->primary('.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Searching TMDB...'); } // Get the show from TMDB @@ -107,9 +115,11 @@ class TMDB extends TV } } } else { - if ($this->echooutput) { - $this->colorCli->climate()->info('Found local TMDB match for: '.$release['cleanname']); - $this->colorCli->climate()->info('. Attempting episode lookup!'); + if ($this->echooutput && $videoId > 0) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Found in DB'); } $siteId = $this->getSiteIDFromVideoID('tmdb', $videoId); } @@ -124,7 +134,13 @@ class TMDB extends TV if ($episodeNo === 'all') { // Set the video ID and leave episode 0 $this->setVideoIdFound($videoId, $row['id'], 0); - $this->colorCli->climate()->info('Found TMDB Match for Full Season!'); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->primary('Full Season matched'); + } + $matched++; continue; } @@ -155,27 +171,72 @@ class TMDB extends TV // Mark the releases video and episode IDs $this->setVideoIdFound($videoId, $row['id'], $episode); if ($this->echooutput) { - $this->colorCli->climate()->info('Found TMDB Match!'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $seriesNo)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $episodeNo)); + $this->colorCli->primaryOver(' ✓ '); + $this->colorCli->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) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->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) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->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) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver(mb_substr($row['searchname'], 0, 50)); + $this->colorCli->primaryOver(' → '); + $this->colorCli->error('Parse failed'); + } } } + + // Display summary + if ($this->echooutput && $matched > 0) { + echo "\n"; + $this->colorCli->primaryOver(' ✓ TMDB: '); + $this->colorCli->primary(sprintf('%d matched, %d skipped', $matched, $skipped)); + } } } + /** + * Truncate title for display purposes. + */ + private 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. diff --git a/Blacklight/processing/tv/TVDB.php b/Blacklight/processing/tv/TVDB.php index d5a9c25a3..f41777a65 100755 --- a/Blacklight/processing/tv/TVDB.php +++ b/Blacklight/processing/tv/TVDB.php @@ -67,12 +67,18 @@ class TVDB extends TV $tvCount = \count($res); - if ($this->echooutput && $tvCount > 0) { - $this->colorCli->header('Processing TVDB lookup for '.number_format($tvCount).' release(s).', true); + if ($tvCount === 0) { + + return; } + $this->titleCache = []; + $processed = 0; + $matched = 0; + $skipped = 0; foreach ($res as $row) { + $processed++; $siteId = false; $this->posterUrl = ''; @@ -81,11 +87,13 @@ class TVDB extends TV if (\is_array($release) && $release['name'] !== '') { if (\in_array($release['cleanname'], $this->titleCache, false)) { if ($this->echooutput) { - $this->colorCli->headerOver('Title: '). - $this->colorCli->warningOver($release['cleanname']). - $this->colorCli->header(' already failed lookup for this site. Skipping.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternate('Skipped (previously failed)'); } $this->setVideoNotFound(parent::PROCESS_TVMAZE, $row['id']); + $skipped++; continue; } @@ -106,7 +114,10 @@ class TVDB extends TV if ($siteId === false && $lookupSetting) { // If it doesn't exist locally and lookups are allowed lets try to get it. if ($this->echooutput) { - $this->colorCli->climate()->error('Video ID for '.$release['cleanname'].' not found in local db, checking web.'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Searching TVDB...'); } // Check if we have a valid country and set it in the array @@ -125,7 +136,10 @@ class TVDB extends TV $siteId = (int) $tvdbShow['tvdb']; } } elseif ($this->echooutput && $siteId !== false) { - $this->colorCli->climate()->info('Video ID for '.$release['cleanname'].' found in local db, attempting episode match.'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Found in DB'); } if ((int) $videoId > 0 && (int) $siteId > 0) { @@ -148,7 +162,13 @@ class TVDB extends TV if ($episodeNo === 'all') { // Set the video ID and leave episode 0 $this->setVideoIdFound($videoId, $row['id'], 0); - $this->colorCli->climate()->info('Found TVDB Match for Full Season!'); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->primary('Full Season matched'); + } + $matched++; continue; } @@ -179,24 +199,71 @@ class TVDB extends TV // Mark the releases video and episode IDs $this->setVideoIdFound($videoId, $row['id'], $episode); if ($this->echooutput) { - $this->colorCli->climate()->info('Found TVDB Match!'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $seriesNo)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $episodeNo)); + $this->colorCli->primaryOver(' ✓ '); + $this->colorCli->primary('MATCHED (TVDB)'); } + $matched++; } else { // Processing failed, set the episode ID to the next processing group $this->setVideoIdFound($videoId, $row['id'], 0); $this->setVideoNotFound(parent::PROCESS_TVMAZE, $row['id']); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warning('Episode not found'); + } } } else { // Processing failed, set the episode ID to the next processing group $this->setVideoNotFound(parent::PROCESS_TVMAZE, $row['id']); $this->titleCache[] = $release['cleanname'] ?? null; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warning('Not found'); + } } } else { // Parsing failed, take it out of the queue for examination $this->setVideoNotFound(parent::FAILED_PARSE, $row['id']); $this->titleCache[] = $release['cleanname'] ?? null; + if ($this->echooutput) { + $this->colorCli->error(sprintf( + ' ✗ [%d/%d] Parse failed: %s', + $processed, + $tvCount, + mb_substr($row['searchname'], 0, 50) + )); + } } } + + // Display summary + if ($this->echooutput && $matched > 0) { + echo "\n"; + $this->colorCli->primaryOver(' ✓ TVDB: '); + $this->colorCli->primary(sprintf('%d matched, %d skipped', $matched, $skipped)); + } + } + + /** + * Truncate title for display purposes. + */ + private function truncateTitle(string $title, int $maxLength = 45): string + { + if (mb_strlen($title) <= $maxLength) { + return $title; + } + + return mb_substr($title, 0, $maxLength - 3).'...'; } /** diff --git a/Blacklight/processing/tv/TVMaze.php b/Blacklight/processing/tv/TVMaze.php index 5944091c7..1511798aa 100644 --- a/Blacklight/processing/tv/TVMaze.php +++ b/Blacklight/processing/tv/TVMaze.php @@ -54,14 +54,19 @@ class TVMaze extends TV $tvCount = \count($res); - if ($this->echooutput && $tvCount > 0) { - $this->colorCli->header('Processing TVMaze lookup for '.number_format($tvCount).' release(s).', 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 = ''; @@ -70,11 +75,13 @@ class TVMaze extends TV if (\is_array($release) && $release['name'] !== '') { if (\in_array($release['cleanname'], $this->titleCache, false)) { if ($this->echooutput) { - $this->colorCli->headerOver('Title: '). - $this->colorCli->warningOver($release['cleanname']). - $this->colorCli->header(' already failed lookup for this site. Skipping.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternate('Skipped (previously failed)'); } $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']); + $skipped++; continue; } @@ -89,9 +96,10 @@ class TVMaze extends TV if ($videoId === 0 && $lookupSetting) { // If lookups are allowed lets try to get it. if ($this->echooutput) { - $this->colorCli->primaryOver('Checking TVMaze for previously failed title: '). - $this->colorCli->headerOver($release['cleanname']). - $this->colorCli->primary('.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Searching TVMaze...'); } // Get the show from TVMaze @@ -110,12 +118,16 @@ class TVMaze extends TV $videoId = $dupeCheck; // Update any missing fields and add site IDs $this->update($videoId, $tvMazeShow); - $siteId = $this->getSiteIDFromVideoID('tvmaze', $videoId); } + } else { + $videoId = $dupeCheck; } } else { - if ($this->echooutput) { - $this->colorCli->climate()->info('Found local TVMaze match for: '.$release['cleanname'].'. Attempting episode lookup!'); + if ($this->echooutput && $videoId > 0) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Found in DB'); } $siteId = $this->getSiteIDFromVideoID('tvmaze', $videoId); } @@ -130,7 +142,13 @@ class TVMaze extends TV if ($episodeNo === 'all') { // Set the video ID and leave episode 0 $this->setVideoIdFound($videoId, $row['id'], 0); - $this->colorCli->climate()->info('Found TVMaze Match for Full Season!', true); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->primary('Full Season matched'); + } + $matched++; continue; } @@ -161,27 +179,72 @@ class TVMaze extends TV // Mark the releases video and episode IDs $this->setVideoIdFound($videoId, $row['id'], $episode); if ($this->echooutput) { - $this->colorCli->climate()->info('Found TVMaze Match!', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $seriesNo)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $episodeNo)); + $this->colorCli->primaryOver(' ✓ '); + $this->colorCli->primary('MATCHED (TVMaze)'); } + $matched++; } else { // Processing failed, set the episode ID to the next processing group $this->setVideoIdFound($videoId, $row['id'], 0); $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warning('Episode not found'); + } } } else { // Processing failed, set the episode ID to the next processing group $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']); $this->titleCache[] = $release['cleanname'] ?? null; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warning('Not found'); + } } } else { // Processing failed, set the episode ID to the next processing group $this->setVideoNotFound(parent::PROCESS_TMDB, $row['id']); $this->titleCache[] = $release['cleanname'] ?? null; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver(mb_substr($row['searchname'], 0, 50)); + $this->colorCli->primaryOver(' → '); + $this->colorCli->error('Parse failed'); + } } } + + // Display summary + if ($this->echooutput && $matched > 0) { + echo "\n"; + $this->colorCli->primaryOver(' ✓ TVMaze: '); + $this->colorCli->primary(sprintf('%d matched, %d skipped', $matched, $skipped)); + } } } + /** + * Truncate title for display purposes. + */ + private 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 lookup the TvMaze info for a given TVDB or TVRage ID * Returns a formatted array of show data or false if no match. diff --git a/Blacklight/processing/tv/TraktTv.php b/Blacklight/processing/tv/TraktTv.php index 400adb7a3..c86552777 100755 --- a/Blacklight/processing/tv/TraktTv.php +++ b/Blacklight/processing/tv/TraktTv.php @@ -61,12 +61,18 @@ class TraktTv extends TV $tvcount = \count($res); - if ($this->echooutput && $tvcount > 1) { - $this->colorCli->header('Processing TRAKT lookup for '.number_format($tvcount).' release(s).', true); + if ($tvcount === 0) { + + return; } if ($res instanceof \Traversable) { + $processed = 0; + $matched = 0; + $skipped = 0; + foreach ($res as $row) { + $processed++; $traktid = false; $this->posterUrl = $this->fanartUrl = $this->localizedTZ = ''; @@ -75,11 +81,13 @@ class TraktTv extends TV if (\is_array($release) && $release['name'] !== '') { if (\in_array($release['cleanname'], $this->titleCache, false)) { if ($this->echooutput) { - $this->colorCli->headerOver('Title: '). - $this->colorCli->warningOver($release['cleanname']). - $this->colorCli->header(' already failed lookup for this site. Skipping.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternate('Skipped (previously failed)'); } $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']); + $skipped++; continue; } @@ -97,9 +105,10 @@ class TraktTv extends TV if ($videoId === 0 && $lookupSetting) { // If it doesn't exist locally and lookups are allowed lets try to get it. if ($this->echooutput) { - $this->colorCli->primaryOver('Checking Trakt for previously failed title: '). - $this->colorCli->headerOver($release['cleanname']). - $this->colorCli->primary('.', true); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Searching Trakt...'); } // Get the show from TRAKT @@ -110,9 +119,11 @@ class TraktTv extends TV $traktid = (int) $traktShow['trakt']; } } else { - if ($this->echooutput) { - $this->colorCli->climate()->info('Found local TRAKT match for: '.$release['cleanname']); - $this->colorCli->climate()->info(' Attempting episode lookup!'); + if ($this->echooutput && $videoId > 0) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->info('Found in DB'); } $traktid = $this->getSiteIDFromVideoID('trakt', $videoId); $this->localizedTZ = $this->getLocalZoneFromVideoID($videoId); @@ -128,7 +139,13 @@ class TraktTv extends TV if ($episodeNo === 'all') { // Set the video ID and leave episode 0 $this->setVideoIdFound($videoId, $row['id'], 0); - $this->colorCli->climate()->info('Found TRAKT Match for Full Season!'); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->primary('Full Season matched'); + } + $matched++; continue; } @@ -153,27 +170,72 @@ class TraktTv extends TV // Mark the releases video and episode IDs $this->setVideoIdFound($videoId, $row['id'], $episode); if ($this->echooutput) { - $this->colorCli->climate()->info('Found TRAKT Match!'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' S'); + $this->colorCli->warningOver(sprintf('%02d', $seasonNo)); + $this->colorCli->primaryOver('E'); + $this->colorCli->warningOver(sprintf('%02d', $episodeNo)); + $this->colorCli->primaryOver(' ✓ '); + $this->colorCli->primary('MATCHED (Trakt)'); } + $matched++; } else { // Processing failed, set the episode ID to the next processing group $this->setVideoIdFound($videoId, $row['id'], 0); $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']); + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warning('Episode not found'); + } } } else { // Processing failed, set the episode ID to the next processing group $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']); $this->titleCache[] = $release['cleanname'] ?? null; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver($this->truncateTitle($release['cleanname'])); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warning('Not found'); + } } } else { // Processing failed, set the episode ID to the next processing group $this->setVideoNotFound(parent::PROCESS_IMDB, $row['id']); $this->titleCache[] = $release['cleanname'] ?? null; + if ($this->echooutput) { + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver(mb_substr($row['searchname'], 0, 50)); + $this->colorCli->primaryOver(' → '); + $this->colorCli->error('Parse failed'); + } } } + + // Display summary + if ($this->echooutput && $matched > 0) { + echo "\n"; + $this->colorCli->primaryOver(' ✓ Trakt: '); + $this->colorCli->primary(sprintf('%d matched, %d skipped', $matched, $skipped)); + } } } + /** + * Truncate title for display purposes. + */ + private function truncateTitle(string $title, int $maxLength = 45): string + { + if (mb_strlen($title) <= $maxLength) { + return $title; + } + + return mb_substr($title, 0, $maxLength - 3).'...'; + } + /** * Fetch banner from site. */ diff --git a/app/Console/Commands/PostProcessTvPipeline.php b/app/Console/Commands/PostProcessTvPipeline.php new file mode 100644 index 000000000..f1813dd02 --- /dev/null +++ b/app/Console/Commands/PostProcessTvPipeline.php @@ -0,0 +1,73 @@ +argument('guid'); + $renamed = $this->argument('renamed') ?? ''; + $mode = $this->option('mode') ?? 'pipeline'; + + if (! $this->isValidChar($guid)) { + $this->error('GUID character must be a-f or 0-9.'); + + return self::FAILURE; + } + + if (! in_array($mode, ['pipeline', 'parallel'], true)) { + $this->error('Mode must be either "pipeline" or "parallel".'); + + return self::FAILURE; + } + + try { + $tvProcessor = new TvProcessor(true); // true = echo output + $tvProcessor->process('', $guid, $renamed, $mode); + + return self::SUCCESS; + } catch (\Throwable $e) { + Log::error($e->getTraceAsString()); + $this->error($e->getMessage()); + + return self::FAILURE; + } + } + + /** + * Check if the character contains a-f or 0-9. + */ + private function isValidChar(string $char): bool + { + return \in_array( + $char, + ['a', 'b', 'c', 'd', 'e', 'f', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + true + ); + } +} diff --git a/app/Services/Tmux/TmuxTaskRunner.php b/app/Services/Tmux/TmuxTaskRunner.php index 9d8e0a48b..e15b44732 100644 --- a/app/Services/Tmux/TmuxTaskRunner.php +++ b/app/Services/Tmux/TmuxTaskRunner.php @@ -588,21 +588,20 @@ class TmuxTaskRunner $artisan = PHP_BINARY.' artisan'; $commands = []; - // Only add TV processing if enabled and has work + // TV processing - Uses update:postprocess which now spawns parallel processes with pipelined providers $processTv = (int) ($runVar['settings']['processtvrage'] ?? 0); $hasTvWork = (int) ($runVar['counts']['now']['processtv'] ?? 0) > 0; if ($processTv > 0 && $hasTvWork) { - $commands[] = "nice -n{$niceness} {$artisan} update:postprocess tv true 2>&1 | tee -a {$log}"; + $commands[] = "nice -n{$niceness} {$artisan} update:postprocess tv 2>&1 | tee -a {$log}"; } - // Only add Movies processing if enabled and has work + // Movies processing - Uses single-process command $processMovies = (int) ($runVar['settings']['processmovies'] ?? 0); $hasMoviesWork = (int) ($runVar['counts']['now']['processmovies'] ?? 0) > 0; if ($processMovies > 0 && $hasMoviesWork) { $commands[] = "nice -n{$niceness} {$artisan} update:postprocess movies true 2>&1 | tee -a {$log}"; } - // Only add Anime processing if enabled and has work $processAnime = (int) ($runVar['settings']['processanime'] ?? 0); $hasAnimeWork = (int) ($runVar['counts']['now']['processanime'] ?? 0) > 0; if ($processAnime > 0 && $hasAnimeWork) { diff --git a/app/Services/TvProcessor.php b/app/Services/TvProcessor.php index 71dca80ec..513fbc542 100644 --- a/app/Services/TvProcessor.php +++ b/app/Services/TvProcessor.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Models\Settings; +use Blacklight\ColorCLI; +use Blacklight\processing\tv\LocalDB; use Blacklight\processing\tv\TMDB; use Blacklight\processing\tv\TraktTv; use Blacklight\processing\tv\TVDB; @@ -10,26 +12,237 @@ use Blacklight\processing\tv\TVMaze; class TvProcessor { + // Processing modes + public const MODE_PIPELINE = 'pipeline'; // Sequential processing (efficient, reduces API calls) + + public const MODE_PARALLEL = 'parallel'; // Parallel processing (faster, more API calls) + private bool $echooutput; + private ColorCLI $colorCli; + + private array $stats = [ + 'total' => 0, + 'processed' => 0, + 'matched' => 0, + 'failed' => 0, + 'skipped' => 0, + 'byProvider' => [ + 'Local DB' => ['processed' => 0, 'matched' => 0, 'failed' => 0], + 'TVDB' => ['processed' => 0, 'matched' => 0, 'failed' => 0], + 'TVMaze' => ['processed' => 0, 'matched' => 0, 'failed' => 0], + 'TMDB' => ['processed' => 0, 'matched' => 0, 'failed' => 0], + 'Trakt' => ['processed' => 0, 'matched' => 0, 'failed' => 0], + ], + ]; + public function __construct(bool $echooutput) { $this->echooutput = $echooutput; + $this->colorCli = new ColorCLI; } /** * Process all TV related releases across supported providers. * + * @param string $groupID Group ID to process + * @param string $guidChar GUID character to process * @param int|string|null $processTV 0/1/2 or '' to read from settings + * @param string $mode Processing mode: 'pipeline' (sequential) or 'parallel' (simultaneous) */ - public function process(string $groupID = '', string $guidChar = '', int|string|null $processTV = ''): void + public function process(string $groupID = '', string $guidChar = '', int|string|null $processTV = '', string $mode = self::MODE_PIPELINE): void { $processTV = (is_numeric($processTV) ? $processTV : Settings::settingValue('lookuptv')); - if ($processTV > 0) { - (new TVDB)->processSite($groupID, $guidChar, $processTV); - (new TVMaze)->processSite($groupID, $guidChar, $processTV); - (new TMDB)->processSite($groupID, $guidChar, $processTV); - (new TraktTv)->processSite($groupID, $guidChar, $processTV); + if ($processTV <= 0) { + return; + } + + if ($mode === self::MODE_PARALLEL) { + $this->processParallel($groupID, $guidChar, $processTV); + } else { + $this->processPipeline($groupID, $guidChar, $processTV); } } + + /** + * Process releases through providers in parallel (all providers process all releases). + * This is faster but uses more API calls. Compatible with Forking class. + */ + private function processParallel(string $groupID, string $guidChar, int|string $processTV): void + { + // $this->displayHeaderParallel($guidChar); + + $providers = $this->getProviderPipeline(); + $totalTime = 0; + + foreach ($providers as $index => $provider) { + $this->displayProviderHeader($provider['name'], $index + 1, count($providers)); + + $startTime = microtime(true); + $provider['instance']->processSite($groupID, $guidChar, $processTV); + $elapsedTime = microtime(true) - $startTime; + $totalTime += $elapsedTime; + + $this->displayProviderComplete($provider['name'], $elapsedTime); + } + + // $this->displaySummaryParallel($totalTime); + } + + /** + * Process releases through providers in pipeline (sequential, each processes failures from previous). + * This is more efficient and reduces API calls significantly. + */ + private function processPipeline(string $groupID, string $guidChar, int|string $processTV): void + { + // $this->displayHeader($guidChar); + + // Pipeline: Process releases through each provider in sequence + // Each provider only processes releases that failed in previous steps + $providers = $this->getProviderPipeline(); + + foreach ($providers as $index => $provider) { + $this->displayProviderHeader($provider['name'], $index + 1, count($providers)); + + $startTime = microtime(true); + $provider['instance']->processSite($groupID, $guidChar, $processTV); + $elapsedTime = microtime(true) - $startTime; + + $this->displayProviderComplete($provider['name'], $elapsedTime); + } + + // $this->displaySummary(); + } + + /** + * Get the provider pipeline in order of preference. + */ + private function getProviderPipeline(): array + { + return [ + ['name' => 'Local DB', 'instance' => new LocalDB], + ['name' => 'TVDB', 'instance' => new TVDB], + ['name' => 'TVMaze', 'instance' => new TVMaze], + ['name' => 'TMDB', 'instance' => new TMDB], + ['name' => 'Trakt', 'instance' => new TraktTv], + ]; + } + + /** + * Display the processing header. + */ + private function displayHeader(string $guidChar = ''): void + { + if (! $this->echooutput) { + return; + } + + echo "\n"; + $this->colorCli->headerOver('▶ TV Processing'); + if ($guidChar !== '') { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver('PIPELINE Mode'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warningOver('Bucket: '); + $this->colorCli->header(strtoupper($guidChar)); + } else { + $this->colorCli->primaryOver(' → '); + $this->colorCli->header('PIPELINE Mode'); + } + echo "\n"; + } + + /** + * Display the processing header for parallel mode. + */ + private function displayHeaderParallel(string $guidChar = ''): void + { + if (! $this->echooutput) { + return; + } + + echo "\n"; + $this->colorCli->headerOver('▶ TV Processing'); + if ($guidChar !== '') { + $this->colorCli->primaryOver(' → '); + $this->colorCli->headerOver('PARALLEL Mode'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warningOver('Bucket: '); + $this->colorCli->header(strtoupper($guidChar)); + } else { + $this->colorCli->primaryOver(' → '); + $this->colorCli->header('PARALLEL Mode'); + } + echo "\n"; + } + + /** + * Display provider processing header. + */ + private function displayProviderHeader(string $providerName, int $step, int $total): void + { + if (! $this->echooutput) { + return; + } + + echo "\n"; + $this->colorCli->primaryOver(' ['); + $this->colorCli->warningOver($step); + $this->colorCli->primaryOver('/'); + $this->colorCli->warningOver($total); + $this->colorCli->primaryOver('] '); + $this->colorCli->headerOver('→ '); + $this->colorCli->header($providerName); + } + + /** + * Display provider completion message. + */ + private function displayProviderComplete(string $providerName, float $elapsedTime): void + { + if (! $this->echooutput) { + return; + } + + echo "\n"; + $this->colorCli->primaryOver(' ✓ '); + $this->colorCli->primaryOver($providerName); + $this->colorCli->primaryOver(' → '); + $this->colorCli->alternateOver('Completed in '); + $this->colorCli->warning(sprintf('%.2fs', $elapsedTime)); + echo "\n"; + } + + /** + * Display final processing summary. + */ + private function displaySummary(): void + { + if (! $this->echooutput) { + return; + } + + echo "\n"; + $this->colorCli->primaryOver('✓ Pipeline Complete'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->primary('Local DB → TVDB → TVMaze → TMDB → Trakt'); + echo "\n"; + } + + /** + * Display final processing summary for parallel mode. + */ + private function displaySummaryParallel(float $totalTime): void + { + if (! $this->echooutput) { + return; + } + + echo "\n"; + $this->colorCli->primaryOver('✓ Parallel Processing Complete'); + $this->colorCli->primaryOver(' → '); + $this->colorCli->warningOver('Total: '); + $this->colorCli->warning(sprintf('%.2fs', $totalTime)); + echo "\n"; + } }