mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 13:45:42 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user