From c85ee543c545dab66758aba0d8cbaed9ac5cd62b Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 11 Aug 2026 12:35:48 +0200 Subject: [PATCH] Fix tv seasons categorization --- .../Categorizers/MovieCategorizer.php | 6 ++ .../Categorizers/TvCategorizer.php | 21 ++-- .../Categorization/ReleaseContext.php | 10 ++ .../ReleaseNameFixedRecategorizationTest.php | 47 +++++++++ tests/Unit/CategorizeFullSeasonTest.php | 99 +++++++++++++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/CategorizeFullSeasonTest.php diff --git a/app/Services/Categorization/Categorizers/MovieCategorizer.php b/app/Services/Categorization/Categorizers/MovieCategorizer.php index 63732c86a..efb7a3add 100644 --- a/app/Services/Categorization/Categorizers/MovieCategorizer.php +++ b/app/Services/Categorization/Categorizers/MovieCategorizer.php @@ -27,6 +27,12 @@ class MovieCategorizer extends AbstractCategorizer return true; } + // A standalone Sxx token identifies a full TV season even when year or + // edition markers appear before the source and quality markers. + if ($context->hasStandaloneSeasonToken()) { + return true; + } + // Skip if it looks like a TV episode (S01E01) or season pack (S01.1080p) if (preg_match('/[._ -]S\d{1,3}[._ -]?(E\d|D\d|Complete|Full|1080|720|480|2160|WEB|HDTV|BluRay|NF|AMZN)/i', $context->releaseName)) { return true; diff --git a/app/Services/Categorization/Categorizers/TvCategorizer.php b/app/Services/Categorization/Categorizers/TvCategorizer.php index 993bf4d7b..859630ffe 100644 --- a/app/Services/Categorization/Categorizers/TvCategorizer.php +++ b/app/Services/Categorization/Categorizers/TvCategorizer.php @@ -46,7 +46,7 @@ class TvCategorizer extends AbstractCategorizer return $result; } - if (! $this->looksLikeTV($name)) { + if (! $this->looksLikeTV($context)) { return $this->noMatch(); } @@ -77,15 +77,22 @@ class TvCategorizer extends AbstractCategorizer if ($result = $this->checkSD($name)) { return $result; } - if ($result = $this->checkOther($name)) { + if ($result = $this->checkOther($context)) { return $result; } return $this->noMatch(); } - protected function looksLikeTV(string $name): bool + protected function looksLikeTV(ReleaseContext $context): bool { + $name = $context->releaseName; + + // Standalone season token: S01, S02, etc. Quality markers may appear later. + if ($context->hasStandaloneSeasonToken()) { + return true; + } + // Season + Episode pattern: S01E01, S01.E01, S1D1, etc. if (preg_match('/[._ -]s\d{1,3}[._ -]?(e|d(isc)?)\d{1,3}([._ -]|$)/i', $name)) { return true; @@ -284,14 +291,16 @@ class TvCategorizer extends AbstractCategorizer return null; } - protected function checkOther(string $name): ?CategorizationResult + protected function checkOther(ReleaseContext $context): ?CategorizationResult { + $name = $context->releaseName; + // Season + episode pattern if (preg_match('/[._ -]s\d{1,3}[._ -]?(e|d(isc)?)\d{1,3}([._ -]|$)/i', $name)) { return $this->matched(Category::TV_OTHER, 0.6, 'tv_other'); } - // Season pack pattern (S01, S02, etc.) with any quality marker - if (preg_match('/[._ -]S\d{1,3}[._ -]/i', $name)) { + // Standalone season pack pattern (S01, S02, etc.) + if ($context->hasStandaloneSeasonToken()) { return $this->matched(Category::TV_OTHER, 0.6, 'tv_season_pack'); } diff --git a/app/Services/Categorization/ReleaseContext.php b/app/Services/Categorization/ReleaseContext.php index 9585f40bd..97cd51817 100644 --- a/app/Services/Categorization/ReleaseContext.php +++ b/app/Services/Categorization/ReleaseContext.php @@ -9,6 +9,8 @@ namespace App\Services\Categorization; */ class ReleaseContext { + private const string STANDALONE_SEASON_TOKEN_REGEX = '/(?:^|[._ -])S\d{1,3}(?=$|[._ -])/i'; + public function __construct( public readonly string $releaseName, public readonly int|string $groupId, @@ -50,6 +52,14 @@ class ReleaseContext return (bool) preg_match($pattern, $this->groupName); } + /** + * Check whether the release name contains a delimiter-bounded season-pack token. + */ + public function hasStandaloneSeasonToken(): bool + { + return preg_match(self::STANDALONE_SEASON_TOKEN_REGEX, $this->releaseName) === 1; + } + /** * Check if this release has adult/XXX markers. */ diff --git a/tests/Feature/ReleaseNameFixedRecategorizationTest.php b/tests/Feature/ReleaseNameFixedRecategorizationTest.php index 2086f5f53..d23529ca0 100644 --- a/tests/Feature/ReleaseNameFixedRecategorizationTest.php +++ b/tests/Feature/ReleaseNameFixedRecategorizationTest.php @@ -130,6 +130,53 @@ class ReleaseNameFixedRecategorizationTest extends TestCase $this->assertSame(1, (int) $release->isrenamed); } + public function test_renaming_tv_episode_to_full_season_keeps_it_out_of_movies(): void + { + Search::shouldReceive('updateRelease')->twice(); + + $group = UsenetGroup::query()->create([ + 'name' => 'alt.binaries.warcraft', + 'active' => 1, + 'backfill' => 0, + ]); + + $oldName = 'Tale.of.the.Nine.Tailed.S02E10.2023.1080p.AMZN.WEB-DL.x264.DDP2.0-ADWeb'; + $newName = 'Tale.of.the.Nine.Tailed.S02.2023.1080p.AMZN.WEB-DL.x264.DDP2.0-ADWeb'; + + $release = Release::factory()->create([ + 'name' => '[1/8] - "'.$oldName.'.par2" yEnc', + 'searchname' => $oldName, + 'fromname' => 'poster@example.com', + 'groups_id' => $group->id, + 'categories_id' => Category::TV_WEBDL, + 'iscategorized' => 1, + 'isrenamed' => 0, + 'guid' => str_repeat('e', 40), + 'leftguid' => 'e', + 'size' => 1, + 'postdate' => now(), + 'adddate' => now(), + ]); + + app(ReleaseUpdateService::class)->updateRelease( + $release->fresh(), + $newName, + 'Raw file: Flat scene release', + true, + 'Filenames, ', + true, + false, + ); + + $release->refresh(); + + $this->assertSame($newName, $release->searchname); + $this->assertSame(Category::TV_WEBDL, $release->categories_id); + $this->assertNotSame(Category::MOVIE_WEBDL, $release->categories_id); + $this->assertSame(1, (int) $release->iscategorized); + $this->assertSame(1, (int) $release->isrenamed); + } + public function test_renaming_olympic_webdl_release_recategorizes_it_from_movie_webdl_to_tv_sport(): void { Search::shouldReceive('updateRelease')->twice(); diff --git a/tests/Unit/CategorizeFullSeasonTest.php b/tests/Unit/CategorizeFullSeasonTest.php new file mode 100644 index 000000000..7fdc2b330 --- /dev/null +++ b/tests/Unit/CategorizeFullSeasonTest.php @@ -0,0 +1,99 @@ + + */ + public static function fullSeasonProvider(): array + { + return [ + 'reported dotted release' => [ + 'Tale.of.the.Nine.Tailed.S02.2023.1080p.AMZN.WEB-DL.x264.DDP2.0-ADWeb', + Category::TV_WEBDL, + ], + 'season at start' => [ + 'S1.2023.Show.Name.1080p.AMZN.WEB-DL.x264-GROUP', + Category::TV_WEBDL, + ], + 'dash separated season' => [ + 'Show-Name-S2-2023-1080p-WEB-DL-GROUP', + Category::TV_WEBDL, + ], + 'underscore separated season' => [ + 'Show_Name_S12_2023_1080p_WEB-DL_GROUP', + Category::TV_WEBDL, + ], + 'space separated season' => [ + 'Show Name S999 2023 1080p WEB-DL GROUP', + Category::TV_WEBDL, + ], + 'season without quality markers' => [ + 'Show.Name.S03.2023-GROUP', + Category::TV_OTHER, + ], + ]; + } + + #[DataProvider('fullSeasonProvider')] + public function test_standalone_season_tokens_are_categorized_as_tv(string $name, int $expectedCategory): void + { + $context = $this->context($name); + + $tvResult = (new TvCategorizer)->categorize($context); + + $this->assertTrue($context->hasStandaloneSeasonToken()); + $this->assertSame($expectedCategory, $tvResult->categoryId); + $this->assertTrue((new MovieCategorizer)->shouldSkip($context)); + $this->assertNotSame(Category::MOVIE_WEBDL, $tvResult->categoryId); + } + + public function test_embedded_season_like_text_is_not_treated_as_a_full_season(): void + { + $context = $this->context('Movie.Name.AS02.2023.1080p.AMZN.WEB-DL.x264-GROUP'); + $movieCategorizer = new MovieCategorizer; + + $this->assertFalse($context->hasStandaloneSeasonToken()); + $this->assertFalse($movieCategorizer->shouldSkip($context)); + $this->assertSame(Category::MOVIE_WEBDL, $movieCategorizer->categorize($context)->categoryId); + } + + public function test_normal_year_based_movie_remains_a_movie(): void + { + $context = $this->context('Oppenheimer.2023.1080p.AMZN.WEB-DL.x264-GROUP'); + $movieCategorizer = new MovieCategorizer; + + $this->assertFalse($context->hasStandaloneSeasonToken()); + $this->assertFalse($movieCategorizer->shouldSkip($context)); + $this->assertSame(Category::MOVIE_WEBDL, $movieCategorizer->categorize($context)->categoryId); + } + + public function test_existing_episode_pattern_remains_tv_and_is_skipped_by_movies(): void + { + $context = $this->context('Show.Name.S02E10.2023.1080p.AMZN.WEB-DL.x264-GROUP'); + + $this->assertFalse($context->hasStandaloneSeasonToken()); + $this->assertSame(Category::TV_WEBDL, (new TvCategorizer)->categorize($context)->categoryId); + $this->assertTrue((new MovieCategorizer)->shouldSkip($context)); + } + + private function context(string $name): ReleaseContext + { + return new ReleaseContext( + releaseName: $name, + groupId: 0, + catWebDL: true, + ); + } +}