Skip to content

Commit adf44d1

Browse files
EstrellaXDclaude
andcommitted
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>
1 parent e82e6ab commit adf44d1

4 files changed

Lines changed: 319 additions & 10 deletions

File tree

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@
8080
***已支持的下载器:***
8181

8282
- qBittorrent
83-
- Aria2
84-
- Transmission
8583

8684
## Star History
8785

backend/src/module/manager/renamer.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,14 @@ def gen_path(
6464
season = f"0{season_num}" if season_num < 10 else season_num
6565
# Apply episode offset
6666
original_episode = int(file_info.episode)
67-
adjusted_episode = original_episode + episode_offset
68-
# Episode 0 is valid for specials/OVAs when the source episode is already 0.
69-
# But an offset producing exactly 0 (e.g., EP12 + offset -12) is almost always
70-
# an off-by-one user error, so revert to original in that case.
67+
if original_episode == 0 and episode_offset != 0:
68+
# Episode 0 is a special/OVA — never apply offset to avoid
69+
# overwriting regular episodes (see issue #977)
70+
adjusted_episode = 0
71+
else:
72+
adjusted_episode = original_episode + episode_offset
73+
# An offset producing a non-positive result (e.g., EP5 + offset -10)
74+
# is almost always a misconfiguration, so revert to original.
7175
if adjusted_episode < 0 or (adjusted_episode == 0 and original_episode > 0):
7276
adjusted_episode = original_episode
7377
logger.warning(
@@ -138,7 +142,10 @@ async def rename_file(
138142
# Season comes from folder which already has offset applied
139143
# Only apply episode offset
140144
original_ep = int(ep.episode)
141-
adjusted_episode = original_ep + episode_offset
145+
if original_ep == 0 and episode_offset != 0:
146+
adjusted_episode = 0
147+
else:
148+
adjusted_episode = original_ep + episode_offset
142149
if adjusted_episode < 0 or (
143150
adjusted_episode == 0 and original_ep > 0
144151
):

backend/src/module/rss/engine.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,23 @@ async def _pull_rss_with_status(
112112

113113
def _get_filter_pattern(self, filter_str: str) -> re.Pattern:
114114
if filter_str not in self._filter_cache:
115-
self._filter_cache[filter_str] = re.compile(
116-
filter_str.replace(",", "|"), re.IGNORECASE
117-
)
115+
raw_pattern = filter_str.replace(",", "|")
116+
try:
117+
self._filter_cache[filter_str] = re.compile(
118+
raw_pattern, re.IGNORECASE
119+
)
120+
except re.error:
121+
# Filter contains invalid regex chars (e.g. unmatched '[')
122+
# Fall back to escaping each term for literal matching
123+
terms = filter_str.split(",")
124+
escaped = "|".join(re.escape(t) for t in terms)
125+
self._filter_cache[filter_str] = re.compile(
126+
escaped, re.IGNORECASE
127+
)
128+
logger.warning(
129+
f"[Engine] Filter '{filter_str}' contains invalid regex, "
130+
f"using literal matching"
131+
)
118132
return self._filter_cache[filter_str]
119133

120134
def match_torrent(self, torrent: Torrent) -> Optional[Bangumi]:
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
"""Tests reproducing bugs from GitHub issues #974, #976, #977, #986.
2+
3+
Each test class targets a specific issue with tests that demonstrate
4+
the current (buggy) behavior and the expected (fixed) behavior.
5+
"""
6+
7+
import re
8+
9+
import pytest
10+
11+
from module.models import EpisodeFile
12+
from module.manager.renamer import Renamer
13+
from module.parser.analyser.raw_parser import (
14+
get_group,
15+
process,
16+
raw_parser,
17+
)
18+
19+
20+
# ---------------------------------------------------------------------------
21+
# Issue #986: Parser fails on [group][title][episode_text] format
22+
# https://github.com/EstrellaXD/Auto_Bangumi/issues/986
23+
#
24+
# Torrent names from Atlas subtitle group use a [group][title][ep_text]
25+
# format instead of the typical [group] title - ep [tags] format.
26+
# The raw_parser's TITLE_RE regex doesn't match, returning None.
27+
# ---------------------------------------------------------------------------
28+
29+
30+
class TestIssue986AtlasSubGroupFormat:
31+
"""Issue #986: Parser crashes on Atlas subtitle group naming convention."""
32+
33+
ATLAS_TITLES = [
34+
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][04_半神们的卡农曲][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv",
35+
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv",
36+
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][03_无英灵的战斗][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv",
37+
]
38+
39+
def test_get_group_extracts_atlas_group(self):
40+
"""get_group should extract the group name from [group][title][ep] format."""
41+
name = "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][04_半神们的卡农曲]"
42+
group = get_group(name)
43+
assert group == "阿特拉斯字幕组·雪原市出差所"
44+
45+
def test_process_returns_none_for_atlas_format(self):
46+
"""process() currently returns None for Atlas format (bug demonstration)."""
47+
title = self.ATLAS_TITLES[0]
48+
result = process(title)
49+
# BUG: process returns None because TITLE_RE doesn't match this format
50+
assert result is None, (
51+
"If this passes, the parser still can't handle Atlas format. "
52+
"If it fails (result is not None), the bug may have been fixed!"
53+
)
54+
55+
def test_raw_parser_returns_none_for_atlas_format(self):
56+
"""raw_parser returns None for Atlas format, causing AttributeError downstream."""
57+
title = self.ATLAS_TITLES[0]
58+
result = raw_parser(title)
59+
# BUG: returns None → downstream code does .groups() on None → AttributeError
60+
assert result is None
61+
62+
@pytest.mark.parametrize("title", ATLAS_TITLES)
63+
def test_atlas_titles_all_fail_to_parse(self, title):
64+
"""All Atlas format titles fail to parse."""
65+
result = raw_parser(title)
66+
assert result is None
67+
68+
def test_get_group_returns_empty_for_no_brackets(self):
69+
"""get_group returns empty string for title without brackets (regression guard)."""
70+
result = get_group("No Brackets Title")
71+
assert result == ""
72+
73+
def test_get_group_does_not_crash_on_empty_string(self):
74+
"""get_group handles empty string without crashing."""
75+
result = get_group("")
76+
assert result == ""
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# Issue #977: Episode 0 (specials/OVAs) incorrectly renamed to E01
81+
# https://github.com/EstrellaXD/Auto_Bangumi/issues/977
82+
#
83+
# When a file is S01E00.mkv (episode 0 special), and there's a positive
84+
# episode_offset (e.g. from offset scanner), the renamer changes it to
85+
# S01E01.mkv which overwrites the real episode 1.
86+
# ---------------------------------------------------------------------------
87+
88+
89+
class TestIssue977EpisodeZeroOffset:
90+
"""Issue #977: Episode 0 should not be shifted by positive offset."""
91+
92+
def test_episode_zero_preserved_with_no_offset(self):
93+
"""Episode 0 with offset=0 stays as E00."""
94+
ep = EpisodeFile(
95+
media_path="old.mkv", title="Fate strange Fake", season=1,
96+
episode=0, suffix=".mkv",
97+
)
98+
result = Renamer.gen_path(ep, "Fate strange Fake", method="pn", episode_offset=0)
99+
assert "E00" in result
100+
101+
def test_episode_zero_immune_to_positive_offset(self):
102+
"""Episode 0 (special/OVA) should not be shifted by positive offset."""
103+
ep = EpisodeFile(
104+
media_path="old.mkv", title="Fate strange Fake", season=1,
105+
episode=0, suffix=".mkv",
106+
)
107+
result = Renamer.gen_path(ep, "Fate strange Fake", method="pn", episode_offset=1)
108+
assert "E00" in result
109+
110+
def test_episode_zero_immune_to_negative_offset(self):
111+
"""Episode 0 (special/OVA) should not be shifted by negative offset."""
112+
ep = EpisodeFile(
113+
media_path="old.mkv", title="Fate strange Fake", season=1,
114+
episode=0, suffix=".mkv",
115+
)
116+
result = Renamer.gen_path(ep, "Fate strange Fake", method="pn", episode_offset=-12)
117+
assert "E00" in result
118+
119+
def test_regular_episode_offset_still_works(self):
120+
"""Regular episodes should still be affected by offset normally."""
121+
ep = EpisodeFile(
122+
media_path="old.mkv", title="Test", season=1,
123+
episode=13, suffix=".mkv",
124+
)
125+
result = Renamer.gen_path(ep, "Test", method="pn", episode_offset=-12)
126+
assert "E01" in result # 13 - 12 = 1
127+
128+
def test_episode_zero_advance_method(self):
129+
"""Episode 0 with advance method and no offset stays E00."""
130+
ep = EpisodeFile(
131+
media_path="old.mkv", title="Test", season=1,
132+
episode=0, suffix=".mkv",
133+
)
134+
result = Renamer.gen_path(ep, "Bangumi Name", method="advance", episode_offset=0)
135+
assert result == "Bangumi Name S01E00.mkv"
136+
137+
138+
# ---------------------------------------------------------------------------
139+
# Issue #976: NoneType in match_list causes TypeError
140+
# https://github.com/EstrellaXD/Auto_Bangumi/issues/976
141+
#
142+
# When bangumi records have None as title_raw or aliases contain None,
143+
# sorted(title_index.keys(), key=len) crashes because len(None) fails.
144+
# Also, get_group crashes with IndexError on names without brackets.
145+
# ---------------------------------------------------------------------------
146+
147+
148+
class TestIssue976NoneInMatchList:
149+
"""Issue #976: match_list should handle None titles gracefully."""
150+
151+
def test_match_list_filters_none_title_raw(self, db_session):
152+
"""match_list should skip bangumi with title_raw=None."""
153+
from module.database.bangumi import BangumiDatabase
154+
from module.models import Bangumi
155+
156+
db = BangumiDatabase(db_session)
157+
158+
# Create bangumi with None-ish title_raw
159+
b1 = Bangumi(
160+
official_title="Normal Anime",
161+
year="2024",
162+
title_raw="[Group] Normal Anime",
163+
season=1,
164+
)
165+
db.add(b1)
166+
167+
# The match_list code now checks `if m.title_raw:` before adding to index
168+
# This test verifies that path works when all entries are valid
169+
match_datas = db.search_all()
170+
title_index = {}
171+
for m in match_datas:
172+
if m.title_raw:
173+
title_index[m.title_raw] = m
174+
175+
# Should not raise TypeError
176+
sorted_titles = sorted(title_index.keys(), key=len, reverse=True)
177+
assert len(sorted_titles) == 1
178+
179+
def test_sorted_with_none_key_raises_typeerror(self):
180+
"""Demonstrate that sorted() with None keys crashes (the original bug)."""
181+
title_index = {"valid_title": "data", None: "bad_data"}
182+
with pytest.raises(TypeError, match="'NoneType'"):
183+
sorted(title_index.keys(), key=len, reverse=True)
184+
185+
def test_empty_title_index_produces_empty_pattern(self):
186+
"""When all titles are None/empty, the regex pattern should be empty."""
187+
title_index = {}
188+
sorted_titles = sorted(title_index.keys(), key=len, reverse=True)
189+
pattern = "|".join(re.escape(t) for t in sorted_titles)
190+
assert pattern == ""
191+
192+
def test_get_group_no_brackets_returns_empty(self):
193+
"""get_group handles names without brackets (regression for IndexError)."""
194+
# The original code did: re.split(r"[\[\]]", name)[1]
195+
# which crashes with IndexError when there are no brackets
196+
result = get_group("No Brackets At All")
197+
assert result == ""
198+
199+
def test_get_group_single_bracket_pair(self):
200+
"""get_group extracts group from single bracket pair."""
201+
result = get_group("[GroupName] Some Title")
202+
assert result == "GroupName"
203+
204+
def test_get_group_empty_brackets(self):
205+
"""get_group handles empty brackets."""
206+
result = get_group("[] empty")
207+
assert result == ""
208+
209+
210+
# ---------------------------------------------------------------------------
211+
# Issue #974: PatternError when filter string contains regex special chars
212+
# https://github.com/EstrellaXD/Auto_Bangumi/issues/974
213+
#
214+
# The _get_filter_pattern method does filter_str.replace(",", "|") and
215+
# then re.compile(). If the filter contains regex special characters
216+
# like [ ] ( ) etc., it causes PatternError.
217+
# ---------------------------------------------------------------------------
218+
219+
220+
class TestIssue974FilterPatternError:
221+
"""Issue #974: Filter strings with regex special chars crash re.compile."""
222+
223+
def test_normal_filter_compiles(self):
224+
"""Normal filter string like '720,繁体' works fine."""
225+
filter_str = "720,繁体"
226+
pattern_str = filter_str.replace(",", "|")
227+
pattern = re.compile(pattern_str, re.IGNORECASE)
228+
assert pattern.search("720p test")
229+
assert pattern.search("繁体字幕")
230+
assert not pattern.search("1080p 简体")
231+
232+
def test_raw_unterminated_bracket_is_invalid_regex(self):
233+
"""Demonstrate that unterminated '[' is invalid regex."""
234+
filter_str = "720,[字幕组"
235+
pattern_str = filter_str.replace(",", "|")
236+
with pytest.raises(re.error):
237+
re.compile(pattern_str, re.IGNORECASE)
238+
239+
def test_engine_handles_unterminated_bracket(self):
240+
"""_get_filter_pattern falls back to literal matching for invalid regex."""
241+
from module.rss.engine import RSSEngine
242+
from unittest.mock import MagicMock
243+
244+
engine = RSSEngine.__new__(RSSEngine)
245+
engine._filter_cache = {}
246+
pattern = engine._get_filter_pattern("720,[字幕组")
247+
# Should not raise — falls back to escaped literal matching
248+
assert pattern.search("720p video")
249+
assert pattern.search("[字幕组 release")
250+
assert not pattern.search("1080p no match")
251+
252+
def test_engine_handles_unmatched_parenthesis(self):
253+
"""_get_filter_pattern falls back for unmatched '('."""
254+
from module.rss.engine import RSSEngine
255+
256+
engine = RSSEngine.__new__(RSSEngine)
257+
engine._filter_cache = {}
258+
pattern = engine._get_filter_pattern("720,test(v2")
259+
assert pattern.search("720p")
260+
assert pattern.search("test(v2 stuff")
261+
262+
def test_engine_handles_trailing_backslash(self):
263+
"""_get_filter_pattern falls back for trailing backslash."""
264+
from module.rss.engine import RSSEngine
265+
266+
engine = RSSEngine.__new__(RSSEngine)
267+
engine._filter_cache = {}
268+
pattern = engine._get_filter_pattern("720,path\\")
269+
assert pattern.search("720p")
270+
271+
def test_engine_default_filter_still_uses_regex(self):
272+
r"""Default filter '720,\d+-\d+' is valid regex and used as-is."""
273+
from module.rss.engine import RSSEngine
274+
275+
engine = RSSEngine.__new__(RSSEngine)
276+
engine._filter_cache = {}
277+
pattern = engine._get_filter_pattern(r"720,\d+-\d+")
278+
assert pattern.search("720p video")
279+
assert pattern.search("01-12 batch")
280+
assert not pattern.search("1080p single episode")
281+
282+
def test_engine_caches_filter_pattern(self):
283+
"""Filter patterns are cached to avoid recompilation."""
284+
from module.rss.engine import RSSEngine
285+
286+
engine = RSSEngine.__new__(RSSEngine)
287+
engine._filter_cache = {}
288+
p1 = engine._get_filter_pattern("720,1080")
289+
p2 = engine._get_filter_pattern("720,1080")
290+
assert p1 is p2

0 commit comments

Comments
 (0)