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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
This commit is contained in:
Estrella Pan
2026-07-07 17:50:25 +02:00
parent 64cfc1b1f8
commit bbc91d89f2
4 changed files with 78 additions and 5 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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('A<B>C: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/<folder>/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)

View File

@@ -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" <Y>', method="advance")
assert result == "Bangumi X Y S01E05.mkv"
def test_none_method(self):
"""none method: returns original media_path unchanged."""
ep = EpisodeFile(