fix(renamer): distinct names for multi-file movies, revert group-tag prefix, season floor

- Multi-file movie torrents: the largest file keeps "Title (Year).ext",
  extras get "Title (Year) - <original-stem>.ext" (idempotent across
  rename cycles) — no more per-cycle qB conflicts or aria2 collisions
- Reverted the "[group] " filename prefix: group_tag historically only
  named the qB RSS rule; prefixing renames would mass-rename existing
  seeding libraries on upgrade
- Season floor restored for regular episodes (season+offset<1 reverts
  to the original season); only specials may land in Season 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
This commit is contained in:
Estrella Pan
2026-07-02 22:07:12 +02:00
parent 1f5a6195d8
commit d422e615b1
5 changed files with 228 additions and 52 deletions

View File

@@ -12,6 +12,9 @@
### Fixed
- 修复 `gen_save_path` 季度守卫的一处预置 off-by-one 缺陷:合法的 `season=0`(特别篇)此前会被误判并回退为原值并打印多余警告
- 收紧 `gen_save_path` 季度守卫:普通剧集在 `season_offset` 为负导致季号落到 0 时回退原季号(Season 0 会被 Plex/Jellyfin 当作特别篇),仅 `special` 类型允许合法落入第 0 季
- 撤销 `gen_path` 在重命名文件名前追加 `[字幕组] ` 前缀的改动(`group_tag` 恢复为仅影响 qBittorrent RSS 规则名):避免已开启 `group_tag` 的用户升级后整个做种媒体库被批量重命名,破坏 Plex/Jellyfin 索引与 cross-seed/硬链接
- 修复多文件电影种子(正片 + 特典/花絮,BD 常见)所有文件生成相同目标文件名导致重命名互相冲突/覆盖的问题:体积最大的主文件使用「标题 (年份).ext」干净名,其余文件追加原始文件名词干作区分
## Frontend

View File

@@ -81,8 +81,11 @@ def gen_save_path(data: Bangumi | BangumiUpdate):
return str(Path(settings.downloader.path) / folder)
# Apply season_offset to get the adjusted season number for the folder
adjusted_season = data.season + getattr(data, "season_offset", 0)
if adjusted_season < 0:
adjusted_season = data.season # Safety: don't go below 0 (0 = specials)
# 季号下限:普通剧集最小为 1——偏移到 Season 0 会被 Plex/Jellyfin 当作
# 特别篇;只有特别篇(special)允许合法落入第 0 季
min_season = 0 if episode_type == "special" else 1
if adjusted_season < min_season:
adjusted_season = data.season
logger.warning(
f"[Path] Season offset would result in invalid season for {data.official_title}, using original season"
)

View File

@@ -1,6 +1,7 @@
import asyncio
import logging
import time
from pathlib import PurePath
from module.conf import settings
from module.database import Database
@@ -79,13 +80,9 @@ class Renamer:
f"[Renamer] Episode offset {episode_offset} would make episode {original_episode} non-positive, ignoring offset"
)
episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode
# 与旧版 downloader/path.py 的 rule_name 行为保持一致:开启 group_tag 时,
# 在文件名前加上 "[字幕组] " 前缀
group_prefix = (
f"[{file_info.group}] "
if settings.bangumi_manage.group_tag and file_info.group
else ""
)
# 注意:group_tag 只影响 qB RSS 规则名(downloader/path.py 的 rule_name),
# 从不写进重命名后的文件名——已有做种媒体库的文件名必须保持稳定,
# 否则升级后会触发整库批量重命名,破坏 Plex/Jellyfin 索引与硬链接
if method == "none" or method == "subtitle_none":
return file_info.media_path
if file_info.episode_type == "movie":
@@ -96,14 +93,12 @@ class Renamer:
assert isinstance(
file_info, SubtitleFile
), "subtitle methods require a SubtitleFile"
return f"{group_prefix}{base}.{file_info.language}{file_info.suffix}"
return f"{group_prefix}{base}{file_info.suffix}"
return f"{base}.{file_info.language}{file_info.suffix}"
return f"{base}{file_info.suffix}"
elif method == "pn":
return (
f"{group_prefix}{file_info.title} S{season}E{episode}{file_info.suffix}"
)
return f"{file_info.title} S{season}E{episode}{file_info.suffix}"
elif method == "advance":
return f"{group_prefix}{bangumi_name} S{season}E{episode}{file_info.suffix}"
return f"{bangumi_name} S{season}E{episode}{file_info.suffix}"
elif method == "normal":
logger.warning("[Renamer] Normal rename method is deprecated.")
return file_info.media_path
@@ -111,12 +106,12 @@ class Renamer:
assert isinstance(
file_info, SubtitleFile
), "subtitle_pn requires a SubtitleFile"
return f"{group_prefix}{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}"
return f"{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}"
elif method == "subtitle_advance":
assert isinstance(
file_info, SubtitleFile
), "subtitle_advance requires a SubtitleFile"
return f"{group_prefix}{bangumi_name} S{season}E{episode}.{file_info.language}{file_info.suffix}"
return f"{bangumi_name} S{season}E{episode}.{file_info.language}{file_info.suffix}"
else:
logger.error(f"[Renamer] Unknown rename method: {method}")
return file_info.media_path
@@ -195,6 +190,19 @@ class Renamer:
await self.client.delete_torrent(hashes=_hash)
return None
@staticmethod
def _gen_movie_extra_path(new_path: str, media_path: str) -> str:
"""多文件电影种子中,非主文件在干净名(Title (Year).ext)基础上追加
原始文件名词干作区分,避免与主文件生成相同目标名互相冲突/覆盖;
词干若已带 "Title (Year) - " 前缀则先剥离,保证重命名幂等。"""
suffix = PurePath(new_path).suffix
base = new_path[: -len(suffix)] if suffix else new_path
stem = PurePath(media_path).stem
prefix = f"{base} - "
if stem.startswith(prefix):
stem = stem[len(prefix) :]
return f"{base} - {stem}{suffix}"
async def rename_collection(
self,
media_list: list[str],
@@ -205,8 +213,19 @@ class Renamer:
episode_offset: int = 0,
season_offset: int = 0,
episode_type: str = "episode",
file_sizes: dict[str, int] | None = None,
**kwargs,
):
# 多文件电影种子(正片 + 特典/花絮):所有文件会解析出同一标题,
# 需选出主文件(体积最大者,无体积信息时取首个),其余文件追加区分词干
movie_primary: str | None = None
if episode_type == "movie":
ep_list = [p for p in media_list if is_ep(p)]
if ep_list:
if file_sizes:
movie_primary = max(ep_list, key=lambda p: file_sizes.get(p, 0))
else:
movie_primary = ep_list[0]
for media_path in media_list:
if is_ep(media_path):
ep = self._parser.torrent_parser(
@@ -222,6 +241,13 @@ class Renamer:
episode_offset=episode_offset,
season_offset=season_offset,
)
if (
movie_primary is not None
and media_path != movie_primary
and new_path != media_path
):
# new_path == media_path 说明是 none 等直通方法,不做区分
new_path = self._gen_movie_extra_path(new_path, media_path)
if media_path != new_path:
renamed = await self.client.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
@@ -543,7 +569,11 @@ class Renamer:
# Rename collection
elif len(media_list) > 1:
logger.info("[Renamer] Start rename collection")
await self.rename_collection(media_list=media_list, **kwargs)
# 传入各文件体积,供多文件电影种子选出正片主文件
file_sizes = {f["name"]: f.get("size") or 0 for f in files}
await self.rename_collection(
media_list=media_list, file_sizes=file_sizes, **kwargs
)
if len(subtitle_list) > 0:
await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
await self.client.set_category(torrent_hash, "BangumiCollection")

View File

@@ -82,6 +82,52 @@ class TestGenSavePath:
assert result == "/downloads/Bangumi/My Anime (2024)/Season 0"
def test_gen_save_path_regular_offset_to_zero_reverts_to_original_season(self):
"""普通剧集 season+offset 落到 0 时回退原季号:Season 0 会被
Plex/Jellyfin 当作特别篇,只有 special 类型才允许落入。"""
bangumi = make_bangumi(
official_title="My Anime",
year="2024",
season=1,
season_offset=-1,
episode_type="episode",
)
with patch("module.downloader.path.settings") as mock_settings:
mock_settings.downloader.path = "/downloads/Bangumi"
result = gen_save_path(bangumi)
assert result == "/downloads/Bangumi/My Anime (2024)/Season 1"
def test_gen_save_path_special_offset_to_zero_lands_in_season_zero(self):
"""特别篇(special)经偏移落到第 0 季是合法的(Jellyfin/Plex 惯例)。"""
bangumi = make_bangumi(
official_title="My Anime",
year="2024",
season=1,
season_offset=-1,
episode_type="special",
)
with patch("module.downloader.path.settings") as mock_settings:
mock_settings.downloader.path = "/downloads/Bangumi"
result = gen_save_path(bangumi)
assert result == "/downloads/Bangumi/My Anime (2024)/Season 0"
def test_gen_save_path_special_offset_below_zero_reverts_to_original_season(self):
"""特别篇偏移到负季号仍属非法配置,回退原季号。"""
bangumi = make_bangumi(
official_title="My Anime",
year="2024",
season=0,
season_offset=-1,
episode_type="special",
)
with patch("module.downloader.path.settings") as mock_settings:
mock_settings.downloader.path = "/downloads/Bangumi"
result = gen_save_path(bangumi)
assert result == "/downloads/Bangumi/My Anime (2024)/Season 0"
# ---------------------------------------------------------------------------
# rule_name

View File

@@ -161,7 +161,8 @@ class TestGenPathMovie:
result = Renamer.gen_path(sub, "天气之子 (2019)", method="subtitle_advance")
assert result == "天气之子 (2019).zh.ass"
def test_group_tag_prefix_applied(self):
def test_gen_path_movie_group_tag_enabled_keeps_clean_name(self):
"""group_tag 不影响重命名文件名(只影响 qB RSS 规则名)。"""
ep = EpisodeFile(
media_path="raw.mkv",
group="Lilith-Raws",
@@ -173,7 +174,7 @@ class TestGenPathMovie:
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(ep, "天气之子 (2019)", method="advance")
assert result == "[Lilith-Raws] 天气之子 (2019).mkv"
assert result == "天气之子 (2019).mkv"
def test_none_method_returns_original_path(self):
ep = EpisodeFile(
@@ -193,12 +194,13 @@ class TestGenPathMovie:
# ---------------------------------------------------------------------------
class TestGenPathWithGroupTag:
"""gen_path honors bangumi_manage.group_tag as a "[Group] " prefix,
mirroring the legacy downloader/path.py rule_name behavior."""
class TestGenPathGroupTagStability:
"""group_tag 只影响 qB RSS 规则名(downloader/path.py 的 rule_name),
从不写进重命名后的文件名:升级后已有做种媒体库的文件名必须保持稳定,
否则会触发整库批量重命名,破坏 Plex/Jellyfin 索引与 cross-seed/硬链接。"""
def test_group_tag_enabled_prefixes_pn_method(self):
"""pn method gets a "[Group] " prefix when group_tag is enabled."""
def test_gen_path_group_tag_enabled_pn_no_prefix(self):
"""pn 方法在 group_tag 开启时也不加 "[Group] " 前缀。"""
ep = EpisodeFile(
media_path="old.mkv",
group="SubGroup",
@@ -209,10 +211,10 @@ class TestGenPathWithGroupTag:
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(ep, "Bangumi Name", method="pn")
assert result == "[SubGroup] My Anime S01E05.mkv"
assert result == "My Anime S01E05.mkv"
def test_group_tag_enabled_prefixes_advance_method(self):
"""advance method gets a "[Group] " prefix when group_tag is enabled."""
def test_gen_path_group_tag_enabled_advance_no_prefix(self):
"""advance 方法在 group_tag 开启时也不加前缀。"""
ep = EpisodeFile(
media_path="old.mkv",
group="SubGroup",
@@ -223,10 +225,10 @@ class TestGenPathWithGroupTag:
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(ep, "Bangumi Name", method="advance")
assert result == "[SubGroup] Bangumi Name S01E05.mkv"
assert result == "Bangumi Name S01E05.mkv"
def test_group_tag_enabled_prefixes_subtitle_methods(self):
"""subtitle_pn/subtitle_advance also get the prefix when enabled."""
def test_gen_path_group_tag_enabled_subtitle_methods_no_prefix(self):
"""subtitle_pn/subtitle_advance 在 group_tag 开启时也不加前缀。"""
sub = SubtitleFile(
media_path="sub.ass",
group="SubGroup",
@@ -238,10 +240,10 @@ class TestGenPathWithGroupTag:
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(sub, "Bangumi Name", method="subtitle_pn")
assert result == "[SubGroup] My Anime S01E05.zh.ass"
assert result == "My Anime S01E05.zh.ass"
def test_group_tag_disabled_no_prefix(self):
"""No prefix is added when group_tag is disabled, even with a group."""
def test_gen_path_group_tag_disabled_no_prefix(self):
"""group_tag 关闭时同样没有前缀。"""
ep = EpisodeFile(
media_path="old.mkv",
group="SubGroup",
@@ -254,24 +256,8 @@ class TestGenPathWithGroupTag:
result = Renamer.gen_path(ep, "Bangumi Name", method="pn")
assert result == "My Anime S01E05.mkv"
def test_group_tag_enabled_but_no_group_no_prefix(self):
"""No "[None] " prefix leaks in when group_tag is enabled but the
parsed file has no group (e.g. no [Group] tag in the torrent name)."""
ep = EpisodeFile(
media_path="old.mkv",
group=None,
title="My Anime",
season=1,
episode=5,
suffix=".mkv",
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(ep, "Bangumi Name", method="pn")
assert result == "My Anime S01E05.mkv"
def test_group_tag_enabled_none_and_subtitle_none_unaffected(self):
"""none/subtitle_none methods return the original path unchanged,
regardless of group_tag."""
def test_gen_path_group_tag_enabled_none_method_returns_original(self):
"""none/subtitle_none 方法始终原样返回路径,与 group_tag 无关。"""
ep = EpisodeFile(
media_path="original/path/file.mkv",
group="SubGroup",
@@ -467,6 +453,81 @@ class TestRenameCollection:
# Only called once for ep01.mkv (depth 1)
assert renamer.client.client.torrents_rename_file.call_count == 1
@staticmethod
def _rename_new_paths(mock_client) -> list[str]:
"""提取 torrents_rename_file 每次调用的 new_path 参数。"""
return [
call.kwargs["new_path"]
for call in mock_client.torrents_rename_file.call_args_list
]
async def test_rename_collection_movie_multifile_targets_distinct(self, renamer):
"""多文件电影种子(正片 + 特典)不能全部生成同一个目标文件名:
主文件用干净名,其余文件追加原始文件名词干作区分。"""
media_list = ["Tenki no Ko.mkv", "Tenki no Ko Extra PV.mkv"]
renamer.client.client.torrents_rename_file.return_value = True
await renamer.rename_collection(
media_list=media_list,
bangumi_name="天气之子 (2019)",
season=1,
method="advance",
_hash="hash_movie",
episode_type="movie",
file_sizes={
"Tenki no Ko.mkv": 8_000_000_000,
"Tenki no Ko Extra PV.mkv": 100_000_000,
},
)
new_paths = self._rename_new_paths(renamer.client.client)
assert len(new_paths) == 2
assert len(set(new_paths)) == 2
assert "天气之子 (2019).mkv" in new_paths
async def test_rename_collection_movie_largest_file_gets_clean_name(self, renamer):
"""体积最大的文件是主文件,即使它不在列表首位。"""
media_list = ["Extras Menu.mkv", "Tenki no Ko Main Feature.mkv"]
renamer.client.client.torrents_rename_file.return_value = True
await renamer.rename_collection(
media_list=media_list,
bangumi_name="天气之子 (2019)",
season=1,
method="advance",
_hash="hash_movie",
episode_type="movie",
file_sizes={
"Extras Menu.mkv": 100_000_000,
"Tenki no Ko Main Feature.mkv": 8_000_000_000,
},
)
calls = renamer.client.client.torrents_rename_file.call_args_list
by_old = {c.kwargs["old_path"]: c.kwargs["new_path"] for c in calls}
assert by_old["Tenki no Ko Main Feature.mkv"] == "天气之子 (2019).mkv"
assert by_old["Extras Menu.mkv"] == "天气之子 (2019) - Extras Menu.mkv"
async def test_rename_collection_movie_already_renamed_skips_rename(self, renamer):
"""区分名是幂等的:已重命名过的多文件电影种子下一轮不再触发重命名。"""
media_list = [
"天气之子 (2019).mkv",
"天气之子 (2019) - Tenki no Ko Extra PV.mkv",
]
renamer.client.client.torrents_rename_file.return_value = True
await renamer.rename_collection(
media_list=media_list,
bangumi_name="天气之子 (2019)",
season=1,
method="advance",
_hash="hash_movie",
episode_type="movie",
file_sizes={
"天气之子 (2019).mkv": 8_000_000_000,
"天气之子 (2019) - Tenki no Ko Extra PV.mkv": 100_000_000,
},
)
renamer.client.client.torrents_rename_file.assert_not_called()
# ---------------------------------------------------------------------------
# rename_subtitles
@@ -618,6 +679,39 @@ class TestRenameFlow:
"h1", "BangumiCollection"
)
async def test_rename_flow_movie_collection_uses_file_sizes(self, renamer):
"""多文件电影种子走完整 rename 流程时,按文件体积选出主文件,
目标文件名互不相同。"""
renamer.client.client.torrents_info.return_value = [
{
"hash": "h1",
"name": "天气之子 Movie BDRip",
"save_path": "/downloads/Bangumi/天气之子 (2019)",
}
]
renamer.client.client.torrents_files.return_value = [
{"name": "Menu PV.mkv", "size": 100_000_000},
{"name": "Tenki no Ko.mkv", "size": 8_000_000_000},
]
renamer.client.client.torrents_rename_file.return_value = True
with patch.object(
renamer,
"_batch_lookup_offsets",
AsyncMock(return_value={"h1": (0, 0, "movie")}),
):
with patch("module.manager.renamer.settings") as mock_settings:
mock_settings.bangumi_manage.rename_method = "advance"
mock_settings.bangumi_manage.remove_bad_torrent = False
with patch("module.downloader.path.settings") as mock_path_settings:
mock_path_settings.downloader.path = "/downloads/Bangumi"
await renamer.rename()
calls = renamer.client.client.torrents_rename_file.call_args_list
by_old = {c.kwargs["old_path"]: c.kwargs["new_path"] for c in calls}
assert by_old["Tenki no Ko.mkv"] == "天气之子 (2019).mkv"
assert by_old["Menu PV.mkv"] == "天气之子 (2019) - Menu PV.mkv"
async def test_no_media_files_no_crash(self, renamer):
"""When torrent has no media files, logs warning but doesn't crash."""
renamer.client.client.torrents_info.return_value = [