mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-03-20 03:46:40 +08:00
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:
@@ -80,8 +80,6 @@
|
||||
***已支持的下载器:***
|
||||
|
||||
- qBittorrent
|
||||
- Aria2
|
||||
- Transmission
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
290
backend/src/test/test_issue_bugs.py
Normal file
290
backend/src/test/test_issue_bugs.py
Normal 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 = [
|
||||
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_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
|
||||
Reference in New Issue
Block a user