feat(4.0-P2.5): 下载操作按实例路由,重命名逐实例运行,不可用实例跳过并通知

- DownloadClient(instance_id) 按实例缓存客户端,凭据闩锁按实例隔离;DownloaderPool 在一次操作内惰性进入多个实例
- 新种子投递到规则的实例,规则未指定时用订阅的,再退回默认实例;种子行记录实际实例
- 规则 / 订阅 / 电影的 downloader_id 为空表示跟随默认实例(修改未发布的迁移 v26)
- rename_all 逐实例重命名,事件带真实 downloader_id;版本替换事务按实例过滤;不可用实例只在首次不可用时发 DownloaderUnavailableEvent
- 删除规则按种子所在的各实例删除;规则换实例时旧种子留在原位置
- /downloader/torrents 汇总所有实例并标注实例,暂停 / 恢复 / 删除 / 打标按 downloader_id 路由,新增 /downloader/instances
This commit is contained in:
Estrella Pan
2026-10-08 12:29:51 +02:00
parent e4a7fc8483
commit dd505d67ae
36 changed files with 929 additions and 320 deletions

View File

@@ -166,12 +166,13 @@ class DownloaderUnavailableEvent(SystemEvent):
host: str
reason: str # unreachable | credentials | banned
instance_id: str = "default" # 下载器实例 id(plugins.instances)
def dedup_key(self) -> str | None:
return f"downloader:{self.host}"
return f"downloader:{self.instance_id}"
def payload(self) -> dict:
return {"host": self.host, "reason": self.reason}
return {"host": self.host, "reason": self.reason, "instance": self.instance_id}
def describe(self) -> tuple[str, str]:
details = {
@@ -180,7 +181,10 @@ class DownloaderUnavailableEvent(SystemEvent):
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
}
detail = details.get(self.reason, details["unreachable"])
return ("下载器连接异常", f"下载器:{self.host}\n{detail}")
return (
"下载器连接异常",
f"下载器:{self.instance_id}({self.host})\n{detail}",
)
@dataclass(frozen=True, slots=True)

View File

@@ -6,8 +6,8 @@ from pydantic import BaseModel
from module.conf import settings
from module.database import Database, get_db
from module.downloader import DownloadClient
from module.manager import Renamer, TorrentManager
from module.manager import TorrentManager
from module.manager.renamer import rename_all
from module.models import APIResponse, Bangumi, BangumiUpdate, ResponseModel, Torrent
from module.parser.analyser.offset_detector import detect_offset_mismatch
from module.parser.analyser.tmdb_parser import tmdb_parser
@@ -376,9 +376,7 @@ async def dismiss_review(bangumi_id: int):
async def _trigger_rename() -> None:
"""Run a rename pass so applied offsets take effect immediately."""
async with DownloadClient() as client:
renamer = Renamer(client)
await renamer.rename()
await rename_all()
# Registered before /apply-offset/{bangumi_id} so "many" is never captured as an id.

View File

@@ -4,13 +4,15 @@ import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from module.conf import settings
from module.database import Database
from module.database.bangumi import (
build_save_path_index,
match_bangumi_in_list,
normalize_save_path,
)
from module.downloader import DownloadClient
from module.downloader import DownloadClient, downloader_ids, list_torrents
from module.models.config import DOWNLOADER_POINT
from module.security.api import get_current_user
logger = logging.getLogger(__name__)
@@ -20,10 +22,11 @@ router = APIRouter(prefix="/downloader", tags=["downloader"])
class TorrentHashesRequest(BaseModel):
hashes: list[str]
# 种子所在的下载器实例;为空时为默认实例
downloader_id: str | None = None
class TorrentDeleteRequest(BaseModel):
hashes: list[str]
class TorrentDeleteRequest(TorrentHashesRequest):
delete_files: bool = False
@@ -32,12 +35,37 @@ class TorrentTagRequest(BaseModel):
hash: str
bangumi_id: int
downloader_id: str | None = None
def _client(downloader_id: str | None) -> DownloadClient:
if downloader_id is not None and downloader_id not in downloader_ids():
raise HTTPException(
status_code=404, detail=f"Downloader instance {downloader_id} not found"
)
return DownloadClient(downloader_id)
@router.get("/instances", dependencies=[Depends(get_current_user)])
async def get_instances():
"""下载器实例列表(供规则 / 订阅选择下载器)。"""
return {
"default": settings.plugins.slots.downloader,
"instances": [
{"id": i.id, "provider": i.provider}
for i in settings.plugins.instances
if i.point == DOWNLOADER_POINT
],
}
@router.get("/torrents", dependencies=[Depends(get_current_user)])
async def get_torrents():
async with DownloadClient() as client:
return await client.get_torrent_info(category="Bangumi", status_filter=None)
"""所有下载器实例的种子(每条带 downloader_id);不可用的实例跳过。"""
torrents = await list_torrents()
if torrents is None:
raise HTTPException(status_code=503, detail="No downloader is reachable")
return torrents
@router.get("/rename-conflicts", dependencies=[Depends(get_current_user)])
@@ -79,7 +107,7 @@ async def retry_rename_conflict(operation_id: int):
@router.post("/torrents/pause", dependencies=[Depends(get_current_user)])
async def pause_torrents(req: TorrentHashesRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
async with _client(req.downloader_id) as client:
await client.pause_torrent(hashes)
return {"msg_en": "Torrents paused", "msg_zh": "种子已暂停"}
@@ -87,7 +115,7 @@ async def pause_torrents(req: TorrentHashesRequest):
@router.post("/torrents/resume", dependencies=[Depends(get_current_user)])
async def resume_torrents(req: TorrentHashesRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
async with _client(req.downloader_id) as client:
await client.resume_torrent(hashes)
return {"msg_en": "Torrents resumed", "msg_zh": "种子已恢复"}
@@ -95,7 +123,7 @@ async def resume_torrents(req: TorrentHashesRequest):
@router.post("/torrents/delete", dependencies=[Depends(get_current_user)])
async def delete_torrents(req: TorrentDeleteRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
async with _client(req.downloader_id) as client:
ok = await client.delete_torrent(hashes, delete_files=req.delete_files)
if not ok:
return {
@@ -124,7 +152,7 @@ async def tag_torrent(req: TorrentTagRequest):
}
tag = f"ab:{req.bangumi_id}"
async with DownloadClient() as client:
async with _client(req.downloader_id) as client:
await client.add_tag(req.hash, tag)
return {
@@ -150,42 +178,45 @@ async def auto_tag_torrents():
bangumi_list = await db.bangumi.search_all()
save_path_index = build_save_path_index(bangumi_list)
async with DownloadClient() as client:
# Get all Bangumi torrents
torrents = await client.get_torrent_info(category="Bangumi", status_filter=None)
for instance_id in downloader_ids():
async with DownloadClient(instance_id) as client:
# Get all Bangumi torrents
torrents = await client.get_torrent_info(
category="Bangumi", status_filter=None
)
for torrent in torrents:
torrent_hash = torrent["hash"]
torrent_name = torrent["name"]
save_path = torrent["save_path"]
tags = torrent.get("tags", "")
for torrent in torrents:
torrent_hash = torrent["hash"]
torrent_name = torrent["name"]
save_path = torrent["save_path"]
tags = torrent.get("tags", "")
# Skip if already has an ab:<id> link tag。必须精确匹配数字 id:
# ab:renamed(处理完成标记)等同前缀标签不代表已关联番剧
if re.search(r"ab:\d+", tags):
continue
# Skip if already has an ab:<id> link tag。必须精确匹配数字 id:
# ab:renamed(处理完成标记)等同前缀标签不代表已关联番剧
if re.search(r"ab:\d+", tags):
continue
# First try by torrent name, then fall back to save_path
bangumi = match_bangumi_in_list(torrent_name, bangumi_list)
if not bangumi:
bangumi = save_path_index.get(normalize_save_path(save_path))
# First try by torrent name, then fall back to save_path
bangumi = match_bangumi_in_list(torrent_name, bangumi_list)
if not bangumi:
bangumi = save_path_index.get(normalize_save_path(save_path))
if bangumi and not bangumi.deleted:
tag = f"ab:{bangumi.id}"
await client.add_tag(torrent_hash, tag)
tagged_count += 1
logger.info(
f"Tagged '{torrent_name[:50]}...' with {tag} "
f"(matched: {bangumi.official_title})"
)
else:
unmatched.append(
{
"hash": torrent_hash,
"name": torrent_name,
"save_path": save_path,
}
)
if bangumi and not bangumi.deleted:
tag = f"ab:{bangumi.id}"
await client.add_tag(torrent_hash, tag)
tagged_count += 1
logger.info(
f"Tagged '{torrent_name[:50]}...' with {tag} "
f"(matched: {bangumi.official_title})"
)
else:
unmatched.append(
{
"hash": torrent_hash,
"name": torrent_name,
"save_path": save_path,
}
)
return {
"status": True,

View File

@@ -22,7 +22,7 @@ from module.api.log import _read_log_tail
from module.conf import LOG_PATH, VERSION
from module.core import AppContext
from module.database import Database
from module.downloader import DownloadClient
from module.downloader import list_torrents
from module.notification.inbox import InboxChanged, inbox_revision
from module.plugin.host import get_bus
from module.security.api import get_current_user
@@ -54,25 +54,14 @@ def _status_payload(ctx: AppContext) -> dict:
}
async def _fetch_torrents() -> list[dict]:
async with DownloadClient() as client:
return await client.get_torrent_info(category="Bangumi", status_filter=None)
async def _downloader_payload() -> list[dict] | None:
"""获取种子列表;下载器未配置、不可达或超时时返回 None。
"""获取所有下载器实例的种子列表;全部实例不可达或超时时返回 None。
asyncio.wait_for 超时会取消内部任务并等待其退出,慢查询不会泄漏;
None 会以 null 推送给前端,作为显式的"下载器不可用"信号。
超时按实例计算,一个实例不可用不影响其它实例的种子;asyncio.wait_for
超时会取消内部任务并等待其退出,慢查询不会泄漏。None 会以 null 推送给
前端,作为显式的"下载器不可用"信号。
"""
try:
return await asyncio.wait_for(_fetch_torrents(), _DOWNLOADER_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
logger.debug("SSE: downloader status fetch timed out")
return None
except Exception:
logger.debug("SSE: downloader status unavailable", exc_info=True)
return None
return await list_torrents(timeout=_DOWNLOADER_TIMEOUT_SECONDS)
async def _notification_payload() -> dict:

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from module.database import Database, get_db
from module.downloader import DownloadClient
from module.downloader import DownloadClient, DownloaderPool, resolve_downloader_id
from module.manager import SeasonCollector
from module.models import APIResponse, Bangumi, Movie, RSSItem, RSSUpdate, Torrent
from module.rss import RSSAnalyser, RSSEngine
@@ -140,9 +140,9 @@ async def update_rss(
dependencies=[Depends(get_current_user)],
)
async def refresh_all(db: Database = Depends(get_db)):
async with DownloadClient() as client:
async with DownloaderPool() as downloaders:
engine = RSSEngine(db)
await engine.refresh_rss(client)
await engine.refresh_rss(downloaders)
return JSONResponse(
status_code=200,
content={
@@ -158,9 +158,9 @@ async def refresh_all(db: Database = Depends(get_db)):
dependencies=[Depends(get_current_user)],
)
async def refresh_rss(rss_id: int, db: Database = Depends(get_db)):
async with DownloadClient() as client:
async with DownloaderPool() as downloaders:
engine = RSSEngine(db)
await engine.refresh_rss(client, rss_id)
await engine.refresh_rss(downloaders, rss_id)
return JSONResponse(
status_code=200,
content={"msg_en": "Refresh RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
@@ -200,7 +200,7 @@ async def analysis(rss: RSSItem):
"/collect", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def download_collection(data: Bangumi):
async with DownloadClient() as client:
async with DownloadClient(resolve_downloader_id(data.downloader_id)) as client:
collector = SeasonCollector(client)
resp = await collector.collect_season(data, data.rss_link)
return u_response(resp)

View File

@@ -271,7 +271,9 @@ class AppContext:
try:
await self.notifier.send_event(
DownloaderUnavailableEvent(
host=self.settings.downloader.host, reason=reason
host=self.settings.downloader.host,
reason=reason,
instance_id=self.settings.plugins.slots.downloader,
)
)
except Exception:

View File

@@ -9,8 +9,9 @@ import logging
from module.conf import settings
from module.database import Database
from module.downloader import DownloadClient
from module.manager import Renamer, TorrentManager, eps_complete
from module.downloader import DownloaderPool
from module.manager import TorrentManager, eps_complete
from module.manager.renamer import rename_all
from module.notification import NotificationManager, UpdateAvailableEvent
from module.rss import RSSAnalyser, RSSEngine
from module.update.updater import updater
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
async def rss_tick(analyser: RSSAnalyser, notifier: NotificationManager) -> None:
"""Analyse aggregate RSS feeds and refresh the RSS engine once."""
async with DownloadClient() as client:
async with DownloaderPool() as downloaders:
async with Database() as db:
engine = RSSEngine(db)
# Analyse RSS
@@ -46,7 +47,7 @@ async def rss_tick(analyser: RSSAnalyser, notifier: NotificationManager) -> None
exc_info=True,
)
# Run RSS Engine
events = await engine.refresh_rss(client)
events = await engine.refresh_rss(downloaders)
if events:
# 站内通知中心要求事件总是送达 notifier(send_event 内部先落库,
# 再按 settings.notification.enable 决定是否外发)。
@@ -61,11 +62,8 @@ async def rss_tick(analyser: RSSAnalyser, notifier: NotificationManager) -> None
async def rename_tick(notifier: NotificationManager) -> None:
"""Rename completed downloads and notify via the shared notifier."""
async with DownloadClient() as client:
renamer = Renamer(client)
renamed_info = await renamer.rename()
rename_events = list(renamer.events)
"""Rename completed downloads on every downloader instance and notify."""
renamed_info, rename_events = await rename_all()
if rename_events:
await asyncio.gather(*[notifier.send_event(event) for event in rename_events])
if settings.notification.enable and renamed_info:

View File

@@ -100,8 +100,14 @@ class Migration:
return self.statements
# 存量行归属默认下载器实例(plugins.instances 中 id 为 default 的实例)
_DOWNLOADER_ID_TABLES = ("bangumi", "movie", "rssitem", "torrent")
# 规则与订阅的 downloader_id 为空表示「用默认实例」,存量行保持为空;
# 种子记录实际所在的实例,存量种子都在 3.3 的下载器,即实例 default
_DOWNLOADER_ID_COLUMNS = {
"bangumi": "downloader_id VARCHAR",
"movie": "downloader_id VARCHAR",
"rssitem": "downloader_id VARCHAR",
"torrent": "downloader_id VARCHAR DEFAULT 'default'",
}
def _has_downloader_id(table: str) -> AppliedCheck:
@@ -817,17 +823,13 @@ MIGRATIONS: tuple[Migration, ...] = (
26,
"add downloader_id to bangumi, movie, rssitem and torrent",
tuple(
f"ALTER TABLE {table} ADD COLUMN downloader_id VARCHAR DEFAULT 'default'"
for table in _DOWNLOADER_ID_TABLES
f"ALTER TABLE {table} ADD COLUMN {column}"
for table, column in _DOWNLOADER_ID_COLUMNS.items()
),
all_checks(*(_has_downloader_id(table) for table in _DOWNLOADER_ID_TABLES)),
all_checks(*(_has_downloader_id(table) for table in _DOWNLOADER_ID_COLUMNS)),
tuple(
(
f"ALTER TABLE {table} ADD COLUMN downloader_id VARCHAR "
"DEFAULT 'default'",
_has_downloader_id(table),
)
for table in _DOWNLOADER_ID_TABLES
(f"ALTER TABLE {table} ADD COLUMN {column}", _has_downloader_id(table))
for table, column in _DOWNLOADER_ID_COLUMNS.items()
),
),
)

View File

@@ -356,10 +356,14 @@ class RenameOperationDatabase:
)
return list(result.scalars().all())
async def list_active_replacements(self, limit: int = 100) -> list[RenameOperation]:
async def list_active_replacements(
self, downloader_type: str, limit: int = 100
) -> list[RenameOperation]:
"""某个下载器实例(``downloader_type``)上未完成的版本替换事务。"""
result = await self.session.execute(
select(RenameOperation)
.where(
RenameOperation.downloader_type == downloader_type,
RenameOperation.kind == "replacement",
col(RenameOperation.state).in_(
("planned", "old_staged", "new_promoted", "old_removed")

View File

@@ -6,15 +6,26 @@ from .base import (
RenameOutcome,
RenameResult,
)
from .download_client import DownloadClient, shutdown
from .download_client import (
DownloadClient,
DownloaderPool,
downloader_ids,
list_torrents,
resolve_downloader_id,
shutdown,
)
__all__ = [
"AddResult",
"CoreDownloaderClient",
"DownloadClient",
"DownloaderPool",
"DownloaderCapabilities",
"DownloaderClient",
"RenameOutcome",
"RenameResult",
"downloader_ids",
"list_torrents",
"resolve_downloader_id",
"shutdown",
]

View File

@@ -1,12 +1,14 @@
import asyncio
import logging
from collections import defaultdict
from contextlib import AsyncExitStack
from urllib.parse import urlparse
from ab_sdk import points
from ab_sdk.ingest import AddRequest
from module.conf import settings
from module.models import Bangumi, Torrent
from module.models.config import DOWNLOADER_POINT, DownloaderInstance
from module.network import RequestContent
from module.plugin import host as plugin_host
from module.plugin.views import bangumi_info, torrent_info
@@ -116,24 +118,44 @@ async def _apply_adding_hooks(
# `_bookkeeping_lock` serializes reads/writes of this shared state across the
# awaits in ``__aenter__``/``__aexit__``.
# ---------------------------------------------------------------------------
_client_cache: tuple[tuple, DownloaderClient] | None = None
# 实例 id → (连接设置 key, 具体客户端)。每个下载器实例各缓存一个客户端。
_client_cache: dict[str, tuple[tuple, DownloaderClient]] = {}
_stale_clients: list[DownloaderClient] = []
_active_holders: dict[int, int] = {}
_pending_close: set[int] = set()
_bookkeeping_lock = asyncio.Lock()
# 凭据被服务端明确拒绝后的闩锁:记录失败时的连接设置 key。命中时 enter 直接
# 失败、不再发 login POST——每个 tick 重试一次登录,约 5 次即触发 qB 的
# 凭据被服务端明确拒绝后的闩锁:实例 id → 失败时的连接设置 key。命中时 enter
# 直接失败、不再发 login POST——每个 tick 重试一次登录,约 5 次即触发 qB 的
# WebUI IP ban。设置变更(key 不同)自然解锁;同值重存经
# clear_credential_latch()(AppContext.reload_settings)解锁。
_credential_failed_key: tuple | None = None
_credential_failed: dict[str, tuple] = {}
# Warn at most once per (client type, operation) when a backend cannot perform
# an operation, so aria2 users are not spammed every rename cycle.
_warned_unsupported: set[tuple[str, str]] = set()
def _settings_key() -> tuple:
d = settings.downloader
def downloader_ids() -> list[str]:
"""所有下载器实例 id(配置顺序)。"""
return [i.id for i in settings.plugins.instances if i.point == DOWNLOADER_POINT]
def resolve_downloader_id(*candidates: str | None) -> str:
"""取第一个指向已配置实例的候选 id;都为空时用默认实例(slots.downloader)。
指向已删除实例的候选记录告警后跳过,不让一条规则卡住整轮。
"""
known = downloader_ids()
for candidate in candidates:
if not candidate:
continue
if candidate in known:
return candidate
logger.warning("Downloader instance %r no longer exists; skipped", candidate)
return settings.plugins.slots.downloader
def _settings_key(d: DownloaderInstance) -> tuple:
# 带上 Provider 登记项:插件重载后登记项换新,旧客户端随之退役
entry = plugin_host.get_registry().providers(points.DOWNLOADER).get(d.type)
return (d.type, d.host, d.username, d.password, d.ssl, entry)
@@ -141,8 +163,8 @@ def _settings_key() -> tuple:
def _reset_client_cache() -> None:
"""Drop the cached/stale concrete clients and refcount state (used by tests)."""
global _client_cache, _stale_clients, _active_holders, _pending_close
_client_cache = None
global _stale_clients, _active_holders, _pending_close
_client_cache.clear()
_stale_clients = []
_active_holders = {}
_pending_close = set()
@@ -150,9 +172,8 @@ def _reset_client_cache() -> None:
def clear_credential_latch() -> None:
"""解除凭据失败闩锁(配置保存后调用,允许用户重试同值凭据)。"""
global _credential_failed_key
_credential_failed_key = None
"""解除所有实例的凭据失败闩锁(配置保存后调用,允许用户重试同值凭据)。"""
_credential_failed.clear()
async def _close_client(client) -> None:
@@ -163,15 +184,13 @@ async def _close_client(client) -> None:
async def shutdown() -> None:
"""Log out and close the cached concrete client.
"""Log out and close every cached concrete client.
Invoked by the composition root (`AppContext`) on application shutdown.
"""
global _client_cache, _stale_clients
clients = list(_stale_clients)
if _client_cache is not None:
clients.append(_client_cache[1])
_client_cache = None
global _stale_clients
clients = list(_stale_clients) + [c for _, c in _client_cache.values()]
_client_cache.clear()
_stale_clients = []
for client in clients:
await _close_client(client)
@@ -187,30 +206,37 @@ class DownloadClient:
down by :func:`shutdown`.
"""
def __init__(self):
global _client_cache
key = _settings_key()
def __init__(self, instance_id: str | None = None):
# None:默认实例(slots.downloader)
self.instance: DownloaderInstance = (
settings.downloader
if instance_id is None
else settings.downloader_instance(instance_id)
)
self.instance_id = self.instance.id
key = _settings_key(self.instance)
self.client: DownloaderClient
self._cache_key = key
if _client_cache is not None and _client_cache[0] == key:
self.client = _client_cache[1]
cached = _client_cache.get(self.instance_id)
if cached is not None and cached[0] == key:
self.client = cached[1]
else:
if _client_cache is not None:
if cached is not None:
# Settings changed: retire the previous client, close it later.
# 用列表累积——连续两次改设置(期间没有 enter)不得把第一个
# 被撤下的客户端顶掉,否则它的连接池泄漏到进程结束。
_stale_clients.append(_client_cache[1])
self.client = self.__getClient()
_client_cache = (key, self.client)
_stale_clients.append(cached[1])
self.client = self.__getClient(self.instance)
_client_cache[self.instance_id] = (key, self.client)
self.authed = False
@staticmethod
def __getClient() -> DownloaderClient:
"""按 ``downloader.type`` 从扩展注册表取下载器工厂并实例化。
def __getClient(instance: DownloaderInstance) -> DownloaderClient:
"""按实例的 Provider id 从扩展注册表取下载器工厂并实例化。
内置的 qbittorrent / aria2 / mock 与插件提供的下载器走同一条路径。
"""
downloader_type = settings.downloader.type
downloader_type = instance.type
factory = plugin_host.provider(points.DOWNLOADER, downloader_type)
if factory is None:
logger.error("Unsupported downloader type: %s", downloader_type)
@@ -218,10 +244,10 @@ class DownloadClient:
if downloader_type == "mock":
logger.debug("Using MockDownloader for local development")
conn = DownloaderConnection(
host=settings.downloader.host,
username=settings.downloader.username,
password=settings.downloader.password,
ssl=settings.downloader.ssl,
host=instance.host,
username=instance.username,
password=instance.password,
ssl=instance.ssl,
)
# 只实现 CoreDownloaderClient 的后端(如 aria2 没有 qB 的 RSS 规则)
# 由 _supports() 按 capabilities 跳过不支持的操作
@@ -251,11 +277,7 @@ class DownloadClient:
return False
async def __aenter__(self):
global _client_cache, _credential_failed_key
if (
_credential_failed_key is not None
and _credential_failed_key == self._cache_key
):
if _credential_failed.get(self.instance_id) == self._cache_key:
# 凭据上次已被服务端明确拒绝且设置未变:不再发 login POST,
# 避免逐 tick 累积到 qB 的 IP ban。checker/等待循环读的是
# 具体客户端上的失败原因,这里补齐。
@@ -297,15 +319,16 @@ class DownloadClient:
raise
if not self.authed:
if self.last_auth_error == "credentials":
_credential_failed_key = self._cache_key
_credential_failed[self.instance_id] = self._cache_key
# Release our slot and close the concrete client's connection
# pool now or it leaks on every failed connect (#1043) --
# unless another already-entered block still holds it, in
# which case defer to its __aexit__ instead of yanking the
# pool out from under it.
async with _bookkeeping_lock:
if _client_cache is not None and _client_cache[1] is self.client:
_client_cache = None
cached = _client_cache.get(self.instance_id)
if cached is not None and cached[1] is self.client:
del _client_cache[self.instance_id]
_pending_close.add(id(self.client))
await self._release_holder()
raise ConnectionError("Download client authentication failed")
@@ -441,7 +464,10 @@ class DownloadClient:
调用方据此决定是否重试或发送失败通知。
"""
if not bangumi.save_path:
bangumi.save_path = gen_save_path(bangumi)
bangumi.save_path = gen_save_path(bangumi, self.instance.path)
# 种子行记录实际投递的实例,之后的重命名 / 删除按它路由
for t in torrent if isinstance(torrent, list) else [torrent]:
t.downloader_id = self.instance_id
torrent_url: str | list[str] | None
async with RequestContent() as req:
if isinstance(torrent, list):
@@ -513,3 +539,67 @@ class DownloadClient:
return
await self.client.add_tag(torrent_hash, tag)
logger.debug("Added tag '%s' to torrent %s...", tag, torrent_hash[:8])
class DownloaderPool:
"""一次操作内按实例 id 惰性进入 :class:`DownloadClient`,退出时全部释放。
进入失败的实例在本池内记住、直接抛 ``ConnectionError``,不再重复登录
(一轮 RSS 可能有很多种子指向同一个不可用实例)。
"""
def __init__(self) -> None:
self._stack = AsyncExitStack()
self._clients: dict[str, DownloadClient] = {}
self._failed: set[str] = set()
async def __aenter__(self) -> "DownloaderPool":
return self
async def __aexit__(self, *_: object) -> None:
await self._stack.aclose()
async def get(self, instance_id: str) -> DownloadClient:
if instance_id in self._failed:
raise ConnectionError(f"Downloader instance {instance_id} is unavailable")
if instance_id not in self._clients:
try:
self._clients[instance_id] = await self._stack.enter_async_context(
DownloadClient(instance_id)
)
except Exception as e:
self._failed.add(instance_id)
raise ConnectionError(
f"Downloader instance {instance_id} is unavailable: {e}"
) from e
return self._clients[instance_id]
async def list_torrents(
status_filter: str | None = None, timeout: float | None = None
) -> list[dict] | None:
"""所有下载器实例中 Bangumi 分类的种子,每条带 ``downloader_id``。
不可用(或超过 ``timeout`` 秒)的实例跳过;全部实例都不可用时返回 None。
"""
async def one(instance_id: str) -> list[dict]:
async with DownloadClient(instance_id) as client:
torrents = await client.get_torrent_info(
category="Bangumi", status_filter=status_filter
)
return [{**t, "downloader_id": instance_id} for t in torrents]
ids = downloader_ids()
results = await asyncio.gather(
*(asyncio.wait_for(one(i), timeout) for i in ids), return_exceptions=True
)
listed: list[dict] = []
reachable = False
for instance_id, result in zip(ids, results):
if isinstance(result, BaseException):
logger.debug("Downloader instance %s unavailable: %s", instance_id, result)
continue
reachable = True
listed.extend(result)
return listed if reachable else None

View File

@@ -70,12 +70,15 @@ def check_files(files: list[dict]):
return media_list, subtitle_list
def path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str = ""):
def path_to_bangumi(
save_path: PathLike[str] | str, torrent_name: str = "", root: str | None = None
):
"""``root`` 为种子所在下载器实例的下载目录,缺省为默认实例的。"""
# Use PureWindowsPath regardless of the host AB runs on: it accepts
# both "\" and "/" separators, so a qBittorrent-on-Windows save_path
# reaching a Linux AB still splits into segments correctly (#1016).
save_parts = PureWindowsPath(save_path).parts
download_parts = PureWindowsPath(settings.downloader.path).parts
download_parts = PureWindowsPath(root or settings.downloader.path).parts
# Get bangumi name and season
bangumi_name = ""
season = 1
@@ -107,9 +110,13 @@ def _media_folder(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
return "Unknown Bangumi"
def gen_save_path(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
def gen_save_path(
data: Bangumi | BangumiUpdate | Movie | MovieUpdate, root: str | None = None
) -> str:
"""Generate save path for a bangumi.
``root`` 为目标下载器实例的下载目录,缺省为默认实例的。
The save path uses the adjusted season number (season + season_offset)
so files are saved directly to the correct season folder.
@@ -121,7 +128,7 @@ def gen_save_path(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
episode_type = getattr(data, "episode_type", "episode")
if isinstance(data, (Movie, MovieUpdate)) or episode_type == "movie":
# 电影/剧场版:Title (Year)/Title (Year).ext,不建 Season 子目录
return str(Path(settings.downloader.path) / folder)
return str(Path(root or settings.downloader.path) / folder)
# Apply season_offset to get the adjusted season number for the folder
adjusted_season = data.season + getattr(data, "season_offset", 0)
# 季号下限:普通剧集最小为 1——偏移到 Season 0 会被 Plex/Jellyfin 当作
@@ -132,7 +139,9 @@ def gen_save_path(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
logger.warning(
f"Season offset would result in invalid season for {data.official_title}, using original season"
)
save_path = Path(settings.downloader.path) / folder / f"Season {adjusted_season}"
save_path = (
Path(root or settings.downloader.path) / folder / f"Season {adjusted_season}"
)
return str(save_path)

View File

@@ -2,7 +2,12 @@ import logging
from module.database import Database
from module.database.bangumi import release_fits_bangumi
from module.downloader import AddResult, DownloadClient
from module.downloader import (
AddResult,
DownloadClient,
DownloaderPool,
resolve_downloader_id,
)
from module.models import Bangumi, ResponseModel
from module.network import RequestContent
from module.rss import RSSEngine
@@ -129,10 +134,12 @@ async def eps_complete():
datas = await db.bangumi.not_complete()
if datas:
logger.info("Start collecting full season...")
async with DownloadClient() as client:
collector = SeasonCollector(client)
async with DownloaderPool() as downloaders:
for data in datas:
if not data.eps_collect:
await collector.collect_season(data)
client = await downloaders.get(
resolve_downloader_id(data.downloader_id)
)
await SeasonCollector(client).collect_season(data)
data.eps_collect = True
await db.bangumi.update_all(datas)

View File

@@ -21,10 +21,19 @@ from module.database.bangumi import (
match_bangumi_in_list,
normalize_save_path,
)
from module.downloader import DownloadClient, RenameOutcome, RenameResult
from module.downloader import (
DownloadClient,
RenameOutcome,
RenameResult,
downloader_ids,
)
from module.downloader.path import check_files, is_ep, path_to_bangumi
from module.models import EpisodeFile, Notification, SubtitleFile
from module.notification import RenameSkippedEvent, SystemEvent
from module.notification import (
DownloaderUnavailableEvent,
RenameSkippedEvent,
SystemEvent,
)
from module.parser import TitleParser
from module.plugin import host as plugin_host
from module.plugin.registry import ProviderEntry
@@ -44,8 +53,8 @@ from .revision_saga import (
logger = logging.getLogger(__name__)
# 下载器 id:多下载器(P2.5)之前只有一个
_DOWNLOADER_ID = "default"
# 上一轮不可用的下载器实例:只在「可用 → 不可用」时通知一次
_unavailable: set[str] = set()
# 已记录过「未登记」日志的重命名方式(每个进程只记一次,重新登记后清除)
_missing_methods: set[str] = set()
# 已发布过的 TorrentOrganized(种子 hash → 最终文件)。未打 ab:renamed 标签的
@@ -589,7 +598,7 @@ class Renamer(RevisionSaga):
old_path=_downloader_path(save_path, old),
new_path=_downloader_path(save_path, new),
file_kind=kind,
downloader_id=_DOWNLOADER_ID,
downloader_id=self.client.instance_id,
)
)
reason = self._skipped.pop(torrent_hash, None)
@@ -624,7 +633,7 @@ class Renamer(RevisionSaga):
torrent_hash=torrent_hash,
bangumi_id=bangumi_id,
files=files,
downloader_id=_DOWNLOADER_ID,
downloader_id=self.client.instance_id,
)
)
@@ -651,6 +660,10 @@ class Renamer(RevisionSaga):
# Collect all hashes for batch query
hashes = [info["hash"] for info in torrents_info]
torrent_records = await db.torrent.search_by_qb_hashes(hashes)
# 同一 hash 在多个实例都有记录时,以本实例的记录为准(排在后面覆盖)
torrent_records.sort(
key=lambda r: r.downloader_id == self.client.instance_id
)
hash_to_bangumi_id = {
r.qb_hash: r.bangumi_id for r in torrent_records if r.bangumi_id
}
@@ -758,7 +771,9 @@ class Renamer(RevisionSaga):
info_by_hash.setdefault(info["hash"], info)
all_infos = list(info_by_hash.values())
async with Database() as db:
active_replacements = await db.rename_operation.list_active_replacements()
active_replacements = await db.rename_operation.list_active_replacements(
self._downloader_type()
)
active_replacement_ids = {
operation.new_task_id for operation in active_replacements
}
@@ -808,7 +823,9 @@ class Renamer(RevisionSaga):
)
continue
media_list, subtitle_list = check_files(files)
bangumi_name, season = path_to_bangumi(save_path, torrent_name)
bangumi_name, season = path_to_bangumi(
save_path, torrent_name, self.client.instance.path
)
episode_offset, episode_type = offset_map[torrent_hash]
kwargs = {
"torrent_name": torrent_name,
@@ -875,3 +892,38 @@ class Renamer(RevisionSaga):
)
logger.debug("Rename process finished.")
return renamed_info
async def rename_all() -> tuple[list[Notification], list[SystemEvent]]:
"""逐个下载器实例运行一轮重命名。
进不去的实例(连不上、凭据被拒、Provider 未登记)跳过,不影响其它实例;
它从可用变为不可用时产生一条 :class:`DownloaderUnavailableEvent`。
"""
renamed: list[Notification] = []
events: list[SystemEvent] = []
for instance_id in downloader_ids():
client: DownloadClient | None = None
try:
client = DownloadClient(instance_id)
await client.__aenter__()
except Exception as e:
logger.warning("Downloader %s unavailable, skipped: %s", instance_id, e)
if instance_id not in _unavailable:
_unavailable.add(instance_id)
events.append(
DownloaderUnavailableEvent(
host=settings.downloader_instance(instance_id).host,
reason=(client and client.last_auth_error) or "unreachable",
instance_id=instance_id,
)
)
continue
_unavailable.discard(instance_id)
try:
renamer = Renamer(client)
renamed += await renamer.rename()
events += renamer.events
finally:
await client.__aexit__(None, None, None)
return renamed, events

View File

@@ -167,13 +167,14 @@ class RevisionSaga:
return value <= datetime.now(timezone.utc)
def _downloader_type(self) -> str:
configured = settings.downloader.type
instance = self.client.instance
configured = instance.type
downloader_type = (
configured
if isinstance(configured, str)
else type(self.client.client).__name__.lower()
)
host = settings.downloader.host
host = instance.host
if not isinstance(host, str) or not host:
return downloader_type
instance_hash = hashlib.sha256(host.strip().lower().encode()).hexdigest()[:12]
@@ -191,7 +192,9 @@ class RevisionSaga:
incoming_id = self._parse_bangumi_id_from_tags(incoming.get("tags"))
_, incoming_season = path_to_bangumi(
incoming.get("save_path", ""), incoming.get("name", "")
incoming.get("save_path", ""),
incoming.get("name", ""),
self.client.instance.path,
)
incoming_identity = parse_revision_identity(
incoming.get("name", ""),
@@ -229,7 +232,9 @@ class RevisionSaga:
):
continue
_, old_season = path_to_bangumi(
info.get("save_path", ""), info.get("name", "")
info.get("save_path", ""),
info.get("name", ""),
self.client.instance.path,
)
owners.append(
RevisionOwner(

View File

@@ -3,7 +3,7 @@ import logging
from module.conf import settings
from module.database import Database
from module.database.bangumi import normalize_save_path
from module.downloader import DownloadClient
from module.downloader import DownloadClient, downloader_ids, resolve_downloader_id
from module.downloader.path import gen_save_path
from module.downloader.rules import build_rss_rule
from module.models import Bangumi, BangumiUpdate, ResponseModel
@@ -19,8 +19,10 @@ class TorrentManager:
self.db = db
@staticmethod
async def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:
async with DownloadClient() as client:
async def __match_torrents_list(
data: Bangumi | BangumiUpdate, instance_id: str | None = None
) -> list:
async with DownloadClient(instance_id) as client:
torrents = await client.get_torrent_info(status_filter=None)
target_save_path = normalize_save_path(data.save_path)
return [
@@ -29,16 +31,33 @@ class TorrentManager:
if normalize_save_path(torrent.get("save_path")) == target_save_path
]
async def delete_torrents(self, data: Bangumi, client: DownloadClient):
hash_list = await self.__match_torrents_list(data)
if hash_list:
if not await client.delete_torrent(hash_list):
async def _torrent_instances(self, data: Bangumi) -> list[str]:
"""番剧的种子所在的下载器实例:种子行记录的实例,加上规则当前的实例。
规则换过下载器时,旧种子仍在原实例上,删除要逐个实例处理。
"""
rows = await self.db.torrent.search_by_bangumi_id(data.id)
ids = {r.downloader_id for r in rows}
ids.add(resolve_downloader_id(data.downloader_id))
return sorted(ids & set(downloader_ids()))
async def delete_torrents(self, data: Bangumi, instance_ids: list[str]):
found = False
for instance_id in instance_ids:
hash_list = await self.__match_torrents_list(data, instance_id)
if not hash_list:
continue
found = True
async with DownloadClient(instance_id) as client:
deleted = await client.delete_torrent(hash_list)
if not deleted:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Failed to delete torrents for {data.official_title}",
msg_zh=f"删除 {data.official_title} 种子失败",
)
if found:
logger.info(f"Delete rule and torrents for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -83,6 +102,8 @@ class TorrentManager:
async def delete_rule(self, _id: int | str, file: bool = False):
data = await self.db.bangumi.search_id(int(_id))
if isinstance(data, Bangumi):
# 种子行删除前先记下种子所在的实例
instance_ids = await self._torrent_instances(data) if file else []
# 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))
@@ -92,16 +113,15 @@ class TorrentManager:
if file:
# Only the file-cleanup path needs the downloader, so an
# unreachable downloader shouldn't block a DB-only delete.
async with DownloadClient() as client:
torrent_message = await self.delete_torrents(data, client)
if torrent_message.status_code == 500:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Deleted rule for {data.official_title}, "
"but deleting its torrents failed.",
msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。",
)
torrent_message = await self.delete_torrents(data, instance_ids)
if torrent_message.status_code == 500:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Deleted rule for {data.official_title}, "
"but deleting its torrents failed.",
msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。",
)
logger.info(f"Delete rule for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -125,8 +145,9 @@ class TorrentManager:
if file:
# Only the file-cleanup path needs the downloader, so an
# unreachable downloader shouldn't block a DB-only disable.
async with DownloadClient() as client:
return await self.delete_torrents(data, client)
return await self.delete_torrents(
data, await self._torrent_instances(data)
)
logger.info(f"Disable rule for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -165,25 +186,29 @@ class TorrentManager:
async def update_rule(self, bangumi_id, data: BangumiUpdate):
old_data = await self.db.bangumi.search_id(bangumi_id)
if old_data:
# Move torrent
match_list = await self.__match_torrents_list(old_data)
async with DownloadClient() as client:
new_path = gen_save_path(data)
old_path = old_data.save_path
old_instance = resolve_downloader_id(old_data.downloader_id)
new_instance = resolve_downloader_id(data.downloader_id)
new_path = gen_save_path(
data, settings.downloader_instance(new_instance).path
)
old_path = old_data.save_path
# 换了下载器实例:旧种子留在原实例原位置,之后的新种子进新实例
if new_instance == old_instance and new_path != old_path:
match_list = await self.__match_torrents_list(old_data, old_instance)
async with DownloadClient(old_instance) as client:
# Move existing torrents to new location if path changed
if match_list:
await client.move_torrent(match_list, new_path)
logger.info(f"Moved torrents from {old_path} to {new_path}")
# Move existing torrents to new location if path changed
if match_list and new_path != old_path:
await client.move_torrent(match_list, new_path)
logger.info(f"Moved torrents from {old_path} to {new_path}")
# Update qBittorrent RSS rule if save_path changed
if new_path != old_path and old_data.rule_name:
# Recreate the rule with the new save_path
rule = build_rss_rule(data, new_path)
await client.set_rss_rule(old_data.rule_name, rule)
logger.info(
f"Updated RSS rule {old_data.rule_name} with new save_path"
)
# Update qBittorrent RSS rule if save_path changed
if old_data.rule_name:
# Recreate the rule with the new save_path
rule = build_rss_rule(data, new_path)
await client.set_rss_rule(old_data.rule_name, rule)
logger.info(
f"Updated RSS rule {old_data.rule_name} with new save_path"
)
data.save_path = new_path
await self.db.bangumi.update(data, bangumi_id)

View File

@@ -6,7 +6,7 @@ from mcp import types
from module.conf import VERSION
from module.database import Database
from module.downloader import DownloadClient
from module.downloader import DownloaderPool, list_torrents
from module.manager import SeasonCollector, TorrentManager
from module.models import Bangumi, BangumiUpdate, RSSItem
from module.rss import RSSAnalyser, RSSEngine
@@ -301,10 +301,7 @@ async def _unsubscribe_anime(bangumi_id: int, delete: bool) -> dict:
async def _list_downloads(status: str) -> list[dict]:
status_filter = None if status == "all" else status
async with DownloadClient() as client:
torrents = await client.get_torrent_info(
status_filter=status_filter, category="Bangumi"
)
torrents = await list_torrents(status_filter) or []
return [
{
"name": t.get("name", ""),
@@ -314,6 +311,7 @@ async def _list_downloads(status: str) -> list[dict]:
"dlspeed": t.get("dlspeed", 0),
"upspeed": t.get("upspeed", 0),
"eta": t.get("eta", 0),
"downloader": t["downloader_id"],
}
for t in torrents
]
@@ -348,10 +346,10 @@ def _get_program_status() -> dict:
async def _refresh_feeds() -> dict:
async with DownloadClient() as client:
async with DownloaderPool() as downloaders:
async with Database() as db:
engine = RSSEngine(db)
await engine.refresh_rss(client)
await engine.refresh_rss(downloaders)
return {"status": True, "message": "RSS feeds refreshed successfully"}

View File

@@ -61,8 +61,9 @@ class Bangumi(SQLModel, table=True):
episode_type: str = Field(
default="episode", alias="episode_type", title="剧集类型"
) # "episode" | "movie" | "special"
downloader_id: str = Field(
default="default", alias="downloader_id", title="下载器实例"
# 为空时用默认下载器实例(plugins.slots.downloader)
downloader_id: Optional[str] = Field(
default=None, alias="downloader_id", title="下载器实例"
)
@@ -87,6 +88,9 @@ class BangumiUpdate(SQLModel):
added: bool = Field(default=False, alias="added", title="是否已添加")
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
downloader_id: Optional[str] = Field(
default=None, alias="downloader_id", title="下载器实例"
)
deleted: bool = Field(False, alias="deleted", title="是否已删除")
archived: bool = Field(default=False, alias="archived", title="是否已归档")
air_weekday: Optional[int] = Field(

View File

@@ -466,10 +466,15 @@ class Config(BaseModel):
def downloader_instance(self, instance_id: str) -> DownloaderInstance:
instance = next(
i
for i in self.plugins.instances
if i.id == instance_id and i.point == DOWNLOADER_POINT
(
i
for i in self.plugins.instances
if i.id == instance_id and i.point == DOWNLOADER_POINT
),
None,
)
if instance is None:
raise KeyError(f"unknown downloader instance {instance_id!r}")
options = DownloaderOptions.model_validate(instance.options)
return DownloaderInstance(
id=instance.id,

View File

@@ -25,8 +25,9 @@ class Movie(SQLModel, table=True):
save_path: str | None = Field(default=None, alias="save_path", title="保存路径")
rule_name: str | None = Field(default=None, alias="rule_name", title="规则名")
filter: str = Field(default="", alias="filter", title="过滤器")
downloader_id: str = Field(
default="default", alias="downloader_id", title="下载器实例"
# 为空时用默认下载器实例
downloader_id: str | None = Field(
default=None, alias="downloader_id", title="下载器实例"
)
@@ -42,3 +43,4 @@ class MovieUpdate(SQLModel):
rss_link: str | None = None
save_path: str | None = None
filter: str | None = None
downloader_id: str | None = None

View File

@@ -13,7 +13,8 @@ class RSSItem(SQLModel, table=True):
connection_status: Optional[str] = Field(None, alias="connection_status")
last_checked_at: Optional[str] = Field(None, alias="last_checked_at")
last_error: Optional[str] = Field(None, alias="last_error")
downloader_id: str = Field("default", alias="downloader_id")
# 由此订阅新建的规则继承;为空时用默认下载器实例
downloader_id: Optional[str] = Field(None, alias="downloader_id")
class RSSUpdate(SQLModel):
@@ -22,3 +23,4 @@ class RSSUpdate(SQLModel):
aggregate: Optional[bool] = Field(True, alias="aggregate")
parser: Optional[str] = Field("mikan", alias="parser")
enabled: Optional[bool] = Field(True, alias="enabled")
downloader_id: Optional[str] = Field(None, alias="downloader_id")

View File

@@ -70,6 +70,7 @@ class RSSAnalyser:
movie=result, rss=rss, torrent=torrent
)
result.rss_link = rss.url
result.downloader_id = rss.downloader_id
seen_identities.add(identity)
new_movies.append(result)
logger.info(f"New movie found: {result.official_title}")
@@ -78,6 +79,7 @@ class RSSAnalyser:
bangumi=result, rss=rss, torrent=torrent
)
result.rss_link = rss.url
result.downloader_id = rss.downloader_id
if not full_parse:
return [result], new_movies
seen_identities.add(identity)
@@ -98,6 +100,7 @@ class RSSAnalyser:
fetch_poster=fetch_poster,
)
result.rss_link = rss.url
result.downloader_id = rss.downloader_id
else:
await self.official_title_parser(
bangumi=result,
@@ -106,6 +109,7 @@ class RSSAnalyser:
fetch_poster=fetch_poster,
)
result.rss_link = rss.url
result.downloader_id = rss.downloader_id
return result
return None

View File

@@ -14,7 +14,12 @@ from module.database.bangumi import (
match_bangumi_in_list,
release_fits_bangumi,
)
from module.downloader import AddResult, DownloadClient
from module.downloader import (
AddResult,
DownloadClient,
DownloaderPool,
resolve_downloader_id,
)
from module.models import Bangumi, ResponseModel, RSSItem, Torrent
from module.network import RequestContent
from module.notification.events import (
@@ -323,14 +328,17 @@ class RSSEngine:
return skip_ids
async def refresh_rss(
self, client: DownloadClient, rss_id: Optional[int] = None
self, downloaders: DownloaderPool, rss_id: Optional[int] = None
) -> list[SystemEvent]:
"""Refresh feeds with one parser engine for the complete workflow."""
"""Refresh feeds with one parser engine for the complete workflow.
匹配到的种子投递到规则的下载器实例,规则未指定时用订阅的,再退回默认实例。
"""
with parser_engine_snapshot():
return await self._refresh_rss(client, rss_id)
return await self._refresh_rss(downloaders, rss_id)
async def _refresh_rss(
self, client: DownloadClient, rss_id: Optional[int] = None
self, downloaders: DownloaderPool, rss_id: Optional[int] = None
) -> list[SystemEvent]:
# Get All RSS Items
if not rss_id:
@@ -438,6 +446,17 @@ class RSSEngine:
matched_data.official_title,
)
continue
try:
client = await downloaders.get(
resolve_downloader_id(
matched_data.downloader_id, rss_item.downloader_id
)
)
except ConnectionError as e:
# 实例不可用:不入库,下一轮重试;不可用由重命名轮次通知
logger.warning("Skip %s: %s", torrent.name, e)
failed_ids.add(id(torrent))
continue
result = await client.add_torrent(torrent, matched_data)
if result is AddResult.FAILED:
# 投递失败:不入库(check_new 按 URL 去重,入库就
@@ -480,7 +499,9 @@ class RSSEngine:
t for t in torrents if await self.plugin_accepts(t, bangumi)
]
if torrents:
async with DownloadClient() as client:
async with DownloadClient(
resolve_downloader_id(bangumi.downloader_id)
) as client:
result = await client.add_torrent(torrents, bangumi)
if result is AddResult.FAILED:
return ResponseModel(

View File

@@ -2,6 +2,7 @@
from datetime import datetime, timezone
from module.downloader import DownloaderPool
from module.models import Bangumi, RSSItem, Torrent
from module.models.config import Config
from module.models.passkey import Passkey
@@ -85,3 +86,14 @@ def make_passkey(**overrides) -> Passkey:
)
defaults.update(overrides)
return Passkey(**defaults)
class SingleClientPool(DownloaderPool):
"""所有实例都返回同一个(模拟)客户端的下载器池。"""
def __init__(self, client) -> None:
super().__init__()
self.client = client
async def get(self, instance_id: str):
return self.client

View File

@@ -297,79 +297,64 @@ class TestApplyOffset:
"""POST /bangumi/apply-offset/{id} applies offset and reruns the renamer."""
mock_db = MagicMock()
mock_db.bangumi.apply_offset = AsyncMock(return_value=True)
mock_renamer = AsyncMock()
mock_rename_all = AsyncMock(return_value=([], []))
with patch("module.api.bangumi.Database", _async_db_mock(mock_db)):
with patch("module.api.bangumi.Renamer", return_value=mock_renamer):
with patch("module.api.bangumi.DownloadClient") as MockClient:
MockClient.return_value.__aenter__ = AsyncMock(
return_value=MagicMock()
)
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
response = authed_client.post("/api/v1/bangumi/apply-offset/1")
with patch("module.api.bangumi.rename_all", mock_rename_all):
response = authed_client.post("/api/v1/bangumi/apply-offset/1")
assert response.status_code == 200
data = response.json()
assert data["status"] is True
mock_db.bangumi.apply_offset.assert_awaited_once_with(1)
mock_renamer.rename.assert_awaited_once()
mock_rename_all.assert_awaited_once()
def test_apply_offset_not_found_skips_rename(self, authed_client):
"""POST /bangumi/apply-offset/{id} with a non-existent bangumi does not rename."""
mock_db = MagicMock()
mock_db.bangumi.apply_offset = AsyncMock(return_value=False)
with patch("module.api.bangumi.Database", _async_db_mock(mock_db)):
with patch("module.api.bangumi.Renamer") as MockRenamer:
with patch("module.api.bangumi.rename_all") as mock_rename_all:
response = authed_client.post("/api/v1/bangumi/apply-offset/999")
assert response.status_code == 404
MockRenamer.assert_not_called()
mock_rename_all.assert_not_called()
def test_apply_offset_many_success(self, authed_client):
"""POST /bangumi/apply-offset/many applies offsets to every id and renames once."""
mock_db = MagicMock()
mock_db.bangumi.apply_offset = AsyncMock(side_effect=[True, True])
mock_renamer = AsyncMock()
mock_rename_all = AsyncMock(return_value=([], []))
with patch("module.api.bangumi.Database", _async_db_mock(mock_db)):
with patch("module.api.bangumi.Renamer", return_value=mock_renamer):
with patch("module.api.bangumi.DownloadClient") as MockClient:
MockClient.return_value.__aenter__ = AsyncMock(
return_value=MagicMock()
)
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
response = authed_client.post(
"/api/v1/bangumi/apply-offset/many", json=[1, 2]
)
with patch("module.api.bangumi.rename_all", mock_rename_all):
response = authed_client.post(
"/api/v1/bangumi/apply-offset/many", json=[1, 2]
)
assert response.status_code == 200
assert mock_db.bangumi.apply_offset.await_count == 2
mock_renamer.rename.assert_awaited_once()
mock_rename_all.assert_awaited_once()
def test_apply_offset_many_partial_failure(self, authed_client):
"""POST /bangumi/apply-offset/many reports a partial failure and still renames."""
mock_db = MagicMock()
mock_db.bangumi.apply_offset = AsyncMock(side_effect=[True, False])
mock_renamer = AsyncMock()
mock_rename_all = AsyncMock(return_value=([], []))
with patch("module.api.bangumi.Database", _async_db_mock(mock_db)):
with patch("module.api.bangumi.Renamer", return_value=mock_renamer):
with patch("module.api.bangumi.DownloadClient") as MockClient:
MockClient.return_value.__aenter__ = AsyncMock(
return_value=MagicMock()
)
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
response = authed_client.post(
"/api/v1/bangumi/apply-offset/many", json=[1, 2]
)
with patch("module.api.bangumi.rename_all", mock_rename_all):
response = authed_client.post(
"/api/v1/bangumi/apply-offset/many", json=[1, 2]
)
assert response.status_code == 500
mock_renamer.rename.assert_awaited_once()
mock_rename_all.assert_awaited_once()
def test_apply_offset_many_empty_list(self, authed_client):
"""POST /bangumi/apply-offset/many with an empty list is a 400, no DB/rename calls."""
with patch("module.api.bangumi.Renamer") as MockRenamer:
with patch("module.api.bangumi.rename_all") as mock_rename_all:
response = authed_client.post("/api/v1/bangumi/apply-offset/many", json=[])
assert response.status_code == 400
MockRenamer.assert_not_called()
mock_rename_all.assert_not_called()
def test_apply_offset_auth_required(self, unauthed_client):
"""POST /bangumi/apply-offset/{id} requires authentication."""

View File

@@ -182,7 +182,7 @@ class TestOtherAuthRequired:
class TestGetTorrents:
def test_get_torrents_success(self, authed_client, mock_download_client):
"""GET /downloader/torrents returns list of torrents."""
with patch("module.api.downloader.DownloadClient") as MockClient:
with patch("module.downloader.download_client.DownloadClient") as MockClient:
MockClient.return_value.__aenter__ = AsyncMock(
return_value=mock_download_client
)
@@ -194,11 +194,12 @@ class TestGetTorrents:
data = response.json()
assert len(data) == 2
assert data[0]["hash"] == "abc123"
assert data[0]["downloader_id"] == "default"
def test_get_torrents_empty(self, authed_client, mock_download_client):
"""GET /downloader/torrents returns empty list when no torrents."""
mock_download_client.get_torrent_info.return_value = []
with patch("module.api.downloader.DownloadClient") as MockClient:
with patch("module.downloader.download_client.DownloadClient") as MockClient:
MockClient.return_value.__aenter__ = AsyncMock(
return_value=mock_download_client
)

View File

@@ -84,10 +84,12 @@ class TestDownloaderPayload:
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("module.api.events.DownloadClient", return_value=mock_client):
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
result = await _downloader_payload()
assert result == torrents
assert result == [{**torrents[0], "downloader_id": "default"}]
mock_client.get_torrent_info.assert_called_once_with(
category="Bangumi", status_filter=None
)
@@ -99,7 +101,9 @@ class TestDownloaderPayload:
mock_client.__aenter__ = AsyncMock(side_effect=RuntimeError("no downloader"))
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("module.api.events.DownloadClient", return_value=mock_client):
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
result = await _downloader_payload()
assert result is None
@@ -132,7 +136,10 @@ class TestDownloaderPayloadTimeout:
mock_client = _make_hung_client(cancelled)
with (
patch("module.api.events.DownloadClient", return_value=mock_client),
patch(
"module.downloader.download_client.DownloadClient",
return_value=mock_client,
),
patch("module.api.events._DOWNLOADER_TIMEOUT_SECONDS", 0.05),
):
start = time.monotonic()
@@ -163,7 +170,10 @@ class TestEventGeneratorNonBlocking:
mock_client = _make_hung_client(cancelled)
with (
patch("module.api.events.DownloadClient", return_value=mock_client),
patch(
"module.downloader.download_client.DownloadClient",
return_value=mock_client,
),
patch("module.api.events._DOWNLOADER_TIMEOUT_SECONDS", 0.05),
patch("module.api.events.LOG_PATH", tmp_path / "missing.log"),
):

View File

@@ -6,7 +6,7 @@ from module.database import Database
from module.downloader import AddResult
from module.models import EpisodeFile, Torrent
from module.rss.engine import RSSEngine
from test.factories import make_bangumi, make_rss_item
from test.factories import SingleClientPool, make_bangumi, make_rss_item
# ---------------------------------------------------------------------------
# RSS → Download Flow
@@ -58,7 +58,7 @@ class TestRssToDownloadFlow:
mock_client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
# 5. Execute refresh_rss
await engine.refresh_rss(mock_client)
await engine.refresh_rss(SingleClientPool(mock_client))
# 6. Verify: matched torrents were downloaded
assert mock_client.add_torrent.call_count == 2
@@ -109,7 +109,7 @@ class TestRssToDownloadFlow:
mock_get.return_value = torrents
mock_client = AsyncMock()
mock_client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await engine.refresh_rss(mock_client)
await engine.refresh_rss(SingleClientPool(mock_client))
# Only 1080p should be downloaded (720p is filtered)
assert mock_client.add_torrent.call_count == 1
@@ -149,7 +149,7 @@ class TestRssToDownloadFlow:
mock_get.return_value = torrents
mock_client = AsyncMock()
mock_client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await engine.refresh_rss(mock_client)
await engine.refresh_rss(SingleClientPool(mock_client))
# Only ep02 should be downloaded (ep01 already exists)
assert mock_client.add_torrent.call_count == 1

View File

@@ -99,7 +99,7 @@ class TestRssTick:
with (
patch(
"module.core.loops.DownloadClient",
"module.core.loops.DownloaderPool",
return_value=_async_cm(AsyncMock()),
),
patch("module.core.loops.Database", return_value=_async_cm(mock_db)),
@@ -125,7 +125,7 @@ class TestRssTick:
with (
patch(
"module.core.loops.DownloadClient",
"module.core.loops.DownloaderPool",
return_value=_async_cm(AsyncMock()),
),
patch("module.core.loops.Database", return_value=_async_cm(mock_db)),
@@ -144,6 +144,15 @@ class TestRssTick:
# ---------------------------------------------------------------------------
def _rename_all(renamer):
"""rename_all 的替身:返回模拟 Renamer 的结果与事件。"""
async def run():
return await renamer.rename(), list(renamer.events)
return run
class TestRenameTick:
async def test_persists_and_dispatches_conflict_event_when_push_disabled(self):
event = RenameConflictEvent(
@@ -158,11 +167,7 @@ class TestRenameTick:
mock_renamer.events = [event]
with (
patch(
"module.core.loops.DownloadClient",
return_value=_async_cm(AsyncMock()),
),
patch("module.core.loops.Renamer", return_value=mock_renamer),
patch("module.core.loops.rename_all", _rename_all(mock_renamer)),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.notification.enable = False
@@ -180,11 +185,7 @@ class TestRenameTick:
mock_renamer.rename = AsyncMock(return_value=[notify])
with (
patch(
"module.core.loops.DownloadClient",
return_value=_async_cm(AsyncMock()),
),
patch("module.core.loops.Renamer", return_value=mock_renamer),
patch("module.core.loops.rename_all", _rename_all(mock_renamer)),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.notification.enable = True
@@ -200,11 +201,7 @@ class TestRenameTick:
mock_renamer.rename = AsyncMock(return_value=[notify])
with (
patch(
"module.core.loops.DownloadClient",
return_value=_async_cm(AsyncMock()),
),
patch("module.core.loops.Renamer", return_value=mock_renamer),
patch("module.core.loops.rename_all", _rename_all(mock_renamer)),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.notification.enable = False
@@ -232,11 +229,7 @@ class TestRenameTick:
mock_renamer.rename = AsyncMock(return_value=notifications)
with (
patch(
"module.core.loops.DownloadClient",
return_value=_async_cm(AsyncMock()),
),
patch("module.core.loops.Renamer", return_value=mock_renamer),
patch("module.core.loops.rename_all", _rename_all(mock_renamer)),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.notification.enable = True

View File

@@ -401,7 +401,9 @@ class TestDispatch:
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("module.mcp.tools.DownloadClient", return_value=mock_client):
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
result = await _dispatch("list_downloads", {"status": "all"})
mock_client.get_torrent_info.assert_called_once_with(
@@ -417,7 +419,9 @@ class TestDispatch:
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("module.mcp.tools.DownloadClient", return_value=mock_client):
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
await _dispatch("list_downloads", {"status": "downloading"})
mock_client.get_torrent_info.assert_called_once_with(
@@ -443,7 +447,9 @@ class TestDispatch:
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("module.mcp.tools.DownloadClient", return_value=mock_client):
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
result = await _dispatch("list_downloads", {})
expected_keys = {
@@ -454,6 +460,7 @@ class TestDispatch:
"dlspeed",
"upspeed",
"eta",
"downloader",
}
assert set(result[0].keys()) == expected_keys
@@ -516,7 +523,7 @@ class TestDispatch:
mock_engine.refresh_rss = AsyncMock(return_value=None)
with (
patch("module.mcp.tools.DownloadClient", return_value=mock_client),
patch("module.mcp.tools.DownloaderPool", return_value=mock_client),
patch("module.mcp.tools.RSSEngine", return_value=mock_engine),
):
result = await _dispatch("refresh_feeds", {})

View File

@@ -705,8 +705,10 @@ class TestRunMigrations:
assert prefix == "legacy_cccccccc"
@pytest.mark.parametrize("preexisting", [None, "torrent"])
def test_v26_downloader_id_existing_rows_default(self, preexisting, monkeypatch):
"""v26:存量番剧 / 电影 / 订阅 / 种子都归属默认下载器实例;
def test_v26_downloader_id_existing_rows_follow_default(
self, preexisting, monkeypatch
):
"""v26:存量种子归属实例 default;番剧 / 电影 / 订阅为空(跟随默认实例)。
已有该列的表(如被 create_all 提前建出)跳过,其它表照常补列。"""
engine = _make_v0_engine()
with engine.begin() as conn:
@@ -728,12 +730,13 @@ class TestRunMigrations:
run_migrations(engine)
expected = {"bangumi": None, "movie": None, "rssitem": None}
with engine.connect() as conn:
for table in ("bangumi", "movie", "rssitem", "torrent"):
values = conn.execute(
text(f"SELECT downloader_id FROM {table}")
).scalars()
assert list(values) == ["default"], table
assert list(values) == [expected.get(table, "default")], table
def test_is_idempotent(self):
engine = _make_v0_engine()

View File

@@ -0,0 +1,330 @@
"""多下载器实例(P2.5):按实例缓存客户端、按规则 / 订阅路由新种子、
重命名逐实例运行、不可用实例跳过并通知一次。两个实例都用 mock 下载器。"""
from unittest.mock import AsyncMock, patch
import pytest
from ab_sdk.events import DownloaderUnavailableEvent, TorrentOrganized
from module.conf import settings
from module.database import Database
from module.downloader import (
DownloadClient,
DownloaderPool,
list_torrents,
resolve_downloader_id,
)
from module.downloader.client.mock_downloader import MockDownloader
from module.manager import renamer as renamer_module
from module.manager.renamer import Renamer, rename_all
from module.models import Torrent
from module.models.config import PluginInstance, Plugins, Slots
from module.rss.engine import RSSEngine
from test.factories import make_bangumi, make_rss_item
def _instance(instance_id: str, path: str) -> PluginInstance:
return PluginInstance(
id=instance_id,
point="downloader",
provider="mock",
options={"host": f"{instance_id}:8080", "path": path},
)
@pytest.fixture
def two_instances(monkeypatch):
"""实例 a(默认)与 b,各自的下载目录不同。"""
plugins = Plugins(
instances=[_instance("a", "/a/Bangumi"), _instance("b", "/b/Bangumi")],
slots=Slots(downloader="a"),
)
monkeypatch.setattr(settings, "plugins", plugins)
monkeypatch.setattr(renamer_module, "_unavailable", set())
return plugins
def _mock(instance_id: str) -> MockDownloader:
client = DownloadClient(instance_id).client
assert isinstance(client, MockDownloader)
return client
def _completed(name: str, torrent_hash: str, save_path: str) -> dict:
return {
"hash": torrent_hash,
"name": name,
"save_path": save_path,
"category": "Bangumi",
"state": "completed",
"progress": 1.0,
"files": [{"name": f"{name}.mkv", "size": 100}],
"tags": "",
}
class TestClientCache:
def test_download_client_two_instances_separate_cached_clients(self, two_instances):
a, b = _mock("a"), _mock("b")
assert a is not b
assert _mock("a") is a
assert DownloadClient().instance_id == "a"
async def test_download_client_credentials_rejected_latch_is_per_instance(
self, two_instances
):
b = _mock("b")
b.auth = AsyncMock(return_value=False) # type: ignore[method-assign]
b.last_auth_error = "credentials" # type: ignore[attr-defined]
with pytest.raises(ConnectionError):
async with DownloadClient("b"):
pass
with pytest.raises(ConnectionError):
async with DownloadClient("b"):
pass
assert b.auth.await_count == 1 # 闩锁命中,不再登录
async with DownloadClient("a") as client:
assert client.authed
@pytest.mark.parametrize(
"candidates, expected",
[
((None, None), "a"),
(("", None), "a"),
(("b", None), "b"),
((None, "b"), "b"),
(("a", "b"), "a"),
(("gone", "b"), "b"),
(("gone",), "a"),
],
)
def test_resolve_downloader_id_candidates_first_known_else_default(
two_instances, candidates, expected
):
assert resolve_downloader_id(*candidates) == expected
class TestRssRouting:
@pytest.mark.parametrize(
"bangumi_instance, rss_instance, expected",
[("b", None, "b"), (None, "b", "b"), (None, None, "a"), ("a", "b", "a")],
)
async def test_refresh_rss_adds_to_rule_then_feed_then_default_instance(
self, two_instances, db_engine, bangumi_instance, rss_instance, expected
):
engine = RSSEngine(Database(engine=db_engine))
await engine.db.rss.add(make_rss_item(enabled=True, downloader_id=rss_instance))
await engine.db.bangumi.add(
make_bangumi(
title_raw="Mushoku Tensei",
filter="",
save_path=None,
downloader_id=bangumi_instance,
)
)
torrent = Torrent(
name="[Sub] Mushoku Tensei - 12 [1080p].mkv",
url="magnet:?xt=urn:btih:12",
)
with patch.object(
RSSEngine, "_get_torrents", AsyncMock(return_value=[torrent])
):
async with DownloaderPool() as pool:
await engine.refresh_rss(pool)
rows = await engine.db.torrent.search_all()
assert [(r.downloaded, r.downloader_id) for r in rows] == [(True, expected)]
other = "a" if expected == "b" else "b"
assert len(_mock(expected)._torrents) == 1
assert _mock(other)._torrents == {}
# 新规则的保存目录取目标实例的下载目录
(added,) = _mock(expected)._torrents.values()
assert added["save_path"].startswith(f"/{expected}/Bangumi/")
async def test_refresh_rss_unavailable_instance_torrent_not_persisted(
self, two_instances, db_engine
):
engine = RSSEngine(Database(engine=db_engine))
await engine.db.rss.add(make_rss_item(enabled=True))
await engine.db.bangumi.add(
make_bangumi(title_raw="Mushoku Tensei", filter="", downloader_id="b")
)
_mock("b").auth = AsyncMock(return_value=False) # type: ignore[method-assign]
torrent = Torrent(
name="[Sub] Mushoku Tensei - 12 [1080p].mkv",
url="magnet:?xt=urn:btih:12",
)
with patch.object(
RSSEngine, "_get_torrents", AsyncMock(return_value=[torrent])
):
async with DownloaderPool() as pool:
events = await engine.refresh_rss(pool)
assert events == []
assert await engine.db.torrent.search_all() == [] # 下一轮重试
class TestRenameAll:
async def test_rename_all_events_carry_instance_of_torrent(self, two_instances):
_mock("b")._torrents["hb"] = _completed(
"[Sub] Show - 01 [1080p]", "hb", "/b/Bangumi/Show/Season 1"
)
published: list[object] = []
with patch.object(renamer_module.plugin_host, "publish", published.append):
renamed, events = await rename_all()
organized = [e for e in published if isinstance(e, TorrentOrganized)]
assert [(e.torrent_hash, e.downloader_id) for e in organized] == [("hb", "b")]
assert events == []
assert [n.official_title for n in renamed] == ["Show"]
async def test_rename_all_unavailable_instance_skipped_and_notified_once(
self, two_instances
):
_mock("a")._torrents["ha"] = _completed(
"[Sub] Show - 01 [1080p]", "ha", "/a/Bangumi/Show/Season 1"
)
_mock("b").auth = AsyncMock(return_value=False) # type: ignore[method-assign]
first_renamed, first_events = await rename_all()
_, second_events = await rename_all()
assert [n.official_title for n in first_renamed] == ["Show"]
assert first_events == [
DownloaderUnavailableEvent(
host="b:8080", reason="unreachable", instance_id="b"
)
]
assert second_events == []
async def test_rename_all_recovered_instance_notified_again_on_next_outage(
self, two_instances
):
counts = []
for reachable in (False, True, False):
# 登录失败后缓存的客户端被丢弃,每轮给新取到的客户端打桩
_mock("b").auth = AsyncMock(return_value=reachable) # type: ignore[method-assign]
counts.append(len((await rename_all())[1]))
assert counts == [1, 0, 1]
async def test_rename_replacement_of_other_instance_not_recovered(
self, two_instances
):
"""实例 a 的版本替换事务不能被实例 b 的重命名当成「新种子已消失」去恢复。"""
from module.models.rename_operation import RenameOperation
async with DownloadClient("a") as client_a:
a_type = Renamer(client_a)._downloader_type()
async with Database() as db:
db.add(
RenameOperation(
kind="replacement",
state="planned",
downloader_type=a_type,
new_task_id="only-on-a",
save_path="/a/Bangumi/Show/Season 1",
target_path="Show S01E01.mkv",
source_path="x.mkv",
)
)
await db.commit()
with patch.object(
Renamer, "_recover_missing_replacement", AsyncMock()
) as recover:
async with DownloadClient("b") as client_b:
await Renamer(client_b).rename()
recover.assert_not_awaited()
async def test_list_torrents_one_instance_down_lists_the_other(two_instances):
_mock("a")._torrents["ha"] = _completed("A", "ha", "/a/Bangumi/A/Season 1")
_mock("b").auth = AsyncMock(return_value=False) # type: ignore[method-assign]
listed = await list_torrents()
assert [(t["hash"], t["downloader_id"]) for t in listed or []] == [("ha", "a")]
class TestRuleOnAnotherInstance:
"""规则换到另一个实例后,旧种子留在原实例;删除规则时两边的种子都删。"""
async def _rule_with_torrent_on_a(self) -> int:
_mock("a")._torrents["old"] = _completed(
"Old", "old", "/a/Bangumi/Test Anime (2024)/Season 1"
)
async with Database() as db:
await db.bangumi.add(
make_bangumi(save_path="/a/Bangumi/Test Anime (2024)/Season 1")
)
await db.torrent.add(
Torrent(name="Old", url="u-old", bangumi_id=1, downloader_id="a")
)
return 1
async def test_update_rule_new_instance_keeps_old_torrents_in_place(
self, two_instances
):
from module.manager import TorrentManager
from module.models import BangumiUpdate
bangumi_id = await self._rule_with_torrent_on_a()
async with Database() as db:
old = await db.bangumi.search_id(bangumi_id)
assert old is not None
update = BangumiUpdate(**old.model_dump(exclude={"id"}))
update.downloader_id = "b"
await TorrentManager(db).update_rule(bangumi_id, update)
new = await db.bangumi.search_id(bangumi_id)
assert new is not None and new.downloader_id == "b"
assert new.save_path == "/b/Bangumi/Test Anime (2024)/Season 1"
moved = _mock("a")._torrents["old"]["save_path"]
assert moved == "/a/Bangumi/Test Anime (2024)/Season 1"
async def test_delete_rule_deletes_torrents_on_every_instance(self, two_instances):
from module.manager import TorrentManager
bangumi_id = await self._rule_with_torrent_on_a()
_mock("b")._torrents["new"] = _completed(
"New", "new", "/a/Bangumi/Test Anime (2024)/Season 1"
)
async with Database() as db:
bangumi = await db.bangumi.search_id(bangumi_id)
assert bangumi is not None
bangumi.downloader_id = "b"
await db.bangumi.update(bangumi)
resp = await TorrentManager(db).delete_rule(bangumi_id, file=True)
assert resp.status
assert _mock("a")._torrents == {}
assert _mock("b")._torrents == {}
class TestDownloaderApi:
def test_pause_routes_to_given_instance(self, two_instances, authed_client):
_mock("b")._torrents["hb"] = _completed("B", "hb", "/b/Bangumi/B")
resp = authed_client.post(
"/api/v1/downloader/torrents/pause",
json={"hashes": ["hb"], "downloader_id": "b"},
)
assert resp.status_code == 200
assert _mock("b")._torrents["hb"]["state"] == "paused"
def test_pause_unknown_instance_404(self, two_instances, authed_client):
resp = authed_client.post(
"/api/v1/downloader/torrents/pause",
json={"hashes": ["hb"], "downloader_id": "gone"},
)
assert resp.status_code == 404
def test_instances_lists_downloaders_and_default(
self, two_instances, authed_client
):
resp = authed_client.get("/api/v1/downloader/instances")
assert resp.json() == {
"default": "a",
"instances": [
{"id": "a", "provider": "mock"},
{"id": "b", "provider": "mock"},
],
}

View File

@@ -561,9 +561,13 @@ class TestEventInboxContract:
assert event.kind == "downloader_unavailable"
assert event.severity == "error"
assert event.once is False
# reason 不进 dedup_key:unreachable→credentials 的翻转合并为一条
assert event.dedup_key() == "downloader:http://qb:8080"
assert event.payload() == {"host": "http://qb:8080", "reason": "credentials"}
# 按实例去重,reason 不进 dedup_key:unreachable→credentials 的翻转合并为一条
assert event.dedup_key() == "downloader:default"
assert event.payload() == {
"host": "http://qb:8080",
"reason": "credentials",
"instance": "default",
}
title, body = event.describe()
assert "下载器" in title
assert "密码" in body

View File

@@ -79,6 +79,7 @@ def add_strategy(registry, provider_id: str, target_name, plugin_id: str = "ext"
@pytest.fixture
def renamer(mock_qb_client):
with patch("module.downloader.download_client.settings") as mock_settings:
mock_settings.downloader.id = "default"
mock_settings.downloader.type = "qbittorrent"
mock_settings.downloader.host = "localhost:8080"
mock_settings.downloader.path = "/downloads/Bangumi"

View File

@@ -31,7 +31,7 @@ from module.plugin.registry import HookEntry, ProviderEntry
from module.plugin.runner import CircuitBreaker, HookRunner
from module.rss.analyser import RSSAnalyser
from module.rss.engine import RSSEngine
from test.factories import make_bangumi, make_rss_item, make_torrent
from test.factories import SingleClientPool, make_bangumi, make_rss_item, make_torrent
TORRENT_NAME = "[Sub] Mushoku Tensei - 12 [1080p].mkv"
@@ -69,7 +69,7 @@ async def refresh_with(engine: RSSEngine, torrents: list[Torrent]):
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as get:
get.return_value = torrents
await engine.refresh_rss(client)
await engine.refresh_rss(SingleClientPool(client))
return client

View File

@@ -14,7 +14,7 @@ from module.notification.events import DownloadFailureEvent, RssFailureEvent
from module.parser.analyser.selector import parse_configured_release_title
from module.parser.analyser.tokenizer import ReleaseKind
from module.rss.engine import RSSEngine
from test.factories import make_bangumi, make_rss_item, make_torrent
from test.factories import SingleClientPool, make_bangumi, make_rss_item, make_torrent
@pytest_asyncio.fixture
@@ -281,7 +281,7 @@ class TestRefreshRss:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
# Verify download was attempted
client.add_torrent.assert_called_once()
@@ -305,7 +305,7 @@ class TestRefreshRss:
) as mock_get:
mock_get.return_value = [unmatched]
client = AsyncMock()
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
client.add_torrent.assert_not_called()
all_torrents = await rss_engine.db.torrent.search_all()
@@ -341,7 +341,7 @@ class TestRefreshRss:
mock_get.return_value = [matched, unmatched]
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
all_torrents = await rss_engine.db.torrent.search_all()
assert len(all_torrents) == 1
@@ -372,7 +372,7 @@ class TestRefreshRss:
mock_get.return_value = [matched, unmatched]
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
all_torrents = await rss_engine.db.torrent.search_all()
assert len(all_torrents) == 2
@@ -390,7 +390,7 @@ class TestRefreshRss:
) as mock_get:
mock_get.return_value = []
client = AsyncMock()
await rss_engine.refresh_rss(client, rss_id=2)
await rss_engine.refresh_rss(SingleClientPool(client), rss_id=2)
# Only called once (for rss_id=2)
mock_get.assert_called_once()
@@ -401,7 +401,7 @@ class TestRefreshRss:
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
client = AsyncMock()
await rss_engine.refresh_rss(client, rss_id=999)
await rss_engine.refresh_rss(SingleClientPool(client), rss_id=999)
mock_get.assert_not_called()
@@ -426,7 +426,7 @@ class TestRefreshRssEvents:
) as mock_pull:
mock_pull.return_value = ([], "connection refused")
client = AsyncMock()
events = await rss_engine.refresh_rss(client)
events = await rss_engine.refresh_rss(SingleClientPool(client))
assert len(events) == 1
assert isinstance(events[0], RssFailureEvent)
@@ -443,8 +443,8 @@ class TestRefreshRssEvents:
) as mock_pull:
mock_pull.return_value = ([], "connection refused")
client = AsyncMock()
first_events = await rss_engine.refresh_rss(client)
second_events = await rss_engine.refresh_rss(client)
first_events = await rss_engine.refresh_rss(SingleClientPool(client))
second_events = await rss_engine.refresh_rss(SingleClientPool(client))
assert len(first_events) == 1
assert second_events == []
@@ -459,7 +459,7 @@ class TestRefreshRssEvents:
) as mock_pull:
mock_pull.return_value = ([], None)
client = AsyncMock()
events = await rss_engine.refresh_rss(client)
events = await rss_engine.refresh_rss(SingleClientPool(client))
assert events == []
@@ -481,7 +481,7 @@ class TestRefreshRssEvents:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.FAILED)
events = await rss_engine.refresh_rss(client)
events = await rss_engine.refresh_rss(SingleClientPool(client))
assert len(events) == 1
assert isinstance(events[0], DownloadFailureEvent)
@@ -512,7 +512,7 @@ class TestRefreshRssEvents:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
events = await rss_engine.refresh_rss(client)
events = await rss_engine.refresh_rss(SingleClientPool(client))
assert events == []
@@ -537,7 +537,7 @@ class TestRefreshRssEvents:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.DUPLICATE)
events = await rss_engine.refresh_rss(client)
events = await rss_engine.refresh_rss(SingleClientPool(client))
assert events == []
all_torrents = await rss_engine.db.torrent.search_all()
@@ -573,14 +573,14 @@ class TestRefreshRssRetry:
# Tick 1: transient failure
mock_get.return_value = [feed_torrent()]
client.add_torrent = AsyncMock(return_value=AddResult.FAILED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
client.add_torrent.assert_called_once()
assert await rss_engine.db.torrent.search_all() == []
# Tick 2: same feed item is still there, add now succeeds
mock_get.return_value = [feed_torrent()]
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
client.add_torrent.assert_called_once()
all_torrents = await rss_engine.db.torrent.search_all()
@@ -610,9 +610,9 @@ class TestRefreshRssRetry:
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
mock_get.return_value = [feed_torrent()]
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
mock_get.return_value = [feed_torrent()]
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
client.add_torrent.assert_not_called()
all_torrents = await rss_engine.db.torrent.search_all()
@@ -811,7 +811,7 @@ class TestRefreshRssConcurrency:
rss_engine, "_pull_rss_with_status", side_effect=track_concurrency
):
client = AsyncMock()
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
assert max_active <= 5
@@ -855,7 +855,7 @@ class TestRefreshRssPerHostThrottle:
),
patch("module.rss.engine.RSS_PER_HOST_DELAY", 0),
):
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
assert max_active["nyaa.example"] == 1
assert max_active["mikan.example"] == 1
@@ -878,7 +878,7 @@ class TestRefreshRssPerHostThrottle:
),
patch("module.rss.engine.RSS_PER_HOST_DELAY", 0),
):
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
for rss_id in (1, 2):
item = await rss_engine.db.rss.search_id(rss_id)
@@ -1334,7 +1334,7 @@ class TestRefreshRssPreferenceDedup:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
parsed_after_refresh = parse_configured_release_title(torrent.name)
assert parsed_after_refresh is not None
@@ -1365,7 +1365,7 @@ class TestRefreshRssPreferenceDedup:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
client.add_torrent.assert_called_once()
called_torrent = client.add_torrent.call_args[0][0]
@@ -1400,6 +1400,6 @@ class TestRefreshRssPreferenceDedup:
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await rss_engine.refresh_rss(client)
await rss_engine.refresh_rss(SingleClientPool(client))
assert client.add_torrent.call_count == 2