diff --git a/backend/src/module/api/rss.py b/backend/src/module/api/rss.py index d4362e6c..5f930b3c 100644 --- a/backend/src/module/api/rss.py +++ b/backend/src/module/api/rss.py @@ -13,6 +13,10 @@ from .response import u_response router = APIRouter(prefix="/rss", tags=["rss"]) +# RSSItem.parser 的合法取值(与 webui ab-add-rss 的选项一致); +# 这些值即便与搜索站点同名(mikan)也不做站点名映射 +PARSER_TYPES = {"mikan", "tmdb", "parser"} + @router.get( path="", response_model=list[RSSItem], dependencies=[Depends(get_current_user)] @@ -205,10 +209,14 @@ async def download_collection(data: Bangumi): "/subscribe", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) async def subscribe(data: Bangumi, rss: RSSItem): - # 搜索订阅时前端传来的是站点名(nyaa/dmhy/mikan),而分析器只认识解析器 - # 类型 mikan/tmdb(见 rss/analyser.py),站点名会静默跳过 TMDB 补全(#1053)。 - # 这里按搜索源配置把站点名映射为解析器;已是解析器类型的值原样透传。 - providers = get_provider() - parser = providers[rss.parser]["parser"] if rss.parser in providers else rss.parser + # 搜索订阅时前端传来的是站点名(nyaa/dmhy),而分析器只认识解析器类型 + # mikan/tmdb(见 rss/analyser.py),站点名会静默跳过 TMDB 补全(#1053)。 + # 这里按搜索源配置把站点名映射为解析器;已是解析器类型的值原样透传, + # 且不参与站点映射——避免 "mikan" 这类与站点同名的解析器被配置改写。 + parser = rss.parser + if parser not in PARSER_TYPES: + providers = get_provider() + if parser in providers: + parser = providers[parser]["parser"] resp = await SeasonCollector.subscribe_season(data, parser=parser) return u_response(resp) diff --git a/backend/src/module/database/rss.py b/backend/src/module/database/rss.py index 0f68923d..cc345b24 100644 --- a/backend/src/module/database/rss.py +++ b/backend/src/module/database/rss.py @@ -94,9 +94,11 @@ class RSSDatabase: return await self.session.get(RSSItem, _id) async def search_url(self, url: str) -> RSSItem | None: + # url 仅有索引而无唯一约束(去重只在 add() 里做),遗留库可能存在 + # 重复行,用 first() 而非 scalar_one_or_none() 以免抛 MultipleResultsFound statement = select(RSSItem).where(RSSItem.url == url) result = await self.session.execute(statement) - return result.scalar_one_or_none() + return result.scalars().first() async def search_all(self) -> list[RSSItem]: result = await self.session.execute(select(RSSItem)) diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index 226c2082..e5aa166c 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -52,11 +52,13 @@ class TorrentManager: msg_zh=f"无法找到 {data.official_title} 的种子", ) - async def _delete_orphan_sub_rss(self, data: Bangumi) -> None: - """删除该番剧独立订阅(aggregate=False)且不再被引用的 RSS 条目(#1053)。 + async def _disable_orphan_sub_rss(self, data: Bangumi) -> None: + """停用该番剧独立订阅(aggregate=False)且不再被引用的 RSS 条目(#1053)。 - 聚合订阅由多个番剧共享,永不在此处删除;rss_link 可能是逗号拼接的 - 多个链接(见 BangumiDatabase.match_list),逐个拆分精确匹配。 + 停用而非删除:无法区分搜索订阅与用户手动添加的独立订阅,删除还会 + 连带清掉该 rss_id 下可能属于其他番剧的种子去重记录。聚合订阅由多个 + 番剧共享,永不在此处停用;rss_link 可能是逗号拼接的多个链接 + (见 BangumiDatabase.match_list),逐个拆分精确匹配。 """ urls = {u.strip() for u in (data.rss_link or "").split(",") if u.strip()} if not urls: @@ -67,11 +69,14 @@ class TorrentManager: still_referenced.update( u.strip() for u in (bangumi.rss_link or "").split(",") if u.strip() ) + orphan_ids: list[int] = [] 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}") + if rss_item and not rss_item.aggregate and rss_item.enabled: + orphan_ids.append(rss_item.id) + logger.info(f"[Manager] Disable orphan RSS feed {url}") + if orphan_ids: + await self.db.rss.disable_batch(orphan_ids) async def delete_rule(self, _id: int | str, file: bool = False): data = await self.db.bangumi.search_id(int(_id)) @@ -79,8 +84,8 @@ class TorrentManager: # 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) + # 番剧删除后停用其独立订阅的孤儿 RSS;聚合订阅不受影响 + await self._disable_orphan_sub_rss(data) torrent_message = None if file: # Only the file-cleanup path needs the downloader, so an diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index e478c167..85983060 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -1,5 +1,6 @@ import asyncio import logging +import math from typing import Any import httpx @@ -33,9 +34,13 @@ def _retry_after_delay(response: httpx.Response) -> float: retry_after = response.headers.get("Retry-After") if retry_after is not None: try: - return min(max(float(retry_after), 0.0), HTTP_429_MAX_RETRY_AFTER) + value = float(retry_after) except ValueError: - pass + return HTTP_429_FALLBACK_DELAY + # float("nan") 能通过解析,且 min/max 会传播 NaN → asyncio.sleep(nan) 永不唤醒 + if math.isnan(value): + return HTTP_429_FALLBACK_DELAY + return min(max(value, 0.0), HTTP_429_MAX_RETRY_AFTER) return HTTP_429_FALLBACK_DELAY diff --git a/backend/src/test/test_api_rss.py b/backend/src/test/test_api_rss.py index 258be53c..cdf8206c 100644 --- a/backend/src/test/test_api_rss.py +++ b/backend/src/test/test_api_rss.py @@ -419,3 +419,35 @@ class TestSubscribe: assert response.status_code == 200 assert mock_subscribe.await_args is not None assert mock_subscribe.await_args.kwargs["parser"] == "tmdb" + + def test_subscribe_known_parser_type_never_remapped(self, authed_client): + """parser='mikan' 是解析器类型而非站点名,即便用户自定义了 mikan + 站点的解析器(如 tmdb),也不应被站点映射改写。""" + customized = { + "mikan": { + "url": "https://mikanani.me/RSS/Search?searchstr=%s", + "parser": "tmdb", + }, + } + resp_model = ResponseModel( + status=True, status_code=200, msg_en="Subscribed.", msg_zh="订阅成功。" + ) + with ( + patch( + "module.api.rss.SeasonCollector.subscribe_season", + new_callable=AsyncMock, + return_value=resp_model, + ) as mock_subscribe, + patch("module.api.rss.get_provider", return_value=customized), + ): + response = authed_client.post( + "/api/v1/rss/subscribe", + json={ + "data": make_bangumi(id=1).model_dump(), + "rss": {"url": "https://mikanani.me/RSS/link", "parser": "mikan"}, + }, + ) + + assert response.status_code == 200 + assert mock_subscribe.await_args is not None + assert mock_subscribe.await_args.kwargs["parser"] == "mikan" diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index 3e34eaae..f2cc57dd 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -1037,6 +1037,20 @@ class TestRSSSearchUrl: rss_db = RSSDatabase(db_session) assert await rss_db.search_url("https://example.com/absent") is None + async def test_search_url_duplicate_urls_returns_one_without_raising( + self, db_session + ): + """URL 无数据库唯一约束,遗留库可能有重复行;不应抛 MultipleResultsFound。""" + url = "https://mikanani.me/RSS/dup" + db_session.add(RSSItem(url=url, name="A")) + db_session.add(RSSItem(url=url, name="B")) + await db_session.commit() + + rss_db = RSSDatabase(db_session) + found = await rss_db.search_url(url) + assert found is not None + assert found.url == url + 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 index d37d686d..72de0e6b 100644 --- a/backend/src/test/test_manager_torrent.py +++ b/backend/src/test/test_manager_torrent.py @@ -1,7 +1,8 @@ """Tests for TorrentManager.delete_rule RSS cleanup (#1053). -删除番剧时应一并清理其独立订阅(aggregate=False)的孤儿 RSS 条目; -聚合订阅与仍被其他番剧引用的订阅不受影响。 +删除番剧时应停用其独立订阅(aggregate=False)的孤儿 RSS 条目(停用而非删除: +无法区分搜索订阅与用户手动添加的独立订阅,删除会连带清掉其他番剧的种子 +去重记录);聚合订阅与仍被其他番剧引用的订阅不受影响。 """ from module.database import Database @@ -19,7 +20,7 @@ async def _seed(db: Database, *, bangumi=(), rss=()): class TestDeleteRuleRSSCleanup: - async def test_delete_rule_removes_orphan_sub_rss(self, db_engine): + async def test_delete_rule_disables_orphan_sub_rss(self, db_engine): async with Database(engine=db_engine) as db: await _seed( db, @@ -31,9 +32,11 @@ class TestDeleteRuleRSSCleanup: resp = await manager.delete_rule(1, file=False) assert resp.status is True - assert await db.rss.search_url(SUB_URL) is None + rss_item = await db.rss.search_url(SUB_URL) + assert rss_item is not None + assert rss_item.enabled is False - async def test_delete_rule_keeps_aggregate_rss(self, db_engine): + async def test_delete_rule_keeps_aggregate_rss_enabled(self, db_engine): async with Database(engine=db_engine) as db: await _seed( db, @@ -44,7 +47,9 @@ class TestDeleteRuleRSSCleanup: await manager.delete_rule(1, file=False) - assert await db.rss.search_url(SUB_URL) is not None + rss_item = await db.rss.search_url(SUB_URL) + assert rss_item is not None + assert rss_item.enabled is True async def test_delete_rule_keeps_rss_referenced_by_other_bangumi(self, db_engine): async with Database(engine=db_engine) as db: @@ -65,9 +70,11 @@ class TestDeleteRuleRSSCleanup: await manager.delete_rule(1, file=False) - assert await db.rss.search_url(SUB_URL) is not None + rss_item = await db.rss.search_url(SUB_URL) + assert rss_item is not None + assert rss_item.enabled is True - async def test_delete_rule_comma_joined_link_removes_each_orphan_feed( + async def test_delete_rule_comma_joined_link_disables_each_orphan_feed( self, db_engine ): url_a = "https://mikanani.me/RSS/Bangumi?bangumiId=1" @@ -85,5 +92,28 @@ class TestDeleteRuleRSSCleanup: 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 + for url in (url_a, url_b): + rss_item = await db.rss.search_url(url) + assert rss_item is not None + assert rss_item.enabled is False + + async def test_delete_rule_keeps_orphan_feed_torrent_history(self, db_engine): + """停用而非删除:不应连带清掉该 RSS 关联的种子去重记录。""" + from module.models import Torrent + + 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)], + ) + rss_item = await db.rss.search_url(SUB_URL) + assert rss_item is not None + await db.torrent.add( + Torrent(name="ep01", url="https://example.com/1", rss_id=rss_item.id) + ) + manager = TorrentManager(db) + + await manager.delete_rule(1, file=False) + + assert len(await db.torrent.search_rss(rss_item.id)) == 1 diff --git a/backend/src/test/test_request_url.py b/backend/src/test/test_request_url.py index 31bb953e..abc209da 100644 --- a/backend/src/test/test_request_url.py +++ b/backend/src/test/test_request_url.py @@ -218,6 +218,13 @@ class TestRateLimit429: assert result is not None mock_sleep.assert_awaited_once_with(HTTP_429_MAX_RETRY_AFTER) + async def test_get_url_429_with_nan_retry_after_uses_fallback(self): + result, _, mock_sleep = await self._run( + [self._resp_429({"Retry-After": "nan"}), self._resp_ok()] + ) + assert result is not None + mock_sleep.assert_awaited_once_with(HTTP_429_FALLBACK_DELAY) + async def test_get_url_429_with_date_retry_after_uses_fallback(self): result, _, mock_sleep = await self._run( [