From bbc91d89f2054a83c4d4886cd67e2859655d078a Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:50:25 +0200 Subject: [PATCH] fix(renamer): sanitize reserved characters in generated paths (#721) Titles containing Windows/qB reserved characters (e.g. "Fate/Zero") were joined raw into save paths and filenames, letting qBittorrent split them into extra directories or silently truncate. Replace <>:"/\|?* and control chars with a space in the save-path folder and in every rename method's title component; collapse whitespace and strip trailing dots/spaces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/downloader/path.py | 17 ++++++++++++++- backend/src/module/manager/renamer.py | 17 +++++++++++---- backend/src/test/test_path.py | 30 +++++++++++++++++++++++++++ backend/src/test/test_renamer.py | 19 +++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index c0bbd183..d3eb1a55 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -21,6 +21,21 @@ else: _MEDIA_SUFFIXES = frozenset({".mp4", ".mkv"}) _SUBTITLE_SUFFIXES = frozenset({".ass", ".srt"}) +# Windows/qB 保留字符 + 控制字符:出现在路径片段里会被下载器拆成多级 +# 目录或直接丢字(qB 静默截断),必须在拼路径前替换掉 (#721) +_ILLEGAL_PATH_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def sanitize_path_fragment(name: str) -> str: + """把单个路径片段(文件夹名或文件名,不含分隔符)里的保留字符替换为空格。 + + 多余空白折叠为单个空格;Windows 不允许目录/文件名以点或空格结尾, + 一并去掉。幂等:对已合法的名字原样返回。 + """ + cleaned = _ILLEGAL_PATH_CHARS_RE.sub(" ", name) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned.rstrip(". ") + def check_files(files: list[dict]): media_list = [] @@ -72,7 +87,7 @@ def gen_save_path(data: Bangumi | BangumiUpdate): specials/OVA/OAD land in "Season 0" (Jellyfin/Plex convention) instead of being interleaved with regular episodes. """ - folder = ( + folder = sanitize_path_fragment( f"{data.official_title} ({data.year})" if data.year else data.official_title ) episode_type = getattr(data, "episode_type", "episode") diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 7fb7512c..37798a18 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -11,7 +11,12 @@ from module.database.bangumi import ( normalize_save_path, ) from module.downloader import DownloadClient -from module.downloader.path import check_files, is_ep, path_to_bangumi +from module.downloader.path import ( + check_files, + is_ep, + path_to_bangumi, + sanitize_path_fragment, +) from module.models import EpisodeFile, Notification, SubtitleFile from module.parser import TitleParser @@ -90,10 +95,14 @@ class Renamer: # 否则升级后会触发整库批量重命名,破坏 Plex/Jellyfin 索引与硬链接 if method == "none" or method == "subtitle_none": return file_info.media_path + # 标题可能带有 Windows/qB 保留字符(如 "Fate/Zero"),直接拼进 + # 文件名会被下载器拆成子目录或丢字 (#721) + title = sanitize_path_fragment(file_info.title) + bangumi_name = sanitize_path_fragment(bangumi_name) if file_info.episode_type == "movie": # 电影/剧场版:Title (Year).ext,不使用 SxxExx 编号。bangumi_name 由 # 调用方传入,与 gen_save_path 的文件夹命名保持一致 (Title (Year)) - base = bangumi_name if "advance" in method else file_info.title + base = bangumi_name if "advance" in method else title if method.startswith("subtitle_"): assert isinstance( file_info, SubtitleFile @@ -101,7 +110,7 @@ class Renamer: return f"{base}.{file_info.language}{file_info.suffix}" return f"{base}{file_info.suffix}" elif method == "pn": - return f"{file_info.title} S{season}E{episode}{file_info.suffix}" + return f"{title} S{season}E{episode}{file_info.suffix}" elif method == "advance": return f"{bangumi_name} S{season}E{episode}{file_info.suffix}" elif method == "normal": @@ -111,7 +120,7 @@ class Renamer: assert isinstance( file_info, SubtitleFile ), "subtitle_pn requires a SubtitleFile" - return f"{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}" + return f"{title} S{season}E{episode}.{file_info.language}{file_info.suffix}" elif method == "subtitle_advance": assert isinstance( file_info, SubtitleFile diff --git a/backend/src/test/test_path.py b/backend/src/test/test_path.py index 0fa198c6..ac1a70fc 100644 --- a/backend/src/test/test_path.py +++ b/backend/src/test/test_path.py @@ -9,9 +9,29 @@ from module.downloader.path import ( is_ep, path_to_bangumi, rule_name, + sanitize_path_fragment, ) from test.factories import make_bangumi +# --------------------------------------------------------------------------- +# sanitize_path_fragment +# --------------------------------------------------------------------------- + + +class TestSanitizePathFragment: + def test_replaces_reserved_characters(self): + assert sanitize_path_fragment('AC:D"E/F\\G|H?I*J') == "A B C D E F G H I J" + + def test_collapses_whitespace_and_strips_trailing_dots(self): + assert sanitize_path_fragment("Name ...") == "Name" + + def test_preserves_cjk_and_brackets(self): + assert sanitize_path_fragment("[Sub] 孤独摇滚!(2022)") == "[Sub] 孤独摇滚!(2022)" + + def test_idempotent(self): + once = sanitize_path_fragment("Fate/Zero: Part?2") + assert sanitize_path_fragment(once) == once + # --------------------------------------------------------------------------- # gen_save_path # --------------------------------------------------------------------------- @@ -28,6 +48,16 @@ class TestGenSavePath: assert "My Anime (2024)" in result assert "Season 2" in result + def test_reserved_characters_sanitized_in_folder(self): + """标题里的保留字符不能把保存路径拆成多级目录 (#721)。""" + bangumi = make_bangumi(official_title="Fate/Zero: Part?2", year="2024") + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert "Fate Zero Part 2 (2024)" in result + assert result.count("/") == 4 # /downloads/Bangumi//Season 1 + def test_without_year(self): """Save path omits year parentheses when year is None.""" bangumi = make_bangumi(official_title="My Anime", year=None, season=1) diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index e71f2127..8d093940 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -31,6 +31,25 @@ class TestGenPath: result = Renamer.gen_path(ep, "Bangumi Name", method="advance") assert result == "Bangumi Name S02E12.mkv" + def test_pn_reserved_characters_sanitized(self): + """标题里的保留字符(/ : ?)不能进入生成的文件名 (#721)。""" + ep = EpisodeFile( + media_path="old.mkv", + title="Anime: Re/Start?", + season=1, + episode=5, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "Anime Re Start S01E05.mkv" + + def test_advance_reserved_characters_sanitized(self): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, 'Bangumi "X" ', method="advance") + assert result == "Bangumi X Y S01E05.mkv" + def test_none_method(self): """none method: returns original media_path unchanged.""" ep = EpisodeFile(