From dee536cdc9ef0ab495cb909ff0dda90d3d30d8fd Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 11:39:24 +0200 Subject: [PATCH] fix(parser): admit unmarked episode-less releases on classic engine (#1092) Subtitle groups sometimes release TV specials without an episode number or any special/movie marker (e.g. LoliHouse's ONE PIECE HEROINES). The Preview engine's admission policy already accepts such evidence-backed single releases as eps_collect bangumi, but the classic engine's legacy Episode projection rejected them outright, so default installs silently skipped the torrent. Align classic admission with the Preview persistence policy for exactly this shape (no episode, unknown media type, admissible under persistence_target). The frozen legacy Episode contract and its golden corpus stay untouched; numbered legacy rejections (e.g. fractional episodes) keep their behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- backend/src/module/parser/title_parser.py | 15 ++++- backend/src/test/test_issue_bugs.py | 74 +++++++++++++++++++++++ backend/src/test/test_title_parser.py | 28 ++++++--- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index 49015b60..17013e88 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -456,7 +456,20 @@ class TitleParser: ) deterministic = parse_outcome.result if parse_outcome.engine == "classic": - deterministic = _project_classic_release(raw, deterministic) + projected = _project_classic_release(raw, deterministic) + if ( + projected is None + and deterministic is not None + and deterministic.episode is None + and deterministic.media_type is MediaType.UNKNOWN + and persistence_target(deterministic) is not None + ): + # 字幕组未标记特别篇/剧场版等字样的无集数单发资源(#1092): + # 旧版投影契约会整条拒绝,导致订阅静默漏抓。与 Preview 的 + # 准入策略对齐,按 eps_collect 整理集接纳;带集数的旧版 + # 拒绝(如小数集)不受影响。 + projected = deterministic + deterministic = projected if ( deterministic is None and parse_outcome.trace is not None diff --git a/backend/src/test/test_issue_bugs.py b/backend/src/test/test_issue_bugs.py index 7fb6565c..b5b8cece 100644 --- a/backend/src/test/test_issue_bugs.py +++ b/backend/src/test/test_issue_bugs.py @@ -555,3 +555,77 @@ class TestIssue1005SearchOfficialTitle: db = BangumiDatabase(db_session) result = await db.search_official_title("不存在的番剧") assert result is None + + +# --------------------------------------------------------------------------- +# Issue #1092: 字幕组未标记特别篇等字样时,无集数标题无法解析 +# https://github.com/EstrellaXD/Auto_Bangumi/issues/1092 +# +# LoliHouse 发布的 TV 特别篇(如 ONE PIECE HEROINES)标题中没有集数也没有 +# 特别篇/剧场版标记。Preview 引擎的准入策略按 eps_collect 整理集接纳这类 +# 带发布证据的单发资源,但 classic 引擎的旧版兼容投影会整条拒绝,导致 +# 默认配置下订阅静默漏抓。 +# --------------------------------------------------------------------------- + + +class TestIssue1092UnmarkedEpisodelessRelease: + """Issue #1092: classic admission now matches the Preview policy.""" + + REPORTED_TITLE = ( + "[LoliHouse] 海贼王:女英雄们的故事 / ONE PIECE HEROINES " + "[WebRip 1080p HEVC-10bit AAC][简繁内封字幕]" + ) + + async def test_classic_admits_reported_title_as_collect_bangumi(self): + from unittest.mock import patch + + from module.conf import settings + from module.models import Bangumi + from module.models.config import LLM + from module.parser import TitleParser + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "classic"), + ): + result = await TitleParser.raw_parser(self.REPORTED_TITLE) + + assert isinstance(result, Bangumi) + assert result.official_title == "海贼王:女英雄们的故事" + assert result.group_name == "LoliHouse" + assert result.episode_type == "episode" + assert result.eps_collect is True + + async def test_classic_and_preview_agree_on_reported_title(self): + from unittest.mock import patch + + from module.conf import settings + from module.models import Bangumi + from module.models.config import LLM + from module.parser import TitleParser + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "tokenizer"), + ): + preview_result = await TitleParser.raw_parser(self.REPORTED_TITLE) + + assert isinstance(preview_result, Bangumi) + assert preview_result.official_title == "海贼王:女英雄们的故事" + + async def test_classic_still_rejects_title_without_release_evidence(self): + from unittest.mock import patch + + from module.conf import settings + from module.models.config import LLM + from module.parser import TitleParser + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "classic"), + ): + result = await TitleParser.raw_parser( + "Random Non Matching Title No Digits At All" + ) + + assert result is None diff --git a/backend/src/test/test_title_parser.py b/backend/src/test/test_title_parser.py index e944dfd9..450069ca 100644 --- a/backend/src/test/test_title_parser.py +++ b/backend/src/test/test_title_parser.py @@ -233,14 +233,8 @@ class TestConfiguredParserEngine: assert result is None - @pytest.mark.parametrize( - "raw", - ( - "[Group] Anime [1080p]", - "[Group] Anime EP07.5 [1080p]", - ), - ) - async def test_classic_preserves_legacy_compatibility_rejections(self, raw): + async def test_classic_preserves_legacy_compatibility_rejections(self): + raw = "[Group] Anime EP07.5 [1080p]" parsed = parse_classic_release_title(raw) assert parsed is not None @@ -254,6 +248,24 @@ class TestConfiguredParserEngine: assert result is None + async def test_classic_admits_episodeless_release_with_evidence(self): + """无集数且无类型标记但带发布证据的单发资源(#1092):旧版 Episode + 投影仍拒绝(冻结契约),但 classic 准入与 Preview 策略对齐后放行。""" + raw = "[Group] Anime [1080p]" + parsed = parse_classic_release_title(raw) + + assert parsed is not None + assert to_legacy_episode(parsed) is None + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "classic"), + ): + result = await TitleParser.raw_parser(raw) + + assert isinstance(result, Bangumi) + assert result.eps_collect is True + async def test_classic_projects_admitted_result_through_legacy_episode(self): raw = "[Group] Anime - 01 [2024][1080p]" parsed = parse_classic_release_title(raw)