fix: address code-review findings on #1052/#1053 fixes

- Disable (not delete) orphaned per-sub RSS feeds on bangumi deletion:
  a hard delete could not distinguish search-created subscriptions from
  user-added non-aggregate feeds, and RSSDatabase.delete's cascade also
  wiped torrent dedupe records that other bangumi may rely on. Disabled
  feeds keep their history and can be re-enabled; batch-disabled in one
  commit via disable_batch.
- Never remap known parser types (mikan/tmdb/parser) through the
  search-provider config in /rss/subscribe; only genuine site names
  (nyaa/dmhy) are mapped. Prevents parser='mikan' from being rewritten
  when the user customized the mikan provider's parser.
- RSSDatabase.search_url tolerates duplicate-URL rows (first() instead
  of scalar_one_or_none) - url has no DB unique constraint.
- _retry_after_delay rejects NaN Retry-After values, which would
  propagate through min/max into asyncio.sleep(nan) and never wake.

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 13:02:32 +02:00
parent 63821917a5
commit 5fdc2e723f
8 changed files with 130 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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