From 9b194b1af61c9cfc44290c5772fa2387248fffa7 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:44:58 +0200 Subject: [PATCH 01/10] fix(rss): link manually added torrents to their bangumi Torrents added via search/subscribe/collect were persisted with bangumi_id=NULL, so every matched torrent from those flows landed in the orphan list regardless of the track_orphans setting, and the ab: qB tag was silently skipped (bangumi.id did not exist yet). - subscribe_season/collect_season: insert the bangumi row before downloading so its id exists for the qB tag and the FK - collect_season/download_bangumi: set torrent.bangumi_id before persisting Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/manager/collector.py | 20 +++-- backend/src/module/rss/engine.py | 3 + backend/src/test/test_collector.py | 106 ++++++++++++++++++++++++ backend/src/test/test_rss_engine_new.py | 32 +++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 backend/src/test/test_collector.py diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index 2238f4e2..0f2bd667 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -27,18 +27,23 @@ class SeasonCollector: link, bangumi.filter.replace(",", "|") ) async with Database() as db: + # bangumi 必须先落库拿到 id:add_torrent 用它打 ab: 标签, + # 种子行也要用它关联 bangumi_id——否则种子会被记成孤儿, + # track_orphans 开关对这些"已匹配"的种子完全失效。 + # update() returns False when no existing row matches + # bangumi.id (i.e. this is a brand-new bangumi), in which + # case it needs to be inserted instead. + if bangumi.id is None or not await db.bangumi.update(bangumi): + await db.bangumi.add(bangumi) if await self.client.add_torrent(torrents, bangumi) is AddResult.ADDED: logger.info( f"Collections of {bangumi.official_title} Season {bangumi.season} completed." ) for torrent in torrents: torrent.downloaded = True + torrent.bangumi_id = bangumi.id bangumi.eps_collect = True - # update() returns False when no existing row matches - # bangumi.id (i.e. this is a brand-new bangumi), in which - # case it needs to be inserted instead. - if not await db.bangumi.update(bangumi): - await db.bangumi.add(bangumi) + await db.bangumi.update(bangumi) await db.torrent.add_all(torrents) return ResponseModel( status=True, @@ -69,9 +74,10 @@ class SeasonCollector: aggregate=False, parser=parser, ) - result = await engine.download_bangumi(data) + # 先落库拿到 id:download_bangumi 里 add_torrent 的 ab: + # 标签和种子行的 bangumi_id 关联都依赖它,否则种子被记成孤儿 await db.bangumi.add(data) - return result + return await engine.download_bangumi(data) async def eps_complete(): diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index 964316e3..8750ef28 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -400,6 +400,9 @@ class RSSEngine: ) for torrent in torrents: torrent.downloaded = True + # 关联 bangumi_id,避免已匹配的种子被记成孤儿 + if bangumi.id is not None: + torrent.bangumi_id = bangumi.id await self.db.torrent.add_all(torrents) return ResponseModel( status=True, diff --git a/backend/src/test/test_collector.py b/backend/src/test/test_collector.py new file mode 100644 index 00000000..80d80bb1 --- /dev/null +++ b/backend/src/test/test_collector.py @@ -0,0 +1,106 @@ +"""Tests for SeasonCollector: torrents must be persisted with bangumi_id. + +Regression tests for the orphan-torrent bug: torrents added via +subscribe/collect were stored with ``bangumi_id=None`` and showed up as +orphans regardless of the ``track_orphans`` setting. +""" + +from unittest.mock import AsyncMock, patch + +from module.database import Database +from module.downloader import AddResult +from module.manager.collector import SeasonCollector +from module.models import Torrent +from test.factories import make_bangumi + + +def _async_ctx(inner): + ctx = AsyncMock() + ctx.__aenter__ = AsyncMock(return_value=inner) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + +def _req_with_torrents(torrents): + req = AsyncMock() + req.get_torrents = AsyncMock(return_value=torrents) + return _async_ctx(req) + + +def _make_torrents(): + return [ + Torrent( + name="[TestGroup] Test Anime Raw - 01 [1080p].mkv", + url="https://example.com/ep1.torrent", + ), + Torrent( + name="[TestGroup] Test Anime Raw - 02 [1080p].mkv", + url="https://example.com/ep2.torrent", + ), + ] + + +class TestCollectSeason: + async def test_torrents_persisted_with_bangumi_id(self): + bangumi = make_bangumi(filter="") + torrents = _make_torrents() + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + collector = SeasonCollector(client) + + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(torrents), + ): + resp = await collector.collect_season(bangumi, "https://example.com/rss") + + assert resp.status is True + async with Database() as db: + stored = await db.torrent.search_all() + assert len(stored) == 2 + assert all(t.bangumi_id == bangumi.id for t in stored) + assert await db.torrent.count_orphans() == 0 + + async def test_add_torrent_called_with_persisted_bangumi(self): + """add_torrent 打 ab: 标签依赖调用时 bangumi.id 已存在。""" + bangumi = make_bangumi(filter="") + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + collector = SeasonCollector(client) + + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ): + await collector.collect_season(bangumi, "https://example.com/rss") + + passed_bangumi = client.add_torrent.call_args.args[1] + assert passed_bangumi.id is not None + + +class TestSubscribeSeason: + async def test_torrents_persisted_with_bangumi_id(self): + data = make_bangumi(filter="") + torrents = _make_torrents() + + downloader_client = AsyncMock() + downloader_client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + with ( + patch( + "module.rss.engine.RequestContent", + return_value=_req_with_torrents(torrents), + ), + patch( + "module.rss.engine.DownloadClient", + return_value=_async_ctx(downloader_client), + ), + ): + result = await SeasonCollector.subscribe_season(data) + + assert result.status is True + assert data.id is not None + async with Database() as db: + stored = await db.torrent.search_all() + assert len(stored) == 2 + assert all(t.bangumi_id == data.id for t in stored) + assert await db.torrent.count_orphans() == 0 diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 6a0baeed..287b3b6a 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -600,6 +600,38 @@ class TestRefreshRssRetry: class TestDownloadBangumi: + async def test_success_persists_torrents_with_bangumi_id(self, rss_engine): + """成功下载后种子行必须关联 bangumi_id,否则会被记成孤儿。""" + bangumi = make_bangumi( + official_title="Mushoku Tensei", + rss_link="https://example.com/rss", + filter="", + ) + await rss_engine.db.bangumi.add(bangumi) + torrent = Torrent( + name="[Sub] Mushoku Tensei - 12 [1080p].mkv", + url="https://example.com/ep12.torrent", + ) + with ( + patch("module.rss.engine.RequestContent") as MockReq, + patch("module.rss.engine.DownloadClient") as MockClient, + ): + req = AsyncMock() + req.get_torrents = AsyncMock(return_value=[torrent]) + MockReq.return_value.__aenter__ = AsyncMock(return_value=req) + MockReq.return_value.__aexit__ = AsyncMock(return_value=False) + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + MockClient.return_value.__aenter__ = AsyncMock(return_value=client) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + + resp = await rss_engine.download_bangumi(bangumi) + + assert resp.status is True + stored = await rss_engine.db.torrent.search_all() + assert len(stored) == 1 + assert stored[0].bangumi_id == bangumi.id + async def test_failed_add_returns_failure_and_does_not_persist(self, rss_engine): bangumi = make_bangumi( official_title="Mushoku Tensei", From 64cfc1b1f867f835d4e01309a9bed49fc12c69f2 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:46:26 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(notification):=20support=20Server?= =?UTF-8?q?=E9=85=B1=C2=B3=20sendkeys=20(#904)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sctpt-prefixed sendkeys route to the Server酱³ endpoint (https://.push.ft07.com/send/.send); Turbo keys keep the legacy sctapi.ftqq.com endpoint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- .../notification/providers/server_chan.py | 12 +++++++++- backend/src/test/test_notification.py | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/src/module/notification/providers/server_chan.py b/backend/src/module/notification/providers/server_chan.py index 03f753d3..14a9cd7f 100644 --- a/backend/src/module/notification/providers/server_chan.py +++ b/backend/src/module/notification/providers/server_chan.py @@ -1,6 +1,7 @@ """Server Chan notification provider.""" import logging +import re from typing import TYPE_CHECKING from module.models.bangumi import Notification @@ -11,6 +12,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# Server酱³ 的 sendkey 形如 sctpt,推送端点与 Turbo 版不同 (#904) +_SC3_KEY_RE = re.compile(r"^sctp(\d+)t") + class ServerChanProvider(NotificationProvider): """Server Chan (Server酱) notification provider for WeChat.""" @@ -18,7 +22,13 @@ class ServerChanProvider(NotificationProvider): def __init__(self, config: "ProviderConfig"): super().__init__(config) token = config.token - self.notification_url = f"https://sctapi.ftqq.com/{token}.send" + sc3 = _SC3_KEY_RE.match(token) + if sc3: + self.notification_url = ( + f"https://{sc3.group(1)}.push.ft07.com/send/{token}.send" + ) + else: + self.notification_url = f"https://sctapi.ftqq.com/{token}.send" async def send(self, notification: Notification) -> bool: """Send notification via Server Chan.""" diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index 80d31784..7aee0dee 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -616,6 +616,28 @@ class TestDeliverText: assert data["title"] == "标题" assert data["desp"] == "正文" + def test_server_chan_legacy_key_uses_sctapi_endpoint(self): + config = ProviderConfig(type="server-chan", enabled=True, token="SCT123abc") + provider = ServerChanProvider(config) + + assert provider.notification_url == "https://sctapi.ftqq.com/SCT123abc.send" + + def test_server_chan_sc3_key_uses_ft07_endpoint(self): + """Server酱³ 的 sendkey 以 sctpt 开头,走 push.ft07.com 端点 (#904)。""" + config = ProviderConfig(type="server-chan", enabled=True, token="sctp456tXYZ") + provider = ServerChanProvider(config) + + assert ( + provider.notification_url + == "https://456.push.ft07.com/send/sctp456tXYZ.send" + ) + + def test_server_chan_malformed_sctp_key_falls_back_to_legacy(self): + config = ProviderConfig(type="server-chan", enabled=True, token="sctpXYZ") + provider = ServerChanProvider(config) + + assert provider.notification_url == "https://sctapi.ftqq.com/sctpXYZ.send" + async def test_telegram_deliver_text_posts_combined_text(self): config = ProviderConfig( type="telegram", enabled=True, token="test_token", chat_id="12345" From bbc91d89f2054a83c4d4886cd67e2859655d078a Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:50:25 +0200 Subject: [PATCH 03/10] 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( From 92ad33b2e4505d8b9a73cd15c66784944e3da501 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:53:29 +0200 Subject: [PATCH 04/10] fix(renamer): keep fractional episode numbers like 12.5 (#667, #704) The filename parser already yields 12.5 as a float, but gen_path and rename_file int()-floored it to 12, overwriting the real episode 12. Extract the duplicated offset logic into _adjust_episode and add _format_episode (fraction preserved, integer part zero-padded: "09.5"); widen Notification.episode to int | float so notifications carry the fraction too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/manager/renamer.py | 54 ++++++++++----------- backend/src/module/models/bangumi.py | 3 +- backend/src/test/test_renamer.py | 67 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 27 deletions(-) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 37798a18..6b7316a6 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -61,6 +61,30 @@ class Renamer: ) logger.debug("Checked %s files", torrent_count) + @staticmethod + def _adjust_episode(original: int | float, episode_offset: int) -> int | float: + if original == 0 and episode_offset != 0: + # Episode 0 is a special/OVA — never apply offset to avoid + # overwriting regular episodes (see issue #977) + return 0 + adjusted = original + 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 < 0 or (adjusted == 0 and original > 0): + logger.warning( + f"Episode offset {episode_offset} would make episode {original} non-positive, ignoring offset" + ) + return original + return adjusted + + @staticmethod + def _format_episode(episode: int | float) -> str: + # 总集篇等半集(12.5)保留小数,否则会覆盖同季的整数集 (#667); + # 整数值沿用两位补零 + if isinstance(episode, float) and episode.is_integer(): + episode = int(episode) + return f"0{episode}" if episode < 10 else str(episode) + @staticmethod def gen_path( file_info: EpisodeFile | SubtitleFile, @@ -74,22 +98,9 @@ class Renamer: # So we use file_info.season directly without applying offset again season_num = file_info.season season = f"0{season_num}" if season_num < 10 else season_num - # Apply episode offset - original_episode = int(file_info.episode) - 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( - f"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 + episode = Renamer._format_episode( + Renamer._adjust_episode(file_info.episode, episode_offset) + ) # 注意:group_tag 只影响 qB RSS 规则名(downloader/path.py 的 rule_name), # 从不写进重命名后的文件名——已有做种媒体库的文件名必须保持稳定, # 否则升级后会触发整库批量重命名,破坏 Plex/Jellyfin 索引与硬链接 @@ -176,19 +187,10 @@ class Renamer: _pending_renames.pop(pending_key, None) # Season comes from folder which already has offset applied # Only apply episode offset - original_ep = int(ep.episode) - 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 - ): - adjusted_episode = original_ep return Notification( official_title=bangumi_name, season=ep.season, - episode=adjusted_episode, + episode=self._adjust_episode(ep.episode, episode_offset), ) else: # Rename API returned success but file wasn't actually renamed diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index 1bac9478..69789087 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -113,7 +113,8 @@ class BangumiUpdate(SQLModel): class Notification(BaseModel): official_title: str = Field(..., alias="official_title", title="番剧名") season: int = Field(..., alias="season", title="番剧季度") - episode: int = Field(..., alias="episode", title="番剧集数") + # int | float:总集篇等半集(12.5)在通知里保留小数 (#667) + episode: int | float = Field(..., alias="episode", title="番剧集数") poster_path: Optional[str] = Field(None, alias="poster_path", title="番剧海报路径") diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index 8d093940..f53324f9 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -139,6 +139,50 @@ class TestGenPath: # --------------------------------------------------------------------------- +class TestGenPathFractionalEpisode: + """总集篇等半集(12.5)必须保留小数,否则会覆盖同季的整数集 (#667)。""" + + def test_pn_keeps_fraction(self): + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=12.5, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "My Anime S01E12.5.mkv" + + def test_fraction_below_ten_zero_padded(self): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=9.5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "My Anime S01E09.5.mkv" + + def test_offset_applies_to_fraction(self): + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=13.5, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn", episode_offset=-1) + assert result == "My Anime S01E12.5.mkv" + + def test_integer_valued_float_formats_as_int(self): + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=12.0, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "My Anime S01E12.mkv" + + class TestGenPathMovie: """episode_type='movie' bypasses SxxExx numbering entirely, producing a 'Title (Year).ext' layout instead.""" @@ -339,6 +383,29 @@ class TestRenameFile: assert result.season == 1 assert result.episode == 5 + async def test_fractional_episode_notification_keeps_fraction(self, renamer): + """半集重命名后通知里的集数保留小数 (#667)。""" + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=12.5, + suffix=".mkv", + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + result = await renamer.rename_file( + torrent_name="[Sub] My Anime - 12.5.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + assert result is not None + assert result.episode == 12.5 + async def test_parse_fails_no_remove(self, renamer): """When parser returns None and remove_bad_torrent=False, returns None.""" with patch.object(renamer._parser, "torrent_parser", return_value=None): From e002806bf68925e6c2a38ffb550ffbdbce4f37a5 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:59:20 +0200 Subject: [PATCH 05/10] feat(renamer): tag processed torrents with ab:renamed (#147) After a torrent's media files are renamed (or already conform to the target naming), add an ab:renamed tag in the downloader so external scripts (filebot, hlink, ...) can filter tasks AutoBangumi has finished processing. Skips the API call when the tag is already present, skips failed renames, and skips the none/normal pass-through methods. Capability-guarded via the facade, so aria2/mock downloaders are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/manager/renamer.py | 27 +++++- backend/src/test/test_renamer.py | 134 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 6b7316a6..ead77950 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -30,6 +30,9 @@ _PENDING_RENAME_COOLDOWN = 300 # 5 minutes cooldown before retrying same rename _CLEANUP_INTERVAL = 60 # Clean up pending cache at most once per minute _last_cleanup_time: float = 0 +# 处理完成标记:供外部脚本(filebot、hlink 等)过滤 AB 已重命名的任务 (#147) +_RENAMED_TAG = "ab:renamed" + class Renamer: def __init__(self, client: DownloadClient): @@ -141,6 +144,12 @@ class Renamer: logger.error(f"Unknown rename method: {method}") return file_info.media_path + async def _mark_renamed(self, _hash: str, existing_tags: str | None) -> None: + """给处理完成的种子打 ``ab:renamed`` 标签;已带标签时不再调 API。""" + if _RENAMED_TAG in (t.strip() for t in (existing_tags or "").split(",")): + return + await self.client.add_tag(_hash, _RENAMED_TAG) + async def rename_file( self, torrent_name: str, @@ -152,6 +161,7 @@ class Renamer: episode_offset: int = 0, season_offset: int = 0, episode_type: str = "episode", + existing_tags: str | None = None, **kwargs, ): ep = self._parser.torrent_parser( @@ -168,7 +178,12 @@ class Renamer: episode_offset=episode_offset, season_offset=season_offset, ) - if media_path != new_path: + if media_path == new_path: + # 已符合目标命名(如重启后再次扫描):视为处理完成。 + # none/normal 是直通方法,没有"重命名完成"的语义,不打标 + if method not in ("none", "normal"): + await self._mark_renamed(_hash, existing_tags) + else: # Check if this rename was recently attempted but didn't take effect # (qBittorrent can return 200 but delay actual rename while seeding) pending_key = (_hash, media_path, new_path) @@ -185,6 +200,7 @@ class Renamer: ): # Rename verified successful, remove from pending cache _pending_renames.pop(pending_key, None) + await self._mark_renamed(_hash, existing_tags) # Season comes from folder which already has offset applied # Only apply episode offset return Notification( @@ -228,6 +244,7 @@ class Renamer: season_offset: int = 0, episode_type: str = "episode", file_sizes: dict[str, int] | None = None, + existing_tags: str | None = None, **kwargs, ): # 多文件电影种子(正片 + 特典/花絮):所有文件会解析出同一标题, @@ -240,6 +257,7 @@ class Renamer: movie_primary = max(ep_list, key=lambda p: file_sizes.get(p, 0)) else: movie_primary = ep_list[0] + all_renamed = True for media_path in media_list: if is_ep(media_path): ep = self._parser.torrent_parser( @@ -267,11 +285,16 @@ class Renamer: _hash=_hash, old_path=media_path, new_path=new_path ) if not renamed: + all_renamed = False logger.warning(f"{media_path} rename failed") # Delete bad torrent. if settings.bangumi_manage.remove_bad_torrent: await self.client.delete_torrent(_hash) break + # 全部媒体文件就位(含本轮无需改名的情况)才算处理完成; + # none/normal 直通方法没有"重命名完成"的语义,不打标 + if all_renamed and method not in ("none", "normal"): + await self._mark_renamed(_hash, existing_tags) async def rename_subtitles( self, @@ -579,6 +602,8 @@ class Renamer: "episode_offset": episode_offset, "season_offset": season_offset, "episode_type": episode_type, + # 处理完成后打 ab:renamed 标签用;已带标签则跳过 API 调用 + "existing_tags": info.get("tags"), } # Rename single media file if len(media_list) == 1: diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index f53324f9..209f038c 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -406,6 +406,98 @@ class TestRenameFile: assert result is not None assert result.episode == 12.5 + async def test_successful_rename_adds_renamed_tag(self, renamer): + """重命名成功后给种子打 ab:renamed 标签,供外部脚本识别 (#147)。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_conformant_file_adds_renamed_tag(self, renamer): + """文件名已符合目标命名(如重启后再扫)也视为处理完成,补标签。""" + ep = EpisodeFile( + media_path="My Anime S01E05.mkv", + title="My Anime", + season=1, + episode=5, + suffix=".mkv", + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="My Anime S01E05.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + renamer.client.client.torrents_rename_file.assert_not_awaited() + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_existing_renamed_tag_not_re_added(self, renamer): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + existing_tags="ab:42, ab:renamed", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_failed_rename_does_not_tag(self, renamer): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = False + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash-fail-tag", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_none_method_does_not_tag(self, renamer): + """none 方法不做重命名,也不应打处理完成标签。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="none", + season=1, + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_parse_fails_no_remove(self, renamer): """When parser returns None and remove_bad_torrent=False, returns None.""" with patch.object(renamer._parser, "torrent_parser", return_value=None): @@ -515,6 +607,48 @@ class TestRenameCollection: assert renamer.client.client.torrents_rename_file.call_count == 3 + async def test_collection_success_adds_renamed_tag(self, renamer): + """合集全部重命名成功后打 ab:renamed 标签 (#147)。""" + media_list = ["ep01.mkv", "ep02.mkv"] + + def mock_parser(torrent_path, season, **kwargs): + ep_num = int(torrent_path.replace("ep", "").replace(".mkv", "")) + return EpisodeFile( + media_path=torrent_path, + title="Anime", + season=season, + episode=ep_num, + suffix=".mkv", + ) + + with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_collection_failure_does_not_tag(self, renamer): + ep = EpisodeFile( + media_path="ep01.mkv", title="Anime", season=1, episode=1, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = False + await renamer.rename_collection( + media_list=["ep01.mkv"], + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash-collection-fail", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_skips_deep_files(self, renamer): """Files deeper than 2 levels are skipped (not is_ep).""" media_list = ["ep01.mkv", "extras/bonus/ep_sp.mkv"] From 618464a450b8b79fbc00d4fd6443dc8336e187e6 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 18:05:53 +0200 Subject: [PATCH 06/10] feat(config): user-configurable TMDB API key + Network settings panel (#975, #1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: add network.tmdb_api_key (empty = fall back to the built-in shared key, read live like tmdb_base_url); the config API masks it automatically. WebUI: the network section existed backend-only since #1042 — expose it as a Network settings panel (TMDB API URL, TMDB API key, Bangumi API URL) with types, initConfig defaults, i18n, and mocks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/models/config.py | 2 + .../src/module/parser/analyser/tmdb_parser.py | 13 +++-- backend/src/test/test_path.py | 5 +- backend/src/test/test_rss_engine_new.py | 4 +- backend/src/test/test_tmdb.py | 31 +++++++++++ .../src/components/setting/config-network.vue | 52 +++++++++++++++++++ webui/src/i18n/en.json | 6 +++ webui/src/i18n/zh-CN.json | 6 +++ webui/src/pages/index/config.vue | 8 +++ webui/src/test/mocks/api.ts | 5 ++ webui/types/config.ts | 14 +++++ webui/types/dts/components.d.ts | 1 + 12 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 webui/src/components/setting/config-network.vue diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index e6054314..09205682 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -90,6 +90,8 @@ class Network(BaseModel): tmdb_base_url: str = Field( default="https://api.themoviedb.org", description="TMDB API base URL" ) + # 留空时回退到内置共享 key;自配 key 可避开共享 key 的限流 (#975) + tmdb_api_key: str = Field(default="", description="Custom TMDB API key") bgm_base_url: str = Field( default="https://api.bgm.tv", description="Bangumi (bgm.tv) API base URL" ) diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index c823a243..d16002eb 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -18,6 +18,11 @@ def _tmdb_url() -> str: return settings.network.tmdb_base_url.rstrip("/") +def _api_key() -> str: + # 用户自配 key 优先(#975);留空回退到内置共享 key。同样读实时值 + return settings.network.tmdb_api_key or TMDB_API + + # In-memory cache for TMDB lookups to avoid repeated API calls _TMDB_CACHE_MAX = 512 _tmdb_cache: OrderedDict[str, "TMDBInfo | None"] = OrderedDict() @@ -59,19 +64,19 @@ LANGUAGE = {"zh": "zh-CN", "jp": "ja-JP", "en": "en-US"} def search_url(e): - return f"{_tmdb_url()}/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false" + return f"{_tmdb_url()}/3/search/tv?api_key={_api_key()}&page=1&query={e}&include_adult=false" def search_movie_url(e): - return f"{_tmdb_url()}/3/search/movie?api_key={TMDB_API}&page=1&query={e}&include_adult=false" + return f"{_tmdb_url()}/3/search/movie?api_key={_api_key()}&page=1&query={e}&include_adult=false" def info_url(e, key): - return f"{_tmdb_url()}/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}" + return f"{_tmdb_url()}/3/tv/{e}?api_key={_api_key()}&language={LANGUAGE[key]}" def season_url(tv_id, season_number, key): - return f"{_tmdb_url()}/3/tv/{tv_id}/season/{season_number}?api_key={TMDB_API}&language={LANGUAGE[key]}" + return f"{_tmdb_url()}/3/tv/{tv_id}/season/{season_number}?api_key={_api_key()}&language={LANGUAGE[key]}" async def is_animation(tv_id, language, req: RequestContent) -> bool: diff --git a/backend/src/test/test_path.py b/backend/src/test/test_path.py index ac1a70fc..28011c1d 100644 --- a/backend/src/test/test_path.py +++ b/backend/src/test/test_path.py @@ -26,12 +26,15 @@ class TestSanitizePathFragment: assert sanitize_path_fragment("Name ...") == "Name" def test_preserves_cjk_and_brackets(self): - assert sanitize_path_fragment("[Sub] 孤独摇滚!(2022)") == "[Sub] 孤独摇滚!(2022)" + 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 # --------------------------------------------------------------------------- diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 287b3b6a..dfa12c90 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -323,9 +323,7 @@ class TestRefreshRss: assert len(all_torrents) == 1 assert all_torrents[0].bangumi_id is not None - async def test_track_orphans_on_persists_unmatched( - self, rss_engine, test_settings - ): + async def test_track_orphans_on_persists_unmatched(self, rss_engine, test_settings): """默认开启时行为不变:未匹配种子以 bangumi_id NULL 入库。""" rss_item = make_rss_item(enabled=True) await rss_engine.db.rss.add(rss_item) diff --git a/backend/src/test/test_tmdb.py b/backend/src/test/test_tmdb.py index 7cd422cc..0b4d9ebd 100644 --- a/backend/src/test/test_tmdb.py +++ b/backend/src/test/test_tmdb.py @@ -148,6 +148,37 @@ async def test_tmdb_parser_movie_search_no_results_returns_none(mocker): assert tmdb_info is None +def test_search_url_uses_custom_api_key(): + """用户自配的 TMDB API key 优先于内置共享 key (#975)。""" + from unittest.mock import patch + + with patch.object(tmdb_parser_module, "settings") as mock_settings: + mock_settings.network.tmdb_base_url = "https://api.themoviedb.org" + mock_settings.network.tmdb_api_key = "customkey123" + url = tmdb_parser_module.search_url("test") + + assert "api_key=customkey123" in url + + +def test_search_url_falls_back_to_builtin_key(): + from unittest.mock import patch + + from module.conf import TMDB_API + + with patch.object(tmdb_parser_module, "settings") as mock_settings: + mock_settings.network.tmdb_base_url = "https://api.themoviedb.org" + mock_settings.network.tmdb_api_key = "" + url = tmdb_parser_module.search_url("test") + + assert f"api_key={TMDB_API}" in url + + +def test_network_config_tmdb_api_key_defaults_empty(): + from module.models.config import Network + + assert Network().tmdb_api_key == "" + + def test_reset_cache_clears_tmdb_cache(): """reset_cache() must drop all cached lookups (called on config reload so a changed tmdb_base_url stops serving results from the old endpoint).""" diff --git a/webui/src/components/setting/config-network.vue b/webui/src/components/setting/config-network.vue new file mode 100644 index 00000000..8f6c75a5 --- /dev/null +++ b/webui/src/components/setting/config-network.vue @@ -0,0 +1,52 @@ + + + diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 51ffc195..3aa2a8de 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -118,6 +118,12 @@ "type": "Proxy Type", "username": "Username" }, + "network_set": { + "bgm_base_url": "Bangumi API URL", + "title": "Network Setting", + "tmdb_api_key": "TMDB API Key (optional)", + "tmdb_base_url": "TMDB API URL" + }, "security_set": { "title": "Security", "hint": "Login whitelist: empty = allow all IPs. MCP whitelist: empty = deny all access.", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 87cc500e..edf16870 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -118,6 +118,12 @@ "type": "类型", "username": "用户名" }, + "network_set": { + "bgm_base_url": "Bangumi API 地址", + "title": "网络设置", + "tmdb_api_key": "TMDB API Key(可选)", + "tmdb_base_url": "TMDB API 地址" + }, "security_set": { "title": "安全设置", "hint": "登录白名单:为空则允许所有 IP。MCP 白名单:为空则拒绝所有访问。", diff --git a/webui/src/pages/index/config.vue b/webui/src/pages/index/config.vue index 30a9b4d8..d183562d 100644 --- a/webui/src/pages/index/config.vue +++ b/webui/src/pages/index/config.vue @@ -8,6 +8,7 @@ import ConfigDownload from '@/components/setting/config-download.vue'; import ConfigManage from '@/components/setting/config-manage.vue'; import ConfigNotification from '@/components/setting/config-notification.vue'; import ConfigProxy from '@/components/setting/config-proxy.vue'; +import ConfigNetwork from '@/components/setting/config-network.vue'; import ConfigSearchProvider from '@/components/setting/config-search-provider.vue'; import ConfigPlayer from '@/components/setting/config-player.vue'; import ConfigLlm from '@/components/setting/config-llm.vue'; @@ -106,6 +107,13 @@ const sections: ConfigSection[] = [ groups: ['proxy'], keywords: ['proxy', 'http', 'socks5', 'host', 'port'], }, + { + id: 'network', + titleKey: 'config.network_set.title', + component: ConfigNetwork, + groups: ['network'], + keywords: ['network', 'tmdb', 'bangumi', 'bgm', 'mirror', 'api', 'key'], + }, { id: 'search-provider', titleKey: 'config.search_provider_set.title', diff --git a/webui/src/test/mocks/api.ts b/webui/src/test/mocks/api.ts index a3f4763d..7840eb54 100644 --- a/webui/src/test/mocks/api.ts +++ b/webui/src/test/mocks/api.ts @@ -151,6 +151,11 @@ export const mockConfig = { username: '', password: '', }, + network: { + tmdb_base_url: 'https://api.themoviedb.org', + tmdb_api_key: '', + bgm_base_url: 'https://api.bgm.tv', + }, notification: { enable: false, type: 'telegram', diff --git a/webui/types/config.ts b/webui/types/config.ts index e1ff31d9..7f6ebcc6 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -72,6 +72,13 @@ export interface Proxy { username: string; password: string; } +/** 外部数据源地址与凭据(TMDB / bgm.tv):镜像与自配 key */ +export interface Network { + tmdb_base_url: string; + /** 留空回退到内置共享 key */ + tmdb_api_key: string; + bgm_base_url: string; +} /** Notification provider configuration */ export interface NotificationProviderConfig { type: TupleToUnion; @@ -157,6 +164,7 @@ export interface Config { bangumi_manage: BangumiManage; log: Log; proxy: Proxy; + network: Network; notification: Notification; llm: LLM; /** @deprecated 已被 llm 段取代 */ @@ -203,6 +211,12 @@ export const initConfig: Config = { username: '', password: '', }, + network: { + // 与 backend/src/module/models/config.py 的 Network 默认值保持一致 + tmdb_base_url: 'https://api.themoviedb.org', + tmdb_api_key: '', + bgm_base_url: 'https://api.bgm.tv', + }, notification: { enable: false, providers: [], diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts index c814b169..84581e3c 100644 --- a/webui/types/dts/components.d.ts +++ b/webui/types/dts/components.d.ts @@ -67,6 +67,7 @@ declare module '@vue/runtime-core' { ConfigDownload: typeof import('./../../src/components/setting/config-download.vue')['default'] ConfigLlm: typeof import('./../../src/components/setting/config-llm.vue')['default'] ConfigManage: typeof import('./../../src/components/setting/config-manage.vue')['default'] + ConfigNetwork: typeof import('./../../src/components/setting/config-network.vue')['default'] ConfigNormal: typeof import('./../../src/components/setting/config-normal.vue')['default'] ConfigNotification: typeof import('./../../src/components/setting/config-notification.vue')['default'] ConfigParser: typeof import('./../../src/components/setting/config-parser.vue')['default'] From 3b24b4c6a09c5427ef02db567704d947c019d3f5 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 18:23:11 +0200 Subject: [PATCH 07/10] fix(rss): resolve existing bangumi id when re-subscribing a duplicate bangumi.add() returns False for exact (title_raw+group_name) and semantic duplicates without backfilling the id, so re-subscribing an already-known bangumi still orphaned every torrent (found in live verification against the seeded dev stack). Add find_duplicate() to the repo and resolve the existing row's id in subscribe/collect before downloading. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/database/bangumi.py | 10 ++++++--- backend/src/module/manager/collector.py | 27 ++++++++++++++++++----- backend/src/test/test_collector.py | 29 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 5baf7ed0..d5097ad0 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -246,8 +246,8 @@ class BangumiDatabase: """Get all title patterns for matching (title_raw + all aliases).""" return _all_title_patterns(bangumi) - async def _is_duplicate(self, data: Bangumi) -> bool: - """Check if a bangumi rule already exists based on title_raw and group_name.""" + async def find_duplicate(self, data: Bangumi) -> Optional[Bangumi]: + """Find an existing bangumi with the same title_raw and group_name.""" statement = select(Bangumi).where( and_( Bangumi.title_raw == data.title_raw, @@ -255,7 +255,11 @@ class BangumiDatabase: ) ) result = await self.session.execute(statement) - return result.scalar_one_or_none() is not None + return result.scalars().first() + + async def _is_duplicate(self, data: Bangumi) -> bool: + """Check if a bangumi rule already exists based on title_raw and group_name.""" + return await self.find_duplicate(data) is not None async def add(self, data: Bangumi) -> bool: if await self._is_duplicate(data): diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index 0f2bd667..ceee3ae7 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -10,6 +10,22 @@ from module.searcher import SearchTorrent logger = logging.getLogger(__name__) +async def _ensure_bangumi_id(db: Database, data: Bangumi) -> None: + """确保 ``data.id`` 可用:新番剧插入拿 id,重复番剧解析出已存在行的 id。 + + ``add()`` 对精确重复(title_raw+group_name)与语义重复(并入别名)都 + 返回 False 且不回填 id——不解析已存在行的话,add_torrent 的 ab: + 标签与种子行的 bangumi_id 关联都会丢失,种子被记成孤儿。 + """ + if await db.bangumi.add(data) or data.id is not None: + return + existing = await db.bangumi.find_duplicate(data) + if existing is None: + existing = await db.bangumi.find_semantic_duplicate(data) + if existing is not None: + data.id = existing.id + + class SeasonCollector: def __init__(self, client: DownloadClient): self.client = client @@ -32,9 +48,10 @@ class SeasonCollector: # track_orphans 开关对这些"已匹配"的种子完全失效。 # update() returns False when no existing row matches # bangumi.id (i.e. this is a brand-new bangumi), in which - # case it needs to be inserted instead. + # case it needs to be inserted (or resolved to the existing + # duplicate row) instead. if bangumi.id is None or not await db.bangumi.update(bangumi): - await db.bangumi.add(bangumi) + await _ensure_bangumi_id(db, bangumi) if await self.client.add_torrent(torrents, bangumi) is AddResult.ADDED: logger.info( f"Collections of {bangumi.official_title} Season {bangumi.season} completed." @@ -74,9 +91,9 @@ class SeasonCollector: aggregate=False, parser=parser, ) - # 先落库拿到 id:download_bangumi 里 add_torrent 的 ab: - # 标签和种子行的 bangumi_id 关联都依赖它,否则种子被记成孤儿 - await db.bangumi.add(data) + # 先落库拿到 id(重复订阅时解析已存在行的 id):download_bangumi + # 里 add_torrent 的 ab: 标签和种子行的 bangumi_id 关联都依赖它 + await _ensure_bangumi_id(db, data) return await engine.download_bangumi(data) diff --git a/backend/src/test/test_collector.py b/backend/src/test/test_collector.py index 80d80bb1..a4ee3ba1 100644 --- a/backend/src/test/test_collector.py +++ b/backend/src/test/test_collector.py @@ -79,6 +79,35 @@ class TestCollectSeason: class TestSubscribeSeason: + async def test_existing_bangumi_links_torrents_to_existing_id(self): + """重复订阅已存在的番剧(add 返回 False)时,种子必须挂到 + 已存在行的 id 上,不能被记成孤儿。""" + existing = make_bangumi(filter="") + async with Database() as db: + assert await db.bangumi.add(existing) + + data = make_bangumi(filter="") # 同 title_raw+group_name -> 精确重复 + downloader_client = AsyncMock() + downloader_client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + with ( + patch( + "module.rss.engine.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ), + patch( + "module.rss.engine.DownloadClient", + return_value=_async_ctx(downloader_client), + ), + ): + result = await SeasonCollector.subscribe_season(data) + + assert result.status is True + async with Database() as db: + stored = await db.torrent.search_all() + assert len(stored) == 2 + assert all(t.bangumi_id == existing.id for t in stored) + assert await db.torrent.count_orphans() == 0 + async def test_torrents_persisted_with_bangumi_id(self): data = make_bangumi(filter="") torrents = _make_torrents() From 86b170de923ca17788f839ace4e45320211b489c Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 18:44:49 +0200 Subject: [PATCH 08/10] fix: address code-review findings on the T1 batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renamer: drop retroactive sanitization in gen_path — titles parsed from on-disk files are already legal; sanitizing them would batch- rename existing libraries containing ":" on upgrade. Sanitization stays in gen_save_path (new rules only), with an "Unknown Bangumi" fallback when a title is all reserved characters. - renamer: tag failures no longer break the rename flow (notification still returned, cycle continues); collections with unparseable media files are not marked ab:renamed. - collector: duplicate resolution no longer clobbers user-tuned fields (eps_collect is set via a targeted update); failed collects roll back the freshly inserted row; soft-deleted rules are never linked; stale ids pointing at removed rows are cleared. - database: find_duplicate is deterministic (oldest row) and warns on legacy duplicate keys. - api: auto_tag_torrents matches ab: exactly so the ab:renamed marker doesn't block re-linking. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/api/downloader.py | 6 ++- backend/src/module/database/bangumi.py | 37 +++++++++++--- backend/src/module/downloader/path.py | 4 ++ backend/src/module/manager/collector.py | 29 ++++++++--- backend/src/module/manager/renamer.py | 29 ++++++----- backend/src/test/test_api_downloader.py | 19 ++++++-- backend/src/test/test_collector.py | 64 +++++++++++++++++++++++++ backend/src/test/test_path.py | 10 ++++ backend/src/test/test_renamer.py | 63 +++++++++++++++++++----- 9 files changed, 219 insertions(+), 42 deletions(-) diff --git a/backend/src/module/api/downloader.py b/backend/src/module/api/downloader.py index 72fbc667..6de81a96 100644 --- a/backend/src/module/api/downloader.py +++ b/backend/src/module/api/downloader.py @@ -1,4 +1,5 @@ import logging +import re from fastapi import APIRouter, Depends from pydantic import BaseModel @@ -123,8 +124,9 @@ async def auto_tag_torrents(): save_path = torrent["save_path"] tags = torrent.get("tags", "") - # Skip if already has ab: tag - if "ab:" in tags: + # Skip if already has an ab: link tag。必须精确匹配数字 id: + # ab:renamed(处理完成标记)等同前缀标签不代表已关联番剧 + if re.search(r"ab:\d+", tags): continue # First try by torrent name, then fall back to save_path diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index d5097ad0..c629ddf9 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -247,15 +247,32 @@ class BangumiDatabase: return _all_title_patterns(bangumi) async def find_duplicate(self, data: Bangumi) -> Optional[Bangumi]: - """Find an existing bangumi with the same title_raw and group_name.""" - statement = select(Bangumi).where( - and_( - Bangumi.title_raw == data.title_raw, - Bangumi.group_name == data.group_name, + """Find an existing bangumi with the same title_raw and group_name. + + Legacy data may contain multiple rows for the same key; return the + oldest (lowest id) deterministically and warn instead of raising. + """ + statement = ( + select(Bangumi) + .where( + and_( + Bangumi.title_raw == data.title_raw, + Bangumi.group_name == data.group_name, + ) ) + .order_by(Bangumi.id) # type: ignore[arg-type] ) result = await self.session.execute(statement) - return result.scalars().first() + rows = list(result.scalars().all()) + if len(rows) > 1: + logger.warning( + "Multiple bangumi rows share (title_raw=%s, group_name=%s); " + "using the oldest (id=%s)", + data.title_raw, + data.group_name, + rows[0].id, + ) + return rows[0] if rows else None async def _is_duplicate(self, data: Bangumi) -> bool: """Check if a bangumi rule already exists based on title_raw and group_name.""" @@ -440,6 +457,14 @@ class BangumiDatabase: await self.session.commit() logger.debug("Update %s poster_link to %s.", title_raw, poster_link) + async def mark_eps_collect(self, _id: int) -> None: + """只把 eps_collect 置位,不触碰行内其他字段(offset/filter 等)。""" + bangumi = await self.session.get(Bangumi, _id) + if bangumi: + bangumi.eps_collect = True + self.session.add(bangumi) + await self.session.commit() + async def delete_one(self, _id: int): statement = select(Bangumi).where(Bangumi.id == _id) result = await self.session.execute(statement) diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index d3eb1a55..6cc3b46d 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -90,6 +90,10 @@ def gen_save_path(data: Bangumi | BangumiUpdate): folder = sanitize_path_fragment( f"{data.official_title} ({data.year})" if data.year else data.official_title ) + if not folder: + # 标题全由保留字符组成时清洗结果为空——不能让所有这类番剧 + # 坍缩到同一个 ``…//Season N`` 目录里 + folder = "Unknown Bangumi" episode_type = getattr(data, "episode_type", "episode") if episode_type == "movie": # 电影/剧场版:Title (Year)/Title (Year).ext,不建 Season 子目录 diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index ceee3ae7..dfc9f32f 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -10,20 +10,27 @@ from module.searcher import SearchTorrent logger = logging.getLogger(__name__) -async def _ensure_bangumi_id(db: Database, data: Bangumi) -> None: +async def _ensure_bangumi_id(db: Database, data: Bangumi) -> bool: """确保 ``data.id`` 可用:新番剧插入拿 id,重复番剧解析出已存在行的 id。 ``add()`` 对精确重复(title_raw+group_name)与语义重复(并入别名)都 返回 False 且不回填 id——不解析已存在行的话,add_torrent 的 ab: 标签与种子行的 bangumi_id 关联都会丢失,种子被记成孤儿。 + + 只解析未被软删除的行(禁用规则对下游所有查询都不可见,挂上去等于 + 白挂);传入的 id 指向已不存在的行时清空,避免种子挂到悬空外键。 + 返回是否插入了新行(调用方据此决定失败时是否回滚删除)。 """ - if await db.bangumi.add(data) or data.id is not None: - return + if await db.bangumi.add(data): + return True existing = await db.bangumi.find_duplicate(data) - if existing is None: + if existing is None or existing.deleted: existing = await db.bangumi.find_semantic_duplicate(data) - if existing is not None: + if existing is not None and not existing.deleted: data.id = existing.id + elif data.id is not None and await db.bangumi.search_id(data.id) is None: + data.id = None # type: ignore[assignment] + return False class SeasonCollector: @@ -50,8 +57,9 @@ class SeasonCollector: # bangumi.id (i.e. this is a brand-new bangumi), in which # case it needs to be inserted (or resolved to the existing # duplicate row) instead. + inserted = False if bangumi.id is None or not await db.bangumi.update(bangumi): - await _ensure_bangumi_id(db, bangumi) + inserted = await _ensure_bangumi_id(db, bangumi) if await self.client.add_torrent(torrents, bangumi) is AddResult.ADDED: logger.info( f"Collections of {bangumi.official_title} Season {bangumi.season} completed." @@ -60,7 +68,10 @@ class SeasonCollector: torrent.downloaded = True torrent.bangumi_id = bangumi.id bangumi.eps_collect = True - await db.bangumi.update(bangumi) + # 只更新 eps_collect 单个字段:解析到已存在行时,整行 + # update 会用刚解析的默认值覆盖用户调好的 offset/filter + if bangumi.id is not None: + await db.bangumi.mark_eps_collect(bangumi.id) await db.torrent.add_all(torrents) return ResponseModel( status=True, @@ -69,6 +80,10 @@ class SeasonCollector: msg_zh=f"收集 {bangumi.official_title} 第 {bangumi.season} 季完成。", ) else: + if inserted and bangumi.id is not None: + # 收集失败时回滚刚插入的行,不留下幽灵订阅规则 + await db.bangumi.delete_one(bangumi.id) + bangumi.id = None # type: ignore[assignment] logger.warning( f"Already collected {bangumi.official_title} Season {bangumi.season}." ) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index ead77950..bb31dafa 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -11,12 +11,7 @@ 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, - sanitize_path_fragment, -) +from module.downloader.path import check_files, is_ep, path_to_bangumi from module.models import EpisodeFile, Notification, SubtitleFile from module.parser import TitleParser @@ -109,10 +104,10 @@ 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) + # 注意:这里的 title/bangumi_name 来自已存在于磁盘上的文件/文件夹名 + # (单个路径分量,不可能含分隔符),不做保留字符清洗——追加清洗会让 + # 既有做种库(如含 ":" 的标题)在升级后被整库批量重命名 (#721 评审) + title = file_info.title if file_info.episode_type == "movie": # 电影/剧场版:Title (Year).ext,不使用 SxxExx 编号。bangumi_name 由 # 调用方传入,与 gen_save_path 的文件夹命名保持一致 (Title (Year)) @@ -145,10 +140,17 @@ class Renamer: return file_info.media_path async def _mark_renamed(self, _hash: str, existing_tags: str | None) -> None: - """给处理完成的种子打 ``ab:renamed`` 标签;已带标签时不再调 API。""" + """给处理完成的种子打 ``ab:renamed`` 标签;已带标签时不再调 API。 + + 打标失败绝不能影响重命名主流程(重命名已经成功、通知必须发出、 + 本轮其余种子必须继续处理)——吞掉异常,下一轮会自动补打。 + """ if _RENAMED_TAG in (t.strip() for t in (existing_tags or "").split(",")): return - await self.client.add_tag(_hash, _RENAMED_TAG) + try: + await self.client.add_tag(_hash, _RENAMED_TAG) + except Exception as e: + logger.warning("Failed to tag %s as renamed: %s", _hash[:8], e) async def rename_file( self, @@ -291,6 +293,9 @@ class Renamer: if settings.bangumi_manage.remove_bad_torrent: await self.client.delete_torrent(_hash) break + else: + # 解析失败的媒体文件不会被重命名——不能算处理完成 + all_renamed = False # 全部媒体文件就位(含本轮无需改名的情况)才算处理完成; # none/normal 直通方法没有"重命名完成"的语义,不打标 if all_renamed and method not in ("none", "normal"): diff --git a/backend/src/test/test_api_downloader.py b/backend/src/test/test_api_downloader.py index 2c2270e4..f596744e 100644 --- a/backend/src/test/test_api_downloader.py +++ b/backend/src/test/test_api_downloader.py @@ -369,7 +369,8 @@ class TestAutoTagTorrents: deleted=False, ) - # Mock torrents - one untagged, one already tagged + # Mock torrents - one untagged, one already tagged, one only carrying + # the ab:renamed completion marker (must NOT count as linked) mock_download_client.get_torrent_info.return_value = [ { "hash": "abc123", @@ -383,6 +384,12 @@ class TestAutoTagTorrents: "save_path": "/downloads/Other Anime/Season 1", "tags": "ab:456", # Already tagged }, + { + "hash": "ghi789", + "name": "[TestGroup] Test Anime - 02.mkv", + "save_path": "/downloads/Test Anime/Season 1", + "tags": "ab:renamed", # 处理完成标记 ≠ 已关联番剧 + }, ] with patch("module.api.downloader.DownloadClient") as MockClient: @@ -402,9 +409,13 @@ class TestAutoTagTorrents: assert response.status_code == 200 data = response.json() assert data["status"] is True - assert data["tagged_count"] == 1 - # Only the untagged torrent should be tagged - mock_download_client.add_tag.assert_called_once_with("abc123", "ab:123") + assert data["tagged_count"] == 2 + # The untagged torrent and the ab:renamed-only torrent get linked; + # the ab:456 one is skipped + assert mock_download_client.add_tag.call_args_list == [ + (("abc123", "ab:123"),), + (("ghi789", "ab:123"),), + ] def test_auto_tag_no_matches(self, authed_client, mock_download_client): """POST /downloader/torrents/tag/auto handles unmatched torrents.""" diff --git a/backend/src/test/test_collector.py b/backend/src/test/test_collector.py index a4ee3ba1..b8457b7a 100644 --- a/backend/src/test/test_collector.py +++ b/backend/src/test/test_collector.py @@ -61,6 +61,45 @@ class TestCollectSeason: assert all(t.bangumi_id == bangumi.id for t in stored) assert await db.torrent.count_orphans() == 0 + async def test_duplicate_resolution_preserves_existing_row_fields(self): + """解析到已存在行时不能用刚解析的默认值覆盖用户调好的配置。""" + existing = make_bangumi(filter="", episode_offset=12) + async with Database() as db: + assert await db.bangumi.add(existing) + + payload = make_bangumi(filter="", episode_offset=0) # 同 key,默认 offset + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + collector = SeasonCollector(client) + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ): + resp = await collector.collect_season(payload, "https://example.com/rss") + + assert resp.status is True + async with Database() as db: + row = await db.bangumi.search_id(existing.id) + assert row is not None + assert row.episode_offset == 12 + assert row.eps_collect is True + + async def test_failed_collect_leaves_no_phantom_bangumi(self): + """下载器投递失败时不能留下幽灵订阅规则。""" + bangumi = make_bangumi(filter="") + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.FAILED) + collector = SeasonCollector(client) + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ): + resp = await collector.collect_season(bangumi, "https://example.com/rss") + + assert resp.status is False + async with Database() as db: + assert await db.bangumi.search_all() == [] + async def test_add_torrent_called_with_persisted_bangumi(self): """add_torrent 打 ab: 标签依赖调用时 bangumi.id 已存在。""" bangumi = make_bangumi(filter="") @@ -108,6 +147,31 @@ class TestSubscribeSeason: assert all(t.bangumi_id == existing.id for t in stored) assert await db.torrent.count_orphans() == 0 + async def test_deleted_duplicate_is_not_linked(self): + """已被软删除(禁用)的重复规则对下游查询不可见——不能把新种子挂上去。""" + existing = make_bangumi(filter="", deleted=True) + async with Database() as db: + assert await db.bangumi.add(existing) + + data = make_bangumi(filter="") + downloader_client = AsyncMock() + downloader_client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + with ( + patch( + "module.rss.engine.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ), + patch( + "module.rss.engine.DownloadClient", + return_value=_async_ctx(downloader_client), + ), + ): + await SeasonCollector.subscribe_season(data) + + async with Database() as db: + stored = await db.torrent.search_all() + assert all(t.bangumi_id != existing.id for t in stored) + async def test_torrents_persisted_with_bangumi_id(self): data = make_bangumi(filter="") torrents = _make_torrents() diff --git a/backend/src/test/test_path.py b/backend/src/test/test_path.py index 28011c1d..07b69f62 100644 --- a/backend/src/test/test_path.py +++ b/backend/src/test/test_path.py @@ -34,6 +34,16 @@ class TestSanitizePathFragment: once = sanitize_path_fragment("Fate/Zero: Part?2") assert sanitize_path_fragment(once) == once + def test_all_reserved_title_falls_back_in_save_path(self): + """全保留字符的标题不能让保存路径坍缩出空目录层。""" + bangumi = make_bangumi(official_title="??", year=None) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert "//" not in result + assert "Unknown Bangumi" in result + # --------------------------------------------------------------------------- # gen_save_path diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index 209f038c..32e5af57 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -31,24 +31,18 @@ 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)。""" + def test_title_reserved_characters_kept_verbatim(self): + """既有磁盘文件解析出的标题原样保留:追加清洗会让含 ":" 等字符的 + 既有做种库在升级后被整库批量重命名。""" ep = EpisodeFile( media_path="old.mkv", - title="Anime: Re/Start?", + title="Re:Zero Season 2", 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" + assert result == "Re:Zero Season 2 S01E05.mkv" def test_none_method(self): """none method: returns original media_path unchanged.""" @@ -464,6 +458,26 @@ class TestRenameFile: renamer.client.client.add_tag.assert_not_awaited() + async def test_tagging_failure_does_not_break_rename(self, renamer): + """打标 API 抛异常时通知仍要返回——打标绝不能影响主流程。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + renamer.client.client.add_tag.side_effect = RuntimeError("qB down") + result = await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash-tag-fail", + ) + + assert result is not None + assert result.episode == 5 + async def test_failed_rename_does_not_tag(self, renamer): ep = EpisodeFile( media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" @@ -633,6 +647,33 @@ class TestRenameCollection: renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + async def test_collection_parse_failure_does_not_tag(self, renamer): + """有媒体文件解析失败(不会被重命名)时不能打处理完成标签。""" + media_list = ["ep01.mkv", "garbage-name.mkv"] + + def mock_parser(torrent_path, season, **kwargs): + if "garbage" in torrent_path: + return None + return EpisodeFile( + media_path=torrent_path, + title="Anime", + season=season, + episode=1, + suffix=".mkv", + ) + + with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash-parse-fail", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_collection_failure_does_not_tag(self, renamer): ep = EpisodeFile( media_path="ep01.mkv", title="Anime", season=1, episode=1, suffix=".mkv" From 1c6fdb85c02995a22ceab524d49519983ce0bcb4 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 18:53:43 +0200 Subject: [PATCH 09/10] fix(rss): restore disabled rules on explicit re-subscribe/collect Codex review follow-up: a soft-deleted exact duplicate still orphaned torrents (add() refuses the insert, the deleted row was refused for linking, and download proceeded with no id) and the collect path could link torrents to a still-disabled row. Explicitly subscribing or collecting a disabled rule can only mean the user wants it back: restore_one() re-enables the row (keeping its tuned config) and torrents link to it. eps_complete is unaffected (not_complete already filters deleted rows). Also document the ab:renamed tag semantics (top-level media files; subtitles follow in the same tick, nested extras never rename). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/database/bangumi.py | 11 +++++++++++ backend/src/module/manager/collector.py | 18 +++++++++++++----- backend/src/module/manager/renamer.py | 4 +++- backend/src/test/test_collector.py | 10 +++++++--- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index c629ddf9..5782e2cd 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -457,6 +457,17 @@ class BangumiDatabase: await self.session.commit() logger.debug("Update %s poster_link to %s.", title_raw, poster_link) + async def restore_one(self, _id: int) -> bool: + """取消软删除(重新启用规则)。行不存在或本就未删除时不写库。""" + bangumi = await self.session.get(Bangumi, _id) + if not bangumi or not bangumi.deleted: + return False + bangumi.deleted = False + self.session.add(bangumi) + await self.session.commit() + logger.info("Restored disabled bangumi id: %s.", _id) + return True + async def mark_eps_collect(self, _id: int) -> None: """只把 eps_collect 置位,不触碰行内其他字段(offset/filter 等)。""" bangumi = await self.session.get(Bangumi, _id) diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index dfc9f32f..5b02ff38 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -17,16 +17,20 @@ async def _ensure_bangumi_id(db: Database, data: Bangumi) -> bool: 返回 False 且不回填 id——不解析已存在行的话,add_torrent 的 ab: 标签与种子行的 bangumi_id 关联都会丢失,种子被记成孤儿。 - 只解析未被软删除的行(禁用规则对下游所有查询都不可见,挂上去等于 - 白挂);传入的 id 指向已不存在的行时清空,避免种子挂到悬空外键。 - 返回是否插入了新行(调用方据此决定失败时是否回滚删除)。 + 解析到被软删除(禁用)的行时会重新启用它:显式订阅/收集一个已禁用 + 的规则只能是用户想要它回来——不启用的话种子要么变孤儿、要么挂到 + 对下游所有查询都不可见的行上。传入的 id 指向已不存在的行时清空, + 避免种子挂到悬空外键。返回是否插入了新行(调用方据此决定失败时 + 是否回滚删除)。 """ if await db.bangumi.add(data): return True existing = await db.bangumi.find_duplicate(data) - if existing is None or existing.deleted: + if existing is None: existing = await db.bangumi.find_semantic_duplicate(data) - if existing is not None and not existing.deleted: + if existing is not None: + if existing.deleted: + await db.bangumi.restore_one(existing.id) data.id = existing.id elif data.id is not None and await db.bangumi.search_id(data.id) is None: data.id = None # type: ignore[assignment] @@ -60,6 +64,10 @@ class SeasonCollector: inserted = False if bangumi.id is None or not await db.bangumi.update(bangumi): inserted = await _ensure_bangumi_id(db, bangumi) + else: + # 载荷带着已禁用行的 id 也能 update 成功:显式收集 + # 即用户想重新启用,否则种子会挂到不可见的行上 + await db.bangumi.restore_one(bangumi.id) if await self.client.add_torrent(torrents, bangumi) is AddResult.ADDED: logger.info( f"Collections of {bangumi.official_title} Season {bangumi.season} completed." diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index bb31dafa..1c28044a 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -25,7 +25,9 @@ _PENDING_RENAME_COOLDOWN = 300 # 5 minutes cooldown before retrying same rename _CLEANUP_INTERVAL = 60 # Clean up pending cache at most once per minute _last_cleanup_time: float = 0 -# 处理完成标记:供外部脚本(filebot、hlink 等)过滤 AB 已重命名的任务 (#147) +# 处理完成标记:供外部脚本(filebot、hlink 等)过滤 AB 已重命名的任务 (#147)。 +# 语义 = 顶层媒体文件全部就位;字幕在同一轮循环里紧随其后重命名,深层 +# 嵌套文件(特典/花絮)设计上从不重命名——两者都不阻塞打标 _RENAMED_TAG = "ab:renamed" diff --git a/backend/src/test/test_collector.py b/backend/src/test/test_collector.py index b8457b7a..57668723 100644 --- a/backend/src/test/test_collector.py +++ b/backend/src/test/test_collector.py @@ -147,8 +147,9 @@ class TestSubscribeSeason: assert all(t.bangumi_id == existing.id for t in stored) assert await db.torrent.count_orphans() == 0 - async def test_deleted_duplicate_is_not_linked(self): - """已被软删除(禁用)的重复规则对下游查询不可见——不能把新种子挂上去。""" + async def test_deleted_duplicate_is_restored_and_linked(self): + """重新订阅已禁用的规则时重新启用该行并把种子挂上去——否则种子 + 要么变孤儿、要么挂到对下游查询不可见的行上。""" existing = make_bangumi(filter="", deleted=True) async with Database() as db: assert await db.bangumi.add(existing) @@ -169,8 +170,11 @@ class TestSubscribeSeason: await SeasonCollector.subscribe_season(data) async with Database() as db: + row = await db.bangumi.search_id(existing.id) + assert row is not None + assert row.deleted is False stored = await db.torrent.search_all() - assert all(t.bangumi_id != existing.id for t in stored) + assert stored and all(t.bangumi_id == existing.id for t in stored) async def test_torrents_persisted_with_bangumi_id(self): data = make_bangumi(filter="") From f0866e6dc9fa2251ca9796789d6eac8fa7eff25d Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 18:55:18 +0200 Subject: [PATCH 10/10] chore: bump version to 3.3.1 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- CHANGELOG.md | 9 +++++++++ backend/pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2ff4de..741dd159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# [3.3.1] - 2026-07-07 + +- **修复**:搜索/订阅/收集添加的种子未关联番剧被记成孤儿,导致孤儿记录无法通过 `track_orphans` 关闭;重复订阅时正确解析已存在的规则(不覆盖用户调好的 offset/filter),重新订阅已禁用的规则时自动重新启用;收集失败不再留下幽灵订阅规则 +- **修复**(#721):标题中的 Windows/qB 保留字符(`< > : " / \ | ? *`)会把保存路径拆成多级目录或丢字——生成保存路径时清洗(仅新订阅,不触碰既有做种库) +- **修复**(#667/#704):总集篇等半集(12.5)不再被取整成第 12 集覆盖原文件,重命名保留小数(`S01E12.5`),通知同步保留 +- **新增**(#904):Server酱³ 推送支持——`sctp` 开头的 sendkey 自动走 `push.ft07.com` 端点 +- **新增**(#147):重命名完成的种子在下载器中打 `ab:renamed` 标签,供外部脚本(filebot / hlink 等)过滤已处理任务 +- **新增**(#975 / #1042):TMDB API Key 可自配(留空回退内置共享 key);设置页新增"网络设置"面板(TMDB / Bangumi API 地址与 Key) + # [3.3.0] - 2026-07-07 3.3.0 正式版。主要变化(相对 3.2.x;逐项细节见下方 beta.1–beta.8 条目): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3237311c..0bebc430 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "auto-bangumi" -version = "3.3.0" +version = "3.3.1" description = "AutoBangumi - Automated anime download manager" requires-python = ">=3.13" dependencies = [