From 36e5bc29731f7e79cbe1a7f103f979934c4aa4e5 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Wed, 10 Dec 2025 00:20:19 +0100 Subject: [PATCH] Use Laravel Pipeline for categorization --- .../Categorization/CategorizationPipeline.php | 111 ++++++------------ .../Categorization/CategorizationService.php | 10 +- .../Pipes/AbstractCategorizationPipe.php | 84 +++++++++++++ .../Categorization/Pipes/BookPipe.php | 36 ++++++ .../Pipes/CategorizationPassable.php | 73 ++++++++++++ .../Categorization/Pipes/ConsolePipe.php | 37 ++++++ .../Categorization/Pipes/GroupNamePipe.php | 32 +++++ .../Categorization/Pipes/MiscPipe.php | 33 ++++++ .../Categorization/Pipes/MoviePipe.php | 37 ++++++ .../Categorization/Pipes/MusicPipe.php | 37 ++++++ app/Services/Categorization/Pipes/PcPipe.php | 37 ++++++ app/Services/Categorization/Pipes/TvPipe.php | 37 ++++++ app/Services/Categorization/Pipes/XxxPipe.php | 32 +++++ 13 files changed, 518 insertions(+), 78 deletions(-) create mode 100644 app/Services/Categorization/Pipes/AbstractCategorizationPipe.php create mode 100644 app/Services/Categorization/Pipes/BookPipe.php create mode 100644 app/Services/Categorization/Pipes/CategorizationPassable.php create mode 100644 app/Services/Categorization/Pipes/ConsolePipe.php create mode 100644 app/Services/Categorization/Pipes/GroupNamePipe.php create mode 100644 app/Services/Categorization/Pipes/MiscPipe.php create mode 100644 app/Services/Categorization/Pipes/MoviePipe.php create mode 100644 app/Services/Categorization/Pipes/MusicPipe.php create mode 100644 app/Services/Categorization/Pipes/PcPipe.php create mode 100644 app/Services/Categorization/Pipes/TvPipe.php create mode 100644 app/Services/Categorization/Pipes/XxxPipe.php diff --git a/app/Services/Categorization/CategorizationPipeline.php b/app/Services/Categorization/CategorizationPipeline.php index 18329d66a..c70213bec 100644 --- a/app/Services/Categorization/CategorizationPipeline.php +++ b/app/Services/Categorization/CategorizationPipeline.php @@ -2,54 +2,56 @@ namespace App\Services\Categorization; -use App\Models\Category; use App\Models\Settings; use App\Models\UsenetGroup; -use App\Services\Categorization\Contracts\CategorizerInterface; +use App\Services\Categorization\Pipes\AbstractCategorizationPipe; +use App\Services\Categorization\Pipes\CategorizationPassable; +use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; /** - * Pipeline-based categorization service. + * Pipeline-based categorization service using Laravel Pipeline. * - * This service orchestrates multiple categorizers to determine the best - * category for a release. Each categorizer is responsible for a specific - * category domain and returns a result with a confidence score. + * This service uses Laravel's Pipeline to orchestrate multiple categorizers + * to determine the best category for a release. Each categorizer (pipe) is + * responsible for a specific category domain and returns a result with a + * confidence score. */ class CategorizationPipeline { /** - * @var Collection + * @var Collection */ - protected Collection $categorizers; + protected Collection $pipes; protected bool $categorizeForeign; protected bool $catWebDL; /** - * @param iterable $categorizers + * @param iterable $pipes */ - public function __construct(iterable $categorizers = []) + public function __construct(iterable $pipes = []) { - $this->categorizers = collect($categorizers) - ->sortBy(fn (CategorizerInterface $c) => $c->getPriority()); + $this->pipes = collect($pipes) + ->sortBy(fn (AbstractCategorizationPipe $p) => $p->getPriority()); $this->categorizeForeign = (bool) Settings::settingValue('categorizeforeign'); $this->catWebDL = (bool) Settings::settingValue('catwebdl'); } /** - * Register a categorizer in the pipeline. + * Register a categorizer pipe in the pipeline. */ - public function addCategorizer(CategorizerInterface $categorizer): self + public function addCategorizer(AbstractCategorizationPipe $pipe): self { - $this->categorizers->push($categorizer); - $this->categorizers = $this->categorizers->sortBy(fn (CategorizerInterface $c) => $c->getPriority()); + $this->pipes->push($pipe); + $this->pipes = $this->pipes->sortBy(fn (AbstractCategorizationPipe $p) => $p->getPriority()); return $this; } /** - * Determine the category for a release. + * Determine the category for a release using Laravel Pipeline. * * @param int|string $groupId The usenet group ID * @param string $releaseName The name of the release @@ -74,62 +76,25 @@ class CategorizationPipeline catWebDL: $this->catWebDL, ); - $bestResult = CategorizationResult::noMatch(); - $allResults = []; + $passable = new CategorizationPassable($context, $debug); - foreach ($this->categorizers as $categorizer) { - // Skip if categorizer determines it shouldn't process this release - if ($categorizer->shouldSkip($context)) { - continue; - } + /** @var CategorizationPassable $result */ + $result = app(Pipeline::class) + ->send($passable) + ->through($this->pipes->values()->all()) + ->thenReturn(); - $result = $categorizer->categorize($context); - - if ($debug) { - $allResults[$categorizer->getName()] = [ - 'category_id' => $result->categoryId, - 'confidence' => $result->confidence, - 'matched_by' => $result->matchedBy, - ]; - } - - // If this result is better than our current best, use it - if ($result->isSuccessful() && $result->shouldOverride($bestResult)) { - $bestResult = $result; - - // If we have a very high confidence match, we can stop early - if ($result->confidence >= 0.95) { - break; - } - } - } - - // Build the return array - $returnValue = ['categories_id' => $bestResult->categoryId]; - - if ($debug) { - $returnValue['debug'] = [ - 'final_category' => $bestResult->categoryId, - 'final_confidence' => $bestResult->confidence, - 'matched_by' => $bestResult->matchedBy, - 'release_name' => $releaseName, - 'group_name' => $groupName, - 'all_results' => $allResults, - 'categorizer_details' => $bestResult->debug, - ]; - } - - return $returnValue; + return $result->toArray(); } /** - * Get all registered categorizers. + * Get all registered categorizers (pipes). * - * @return Collection + * @return Collection */ public function getCategorizers(): Collection { - return $this->categorizers; + return $this->pipes; } /** @@ -138,15 +103,15 @@ class CategorizationPipeline public static function createDefault(): self { return new self([ - new Categorizers\GroupNameCategorizer(), - new Categorizers\XxxCategorizer(), - new Categorizers\TvCategorizer(), - new Categorizers\MovieCategorizer(), - new Categorizers\BookCategorizer(), - new Categorizers\MusicCategorizer(), - new Categorizers\PcCategorizer(), - new Categorizers\ConsoleCategorizer(), - new Categorizers\MiscCategorizer(), + new Pipes\GroupNamePipe(), + new Pipes\XxxPipe(), + new Pipes\TvPipe(), + new Pipes\MoviePipe(), + new Pipes\BookPipe(), + new Pipes\MusicPipe(), + new Pipes\PcPipe(), + new Pipes\ConsolePipe(), + new Pipes\MiscPipe(), ]); } } diff --git a/app/Services/Categorization/CategorizationService.php b/app/Services/Categorization/CategorizationService.php index 5a7e0b806..f9b247373 100644 --- a/app/Services/Categorization/CategorizationService.php +++ b/app/Services/Categorization/CategorizationService.php @@ -2,10 +2,10 @@ namespace App\Services\Categorization; -use App\Models\Category; +use App\Services\Categorization\Pipes\AbstractCategorizationPipe; /** - * Categorization service using the new pipeline-based system. + * Categorization service using Laravel Pipeline. * * This class is a drop-in replacement for the legacy Blacklight\Categorize * with additional features like confidence scoring and debug information. @@ -70,11 +70,11 @@ class CategorizationService } /** - * Add a custom categorizer to the pipeline. + * Add a custom categorizer pipe to the pipeline. */ - public function addCategorizer(Contracts\CategorizerInterface $categorizer): self + public function addCategorizer(AbstractCategorizationPipe $pipe): self { - $this->pipeline->addCategorizer($categorizer); + $this->pipeline->addCategorizer($pipe); return $this; } diff --git a/app/Services/Categorization/Pipes/AbstractCategorizationPipe.php b/app/Services/Categorization/Pipes/AbstractCategorizationPipe.php new file mode 100644 index 000000000..217671196 --- /dev/null +++ b/app/Services/Categorization/Pipes/AbstractCategorizationPipe.php @@ -0,0 +1,84 @@ +shouldStopProcessing()) { + return $next($passable); + } + + // Skip if this categorizer shouldn't process this release + if ($this->shouldSkip($passable->context)) { + return $next($passable); + } + + // Attempt categorization + $result = $this->categorize($passable->context); + + // Update the best result + $passable->updateBestResult($result, $this->getName()); + + return $next($passable); + } + + /** + * Get the priority of this categorizer (lower = higher priority). + */ + public function getPriority(): int + { + return $this->priority; + } + + /** + * Get the name of this categorizer for debugging/logging. + */ + abstract public function getName(): string; + + /** + * Attempt to categorize the given release. + */ + abstract protected function categorize(ReleaseContext $context): CategorizationResult; + + /** + * Check if this categorizer should be skipped for the given context. + */ + protected function shouldSkip(ReleaseContext $context): bool + { + return false; + } + + /** + * Create a successful categorization result. + */ + protected function matched(int $categoryId, float $confidence, string $matchedBy, array $debug = []): CategorizationResult + { + return new CategorizationResult($categoryId, $confidence, $matchedBy, $debug); + } + + /** + * Create a no-match result. + */ + protected function noMatch(): CategorizationResult + { + return CategorizationResult::noMatch(); + } +} + diff --git a/app/Services/Categorization/Pipes/BookPipe.php b/app/Services/Categorization/Pipes/BookPipe.php new file mode 100644 index 000000000..385f1c31f --- /dev/null +++ b/app/Services/Categorization/Pipes/BookPipe.php @@ -0,0 +1,36 @@ +categorizer = new BookCategorizer(); + } + + public function getName(): string + { + return 'Book'; + } + + protected function shouldSkip(ReleaseContext $context): bool + { + return $this->categorizer->shouldSkip($context); + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} diff --git a/app/Services/Categorization/Pipes/CategorizationPassable.php b/app/Services/Categorization/Pipes/CategorizationPassable.php new file mode 100644 index 000000000..bbb1fe335 --- /dev/null +++ b/app/Services/Categorization/Pipes/CategorizationPassable.php @@ -0,0 +1,73 @@ +context = $context; + $this->debug = $debug; + $this->bestResult = CategorizationResult::noMatch(); + } + + /** + * Check if we should stop processing (high confidence match found). + */ + public function shouldStopProcessing(): bool + { + return $this->bestResult->confidence >= 0.95; + } + + /** + * Update the best result if the new result is better. + */ + public function updateBestResult(CategorizationResult $result, string $categorizerName): void + { + if ($this->debug) { + $this->allResults[$categorizerName] = [ + 'category_id' => $result->categoryId, + 'confidence' => $result->confidence, + 'matched_by' => $result->matchedBy, + ]; + } + + if ($result->isSuccessful() && $result->shouldOverride($this->bestResult)) { + $this->bestResult = $result; + } + } + + /** + * Build the final result array. + */ + public function toArray(): array + { + $returnValue = ['categories_id' => $this->bestResult->categoryId]; + + if ($this->debug) { + $returnValue['debug'] = [ + 'final_category' => $this->bestResult->categoryId, + 'final_confidence' => $this->bestResult->confidence, + 'matched_by' => $this->bestResult->matchedBy, + 'release_name' => $this->context->releaseName, + 'group_name' => $this->context->groupName, + 'all_results' => $this->allResults, + 'categorizer_details' => $this->bestResult->debug, + ]; + } + + return $returnValue; + } +} + diff --git a/app/Services/Categorization/Pipes/ConsolePipe.php b/app/Services/Categorization/Pipes/ConsolePipe.php new file mode 100644 index 000000000..ce4534d92 --- /dev/null +++ b/app/Services/Categorization/Pipes/ConsolePipe.php @@ -0,0 +1,37 @@ +categorizer = new ConsoleCategorizer(); + } + + public function getName(): string + { + return 'Console'; + } + + protected function shouldSkip(ReleaseContext $context): bool + { + return $this->categorizer->shouldSkip($context); + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/GroupNamePipe.php b/app/Services/Categorization/Pipes/GroupNamePipe.php new file mode 100644 index 000000000..3f16f4912 --- /dev/null +++ b/app/Services/Categorization/Pipes/GroupNamePipe.php @@ -0,0 +1,32 @@ +categorizer = new GroupNameCategorizer(); + } + + public function getName(): string + { + return 'GroupName'; + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/MiscPipe.php b/app/Services/Categorization/Pipes/MiscPipe.php new file mode 100644 index 000000000..16f2545cd --- /dev/null +++ b/app/Services/Categorization/Pipes/MiscPipe.php @@ -0,0 +1,33 @@ +categorizer = new MiscCategorizer(); + } + + public function getName(): string + { + return 'Misc'; + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/MoviePipe.php b/app/Services/Categorization/Pipes/MoviePipe.php new file mode 100644 index 000000000..4c01726ed --- /dev/null +++ b/app/Services/Categorization/Pipes/MoviePipe.php @@ -0,0 +1,37 @@ +categorizer = new MovieCategorizer(); + } + + public function getName(): string + { + return 'Movie'; + } + + protected function shouldSkip(ReleaseContext $context): bool + { + return $this->categorizer->shouldSkip($context); + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/MusicPipe.php b/app/Services/Categorization/Pipes/MusicPipe.php new file mode 100644 index 000000000..fa127aeda --- /dev/null +++ b/app/Services/Categorization/Pipes/MusicPipe.php @@ -0,0 +1,37 @@ +categorizer = new MusicCategorizer(); + } + + public function getName(): string + { + return 'Music'; + } + + protected function shouldSkip(ReleaseContext $context): bool + { + return $this->categorizer->shouldSkip($context); + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/PcPipe.php b/app/Services/Categorization/Pipes/PcPipe.php new file mode 100644 index 000000000..8fa28c3f4 --- /dev/null +++ b/app/Services/Categorization/Pipes/PcPipe.php @@ -0,0 +1,37 @@ +categorizer = new PcCategorizer(); + } + + public function getName(): string + { + return 'PC'; + } + + protected function shouldSkip(ReleaseContext $context): bool + { + return $this->categorizer->shouldSkip($context); + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/TvPipe.php b/app/Services/Categorization/Pipes/TvPipe.php new file mode 100644 index 000000000..213412609 --- /dev/null +++ b/app/Services/Categorization/Pipes/TvPipe.php @@ -0,0 +1,37 @@ +categorizer = new TvCategorizer(); + } + + public function getName(): string + { + return 'TV'; + } + + protected function shouldSkip(ReleaseContext $context): bool + { + return $this->categorizer->shouldSkip($context); + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} + diff --git a/app/Services/Categorization/Pipes/XxxPipe.php b/app/Services/Categorization/Pipes/XxxPipe.php new file mode 100644 index 000000000..dd9afe168 --- /dev/null +++ b/app/Services/Categorization/Pipes/XxxPipe.php @@ -0,0 +1,32 @@ +categorizer = new XxxCategorizer(); + } + + public function getName(): string + { + return 'XXX'; + } + + protected function categorize(ReleaseContext $context): CategorizationResult + { + return $this->categorizer->categorize($context); + } +} +