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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
This commit is contained in:
Estrella Pan
2026-07-07 12:42:35 +02:00
parent b659795288
commit 8f91899670
4 changed files with 134 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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