mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-01 21:26:08 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user