fix(renamer,rss): preserve episode 0 specials and handle invalid filter regex

- Skip episode offset for episode 0 (specials/OVAs) to prevent overwriting
  regular episodes (fixes #977)
- Catch re.PatternError in RSS filter compilation and fall back to literal
  matching when user filter contains invalid regex chars (fixes #974)
- Remove Aria2 and Transmission from README supported downloaders list
  (addresses #987)
- Add regression tests for issues #974, #976, #977, #986

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-02-23 12:03:59 +01:00
parent e82e6ab128
commit adf44d140a
4 changed files with 319 additions and 10 deletions

View File

@@ -80,8 +80,6 @@
***已支持的下载器:***
- qBittorrent
- Aria2
- Transmission
## Star History

View File

@@ -64,10 +64,14 @@ class Renamer(DownloadClient):
season = f"0{season_num}" if season_num < 10 else season_num
# Apply episode offset
original_episode = int(file_info.episode)
adjusted_episode = original_episode + episode_offset
# Episode 0 is valid for specials/OVAs when the source episode is already 0.
# But an offset producing exactly 0 (e.g., EP12 + offset -12) is almost always
# an off-by-one user error, so revert to original in that case.
if original_episode == 0 and episode_offset != 0:
# Episode 0 is a special/OVA — never apply offset to avoid
# overwriting regular episodes (see issue #977)
adjusted_episode = 0
else:
adjusted_episode = original_episode + episode_offset
# An offset producing a non-positive result (e.g., EP5 + offset -10)
# is almost always a misconfiguration, so revert to original.
if adjusted_episode < 0 or (adjusted_episode == 0 and original_episode > 0):
adjusted_episode = original_episode
logger.warning(
@@ -138,7 +142,10 @@ class Renamer(DownloadClient):
# Season comes from folder which already has offset applied
# Only apply episode offset
original_ep = int(ep.episode)
adjusted_episode = original_ep + episode_offset
if original_ep == 0 and episode_offset != 0:
adjusted_episode = 0
else:
adjusted_episode = original_ep + episode_offset
if adjusted_episode < 0 or (
adjusted_episode == 0 and original_ep > 0
):

View File

@@ -112,9 +112,23 @@ class RSSEngine(Database):
def _get_filter_pattern(self, filter_str: str) -> re.Pattern:
if filter_str not in self._filter_cache:
self._filter_cache[filter_str] = re.compile(
filter_str.replace(",", "|"), re.IGNORECASE
)
raw_pattern = filter_str.replace(",", "|")
try:
self._filter_cache[filter_str] = re.compile(
raw_pattern, re.IGNORECASE
)
except re.error:
# Filter contains invalid regex chars (e.g. unmatched '[')
# Fall back to escaping each term for literal matching
terms = filter_str.split(",")
escaped = "|".join(re.escape(t) for t in terms)
self._filter_cache[filter_str] = re.compile(
escaped, re.IGNORECASE
)
logger.warning(
f"[Engine] Filter '{filter_str}' contains invalid regex, "
f"using literal matching"
)
return self._filter_cache[filter_str]
def match_torrent(self, torrent: Torrent) -> Optional[Bangumi]:

View File

@@ -0,0 +1,290 @@
"""Tests reproducing bugs from GitHub issues #974, #976, #977, #986.
Each test class targets a specific issue with tests that demonstrate
the current (buggy) behavior and the expected (fixed) behavior.
"""
import re
import pytest
from module.models import EpisodeFile
from module.manager.renamer import Renamer
from module.parser.analyser.raw_parser import (
get_group,
process,
raw_parser,
)
# ---------------------------------------------------------------------------
# Issue #986: Parser fails on [group][title][episode_text] format
# https://github.com/EstrellaXD/Auto_Bangumi/issues/986
#
# Torrent names from Atlas subtitle group use a [group][title][ep_text]
# format instead of the typical [group] title - ep [tags] format.
# The raw_parser's TITLE_RE regex doesn't match, returning None.
# ---------------------------------------------------------------------------
class TestIssue986AtlasSubGroupFormat:
"""Issue #986: Parser crashes on Atlas subtitle group naming convention."""
ATLAS_TITLES = [
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fatestrange Fake][04_半神们的卡农曲][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv",
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fatestrange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv",
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fatestrange Fake][03_无英灵的战斗][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv",
]
def test_get_group_extracts_atlas_group(self):
"""get_group should extract the group name from [group][title][ep] format."""
name = "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fatestrange Fake][04_半神们的卡农曲]"
group = get_group(name)
assert group == "阿特拉斯字幕组·雪原市出差所"
def test_process_returns_none_for_atlas_format(self):
"""process() currently returns None for Atlas format (bug demonstration)."""
title = self.ATLAS_TITLES[0]
result = process(title)
# BUG: process returns None because TITLE_RE doesn't match this format
assert result is None, (
"If this passes, the parser still can't handle Atlas format. "
"If it fails (result is not None), the bug may have been fixed!"
)
def test_raw_parser_returns_none_for_atlas_format(self):
"""raw_parser returns None for Atlas format, causing AttributeError downstream."""
title = self.ATLAS_TITLES[0]
result = raw_parser(title)
# BUG: returns None → downstream code does .groups() on None → AttributeError
assert result is None
@pytest.mark.parametrize("title", ATLAS_TITLES)
def test_atlas_titles_all_fail_to_parse(self, title):
"""All Atlas format titles fail to parse."""
result = raw_parser(title)
assert result is None
def test_get_group_returns_empty_for_no_brackets(self):
"""get_group returns empty string for title without brackets (regression guard)."""
result = get_group("No Brackets Title")
assert result == ""
def test_get_group_does_not_crash_on_empty_string(self):
"""get_group handles empty string without crashing."""
result = get_group("")
assert result == ""
# ---------------------------------------------------------------------------
# Issue #977: Episode 0 (specials/OVAs) incorrectly renamed to E01
# https://github.com/EstrellaXD/Auto_Bangumi/issues/977
#
# When a file is S01E00.mkv (episode 0 special), and there's a positive
# episode_offset (e.g. from offset scanner), the renamer changes it to
# S01E01.mkv which overwrites the real episode 1.
# ---------------------------------------------------------------------------
class TestIssue977EpisodeZeroOffset:
"""Issue #977: Episode 0 should not be shifted by positive offset."""
def test_episode_zero_preserved_with_no_offset(self):
"""Episode 0 with offset=0 stays as E00."""
ep = EpisodeFile(
media_path="old.mkv", title="Fate strange Fake", season=1,
episode=0, suffix=".mkv",
)
result = Renamer.gen_path(ep, "Fate strange Fake", method="pn", episode_offset=0)
assert "E00" in result
def test_episode_zero_immune_to_positive_offset(self):
"""Episode 0 (special/OVA) should not be shifted by positive offset."""
ep = EpisodeFile(
media_path="old.mkv", title="Fate strange Fake", season=1,
episode=0, suffix=".mkv",
)
result = Renamer.gen_path(ep, "Fate strange Fake", method="pn", episode_offset=1)
assert "E00" in result
def test_episode_zero_immune_to_negative_offset(self):
"""Episode 0 (special/OVA) should not be shifted by negative offset."""
ep = EpisodeFile(
media_path="old.mkv", title="Fate strange Fake", season=1,
episode=0, suffix=".mkv",
)
result = Renamer.gen_path(ep, "Fate strange Fake", method="pn", episode_offset=-12)
assert "E00" in result
def test_regular_episode_offset_still_works(self):
"""Regular episodes should still be affected by offset normally."""
ep = EpisodeFile(
media_path="old.mkv", title="Test", season=1,
episode=13, suffix=".mkv",
)
result = Renamer.gen_path(ep, "Test", method="pn", episode_offset=-12)
assert "E01" in result # 13 - 12 = 1
def test_episode_zero_advance_method(self):
"""Episode 0 with advance method and no offset stays E00."""
ep = EpisodeFile(
media_path="old.mkv", title="Test", season=1,
episode=0, suffix=".mkv",
)
result = Renamer.gen_path(ep, "Bangumi Name", method="advance", episode_offset=0)
assert result == "Bangumi Name S01E00.mkv"
# ---------------------------------------------------------------------------
# Issue #976: NoneType in match_list causes TypeError
# https://github.com/EstrellaXD/Auto_Bangumi/issues/976
#
# When bangumi records have None as title_raw or aliases contain None,
# sorted(title_index.keys(), key=len) crashes because len(None) fails.
# Also, get_group crashes with IndexError on names without brackets.
# ---------------------------------------------------------------------------
class TestIssue976NoneInMatchList:
"""Issue #976: match_list should handle None titles gracefully."""
def test_match_list_filters_none_title_raw(self, db_session):
"""match_list should skip bangumi with title_raw=None."""
from module.database.bangumi import BangumiDatabase
from module.models import Bangumi
db = BangumiDatabase(db_session)
# Create bangumi with None-ish title_raw
b1 = Bangumi(
official_title="Normal Anime",
year="2024",
title_raw="[Group] Normal Anime",
season=1,
)
db.add(b1)
# The match_list code now checks `if m.title_raw:` before adding to index
# This test verifies that path works when all entries are valid
match_datas = db.search_all()
title_index = {}
for m in match_datas:
if m.title_raw:
title_index[m.title_raw] = m
# Should not raise TypeError
sorted_titles = sorted(title_index.keys(), key=len, reverse=True)
assert len(sorted_titles) == 1
def test_sorted_with_none_key_raises_typeerror(self):
"""Demonstrate that sorted() with None keys crashes (the original bug)."""
title_index = {"valid_title": "data", None: "bad_data"}
with pytest.raises(TypeError, match="'NoneType'"):
sorted(title_index.keys(), key=len, reverse=True)
def test_empty_title_index_produces_empty_pattern(self):
"""When all titles are None/empty, the regex pattern should be empty."""
title_index = {}
sorted_titles = sorted(title_index.keys(), key=len, reverse=True)
pattern = "|".join(re.escape(t) for t in sorted_titles)
assert pattern == ""
def test_get_group_no_brackets_returns_empty(self):
"""get_group handles names without brackets (regression for IndexError)."""
# The original code did: re.split(r"[\[\]]", name)[1]
# which crashes with IndexError when there are no brackets
result = get_group("No Brackets At All")
assert result == ""
def test_get_group_single_bracket_pair(self):
"""get_group extracts group from single bracket pair."""
result = get_group("[GroupName] Some Title")
assert result == "GroupName"
def test_get_group_empty_brackets(self):
"""get_group handles empty brackets."""
result = get_group("[] empty")
assert result == ""
# ---------------------------------------------------------------------------
# Issue #974: PatternError when filter string contains regex special chars
# https://github.com/EstrellaXD/Auto_Bangumi/issues/974
#
# The _get_filter_pattern method does filter_str.replace(",", "|") and
# then re.compile(). If the filter contains regex special characters
# like [ ] ( ) etc., it causes PatternError.
# ---------------------------------------------------------------------------
class TestIssue974FilterPatternError:
"""Issue #974: Filter strings with regex special chars crash re.compile."""
def test_normal_filter_compiles(self):
"""Normal filter string like '720,繁体' works fine."""
filter_str = "720,繁体"
pattern_str = filter_str.replace(",", "|")
pattern = re.compile(pattern_str, re.IGNORECASE)
assert pattern.search("720p test")
assert pattern.search("繁体字幕")
assert not pattern.search("1080p 简体")
def test_raw_unterminated_bracket_is_invalid_regex(self):
"""Demonstrate that unterminated '[' is invalid regex."""
filter_str = "720,[字幕组"
pattern_str = filter_str.replace(",", "|")
with pytest.raises(re.error):
re.compile(pattern_str, re.IGNORECASE)
def test_engine_handles_unterminated_bracket(self):
"""_get_filter_pattern falls back to literal matching for invalid regex."""
from module.rss.engine import RSSEngine
from unittest.mock import MagicMock
engine = RSSEngine.__new__(RSSEngine)
engine._filter_cache = {}
pattern = engine._get_filter_pattern("720,[字幕组")
# Should not raise — falls back to escaped literal matching
assert pattern.search("720p video")
assert pattern.search("[字幕组 release")
assert not pattern.search("1080p no match")
def test_engine_handles_unmatched_parenthesis(self):
"""_get_filter_pattern falls back for unmatched '('."""
from module.rss.engine import RSSEngine
engine = RSSEngine.__new__(RSSEngine)
engine._filter_cache = {}
pattern = engine._get_filter_pattern("720,test(v2")
assert pattern.search("720p")
assert pattern.search("test(v2 stuff")
def test_engine_handles_trailing_backslash(self):
"""_get_filter_pattern falls back for trailing backslash."""
from module.rss.engine import RSSEngine
engine = RSSEngine.__new__(RSSEngine)
engine._filter_cache = {}
pattern = engine._get_filter_pattern("720,path\\")
assert pattern.search("720p")
def test_engine_default_filter_still_uses_regex(self):
r"""Default filter '720,\d+-\d+' is valid regex and used as-is."""
from module.rss.engine import RSSEngine
engine = RSSEngine.__new__(RSSEngine)
engine._filter_cache = {}
pattern = engine._get_filter_pattern(r"720,\d+-\d+")
assert pattern.search("720p video")
assert pattern.search("01-12 batch")
assert not pattern.search("1080p single episode")
def test_engine_caches_filter_pattern(self):
"""Filter patterns are cached to avoid recompilation."""
from module.rss.engine import RSSEngine
engine = RSSEngine.__new__(RSSEngine)
engine._filter_cache = {}
p1 = engine._get_filter_pattern("720,1080")
p2 = engine._get_filter_pattern("720,1080")
assert p1 is p2