diff --git a/README.md b/README.md index 7daa04b1..75386a67 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,6 @@ ***已支持的下载器:*** - qBittorrent -- Aria2 -- Transmission ## Star History diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index df64a6a2..ad1374d2 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -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 ): diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index eaea3dc2..a9dc9dac 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -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]: diff --git a/backend/src/test/test_issue_bugs.py b/backend/src/test/test_issue_bugs.py new file mode 100644 index 00000000..18322f9e --- /dev/null +++ b/backend/src/test/test_issue_bugs.py @@ -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 = [ + "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][04_半神们的卡农曲][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv", + "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv", + "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange 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 = "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange 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