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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf
This commit is contained in:
Estrella Pan
2026-07-26 11:39:24 +02:00
parent 520873f035
commit dee536cdc9
3 changed files with 108 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)