From 8f918996700c498a861a0721d83d7b48496cfaff Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 12:42:35 +0200 Subject: [PATCH] fix(manager): remove orphaned per-sub RSS feeds when deleting a bangumi (#1053) Deleting a bangumi left its individually-subscribed (aggregate=False) RSSItem enabled, so the WebUI kept showing the feed as active and the RSS loop kept polling it. delete_rule now removes per-sub feeds that no remaining bangumi references, splitting comma-joined rss_link values and matching URLs exactly. Aggregate feeds and feeds still referenced by any bangumi (including soft-disabled ones) are never touched. Adds RSSDatabase.search_url for exact-URL lookup. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37 --- backend/src/module/database/rss.py | 5 ++ backend/src/module/manager/torrent.py | 29 ++++++-- backend/src/test/test_database.py | 17 +++++ backend/src/test/test_manager_torrent.py | 89 ++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 backend/src/test/test_manager_torrent.py diff --git a/backend/src/module/database/rss.py b/backend/src/module/database/rss.py index 80f8efdf..0f68923d 100644 --- a/backend/src/module/database/rss.py +++ b/backend/src/module/database/rss.py @@ -93,6 +93,11 @@ class RSSDatabase: async def search_id(self, _id: int) -> RSSItem | None: return await self.session.get(RSSItem, _id) + async def search_url(self, url: str) -> RSSItem | None: + statement = select(RSSItem).where(RSSItem.url == url) + result = await self.session.execute(statement) + return result.scalar_one_or_none() + async def search_all(self) -> list[RSSItem]: result = await self.session.execute(select(RSSItem)) return list(result.scalars().all()) diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index 95718227..226c2082 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -52,18 +52,35 @@ class TorrentManager: msg_zh=f"无法找到 {data.official_title} 的种子", ) + async def _delete_orphan_sub_rss(self, data: Bangumi) -> None: + """删除该番剧独立订阅(aggregate=False)且不再被引用的 RSS 条目(#1053)。 + + 聚合订阅由多个番剧共享,永不在此处删除;rss_link 可能是逗号拼接的 + 多个链接(见 BangumiDatabase.match_list),逐个拆分精确匹配。 + """ + urls = {u.strip() for u in (data.rss_link or "").split(",") if u.strip()} + if not urls: + return + # 含软删除(deleted=True)的番剧:其订阅需保留以便重新启用 + still_referenced: set[str] = set() + for bangumi in await self.db.bangumi.search_all(): + still_referenced.update( + u.strip() for u in (bangumi.rss_link or "").split(",") if u.strip() + ) + for url in urls - still_referenced: + rss_item = await self.db.rss.search_url(url) + if rss_item and not rss_item.aggregate: + await self.db.rss.delete(rss_item.id) + logger.info(f"[Manager] Delete orphan RSS feed {url}") + async def delete_rule(self, _id: int | str, file: bool = False): data = await self.db.bangumi.search_id(int(_id)) if isinstance(data, Bangumi): - # NOTE: RSSDatabase.delete() takes an int RSS-item id, not a - # title; there is no reliable bangumi -> RSSItem link to look one - # up here (RSSItem.url may be shared by several aggregated - # bangumi), so we deliberately don't touch RSS items on rule - # deletion rather than risk deleting a feed other bangumi still - # depend on. # Clean up torrent records so re-adding the same anime can re-download await self.db.torrent.delete_by_bangumi_id(int(_id)) await self.db.bangumi.delete_one(int(_id)) + # 番剧删除后清理其独立订阅的孤儿 RSS;聚合订阅不受影响 + await self._delete_orphan_sub_rss(data) torrent_message = None if file: # Only the file-cleanup path needs the downloader, so an diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index f3c63f5e..3e34eaae 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -1021,6 +1021,23 @@ class TestRSSDeleteWithTorrents: assert result is True +class TestRSSSearchUrl: + """Tests for RSSDatabase.search_url (exact-URL lookup, #1053).""" + + async def test_search_url_returns_matching_item(self, db_session): + rss_db = RSSDatabase(db_session) + rss = RSSItem(url="https://mikanani.me/RSS/Bangumi?bangumiId=9", name="Sub") + await rss_db.add(rss) + + found = await rss_db.search_url("https://mikanani.me/RSS/Bangumi?bangumiId=9") + assert found is not None + assert found.id == rss.id + + async def test_search_url_missing_returns_none(self, db_session): + rss_db = RSSDatabase(db_session) + assert await rss_db.search_url("https://example.com/absent") is None + + class TestOrphanTorrents: """Tests for TorrentDatabase orphan-torrent operations (bangumi_id IS NULL).""" diff --git a/backend/src/test/test_manager_torrent.py b/backend/src/test/test_manager_torrent.py new file mode 100644 index 00000000..d37d686d --- /dev/null +++ b/backend/src/test/test_manager_torrent.py @@ -0,0 +1,89 @@ +"""Tests for TorrentManager.delete_rule RSS cleanup (#1053). + +删除番剧时应一并清理其独立订阅(aggregate=False)的孤儿 RSS 条目; +聚合订阅与仍被其他番剧引用的订阅不受影响。 +""" + +from module.database import Database +from module.manager import TorrentManager +from test.factories import make_bangumi, make_rss_item + +SUB_URL = "https://mikanani.me/RSS/Bangumi?bangumiId=9&subgroupid=1" + + +async def _seed(db: Database, *, bangumi=(), rss=()): + for b in bangumi: + await db.bangumi.add(b) + for r in rss: + await db.rss.add(r) + + +class TestDeleteRuleRSSCleanup: + async def test_delete_rule_removes_orphan_sub_rss(self, db_engine): + async with Database(engine=db_engine) as db: + await _seed( + db, + bangumi=[make_bangumi(id=1, rss_link=SUB_URL)], + rss=[make_rss_item(url=SUB_URL, aggregate=False)], + ) + manager = TorrentManager(db) + + resp = await manager.delete_rule(1, file=False) + + assert resp.status is True + assert await db.rss.search_url(SUB_URL) is None + + async def test_delete_rule_keeps_aggregate_rss(self, db_engine): + async with Database(engine=db_engine) as db: + await _seed( + db, + bangumi=[make_bangumi(id=1, rss_link=SUB_URL)], + rss=[make_rss_item(url=SUB_URL, aggregate=True)], + ) + manager = TorrentManager(db) + + await manager.delete_rule(1, file=False) + + assert await db.rss.search_url(SUB_URL) is not None + + async def test_delete_rule_keeps_rss_referenced_by_other_bangumi(self, db_engine): + async with Database(engine=db_engine) as db: + await _seed( + db, + bangumi=[ + make_bangumi(id=1, rss_link=SUB_URL), + make_bangumi( + id=2, + official_title="Other Anime", + title_raw="Other Anime Raw", + rss_link=f"https://example.com/other,{SUB_URL}", + ), + ], + rss=[make_rss_item(url=SUB_URL, aggregate=False)], + ) + manager = TorrentManager(db) + + await manager.delete_rule(1, file=False) + + assert await db.rss.search_url(SUB_URL) is not None + + async def test_delete_rule_comma_joined_link_removes_each_orphan_feed( + self, db_engine + ): + url_a = "https://mikanani.me/RSS/Bangumi?bangumiId=1" + url_b = "https://mikanani.me/RSS/Bangumi?bangumiId=2" + async with Database(engine=db_engine) as db: + await _seed( + db, + bangumi=[make_bangumi(id=1, rss_link=f"{url_a},{url_b}")], + rss=[ + make_rss_item(url=url_a, aggregate=False), + make_rss_item(url=url_b, aggregate=False), + ], + ) + manager = TorrentManager(db) + + await manager.delete_rule(1, file=False) + + assert await db.rss.search_url(url_a) is None + assert await db.rss.search_url(url_b) is None