diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc3a357..f8cd4a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - **新增(媒体库刷新)**:新增内置插件「媒体库刷新」(`media-server-refresh`):配置 Jellyfin / Emby / Plex 地址与 API Key 后,合并一段时间内整理完成的种子,请求刷新媒体库;未配置时不做任何事 - **重构(整理流水线)**:`manager/renamer.py` 拆为编排(`renamer.py`)与 revision 替换事务(`revision_saga.py`);删除仅测试使用的 `Renamer.rename_file` / `_lookup_offsets`、无调用方的 `release_replacement_lease`、`match_by_save_path`、`search_by_qb_hash`,以及在重命名链路中传递却从未使用的 `season_offset` 参数 - **修复(插件配置)**:插件配置模型的校验器(`field_validator`)报错时,保存接口返回 500 而不是 422(错误详情中的异常对象无法序列化) +- **破坏性变更(配置,多下载器)**:下载器改为 `plugins.instances` 中的实例,`plugins.slots.downloader` 指向默认实例;重命名方式与版本冲突策略移到 `plugins.slots.rename_strategy` / `conflict_policy`(另有 `media_files`),插件提供的实现也可以通过 slots 选用。升级后第一次启动时,3.3 的 `downloader`、`bangumi_manage.rename_method`、`bangumi_manage.revision_conflict_policy` 自动迁移,原文件备份为 `config.json.v3.bak`(已有备份时不覆盖,另存为 `config.json.v3.bak.1` 等);迁移失败时从备份恢复配置并拒绝启动,日志写明出错的字段。`AB_DOWNLOADER_*`、`AB_DOWNLOAD_PATH`、`AB_METHOD`、`AB_REVISION_CONFLICT_POLICY` 环境变量照常生效。番剧、电影、RSS 订阅与种子新增 `downloader_id` 列(数据库迁移 v26):存量种子归属 `default`,规则与订阅为空(跟随默认实例)。`ab_sdk` 升至 0.5.0:`ConflictRequest` 删除 `configured`,宿主的冲突策略改为 `hold` / `replace` 两个 Provider +- **新增(多下载器)**:设置 → 下载器设置 可以添加多个下载器实例(qBittorrent、aria2 或插件提供的下载器)并指定默认实例。规则与 RSS 订阅可以选择下载器,留空时用默认实例;由订阅新建的规则继承订阅的选择。每个种子记录它所在的下载器,重命名、删除都在该下载器上进行,规则改用另一个下载器后已有种子留在原处,新种子保存到该下载器的下载目录(默认下载器切换或下载器被删除时也是如此)。重命名逐个下载器运行,连不上的下载器被跳过,并在第一次连不上时通知(`DownloaderUnavailableEvent` 新增 `instance_id`,按实例去重)。下载器页与种子列表标出种子所在的下载器;`GET /api/v1/downloader/torrents` 汇总所有实例,暂停 / 恢复 / 删除 / 打标接口新增 `downloader_id`,新增 `GET /api/v1/downloader/instances` - **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移 - **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST - **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none` diff --git a/backend/src/ab_sdk/__init__.py b/backend/src/ab_sdk/__init__.py index e1dc0171..9dca6562 100644 --- a/backend/src/ab_sdk/__init__.py +++ b/backend/src/ab_sdk/__init__.py @@ -12,7 +12,7 @@ from .events import Event, PluginDisabled, PluginLoaded from .hooks import Verdict, hook, provider, subscribe from .plugin import Plugin -SDK_VERSION = "0.4.0" +SDK_VERSION = "0.5.0" __all__ = [ "SDK_VERSION", diff --git a/backend/src/ab_sdk/events.py b/backend/src/ab_sdk/events.py index 2faeb769..f16f6672 100644 --- a/backend/src/ab_sdk/events.py +++ b/backend/src/ab_sdk/events.py @@ -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) diff --git a/backend/src/ab_sdk/points.py b/backend/src/ab_sdk/points.py index 805746e1..2075b115 100644 --- a/backend/src/ab_sdk/points.py +++ b/backend/src/ab_sdk/points.py @@ -60,7 +60,7 @@ MESSAGE_TEMPLATE = "message_template" # Provider 扩展点: # # - ``RENAME_STRATEGY``:返回 :class:`ab_sdk.rename.RenameStrategy`,id 即 -# ``bangumi_manage.rename_method`` 的取值。宿主自带 ``none``(不改名), +# ``plugins.slots.rename_strategy`` 的取值。宿主自带 ``none``(不改名), # 内置插件 ``rename`` 提供 ``pn``、``advance``、``template`` # - ``MEDIA_FILES``:返回 :class:`ab_sdk.rename.MediaFiles` # - ``CONFLICT_POLICY``:返回 :class:`ab_sdk.rename.ConflictPolicy` diff --git a/backend/src/ab_sdk/rename.py b/backend/src/ab_sdk/rename.py index c842dfa9..c3558bf8 100644 --- a/backend/src/ab_sdk/rename.py +++ b/backend/src/ab_sdk/rename.py @@ -2,14 +2,14 @@ 扩展点一览(常量见 :mod:`ab_sdk.points`): -- ``rename_strategy``(Provider):返回 :class:`RenameStrategy`,id 即设置项 - ``bangumi_manage.rename_method`` 的取值 +- ``rename_strategy``(Provider):返回 :class:`RenameStrategy` - ``media_files``(Provider):返回 :class:`MediaFiles`,判断种子内文件的类别 - ``conflict_policy``(Provider):返回 :class:`ConflictPolicy`,目标路径已被 另一个种子占用时决定保留还是替换 -``media_files`` 与 ``conflict_policy`` 目前只使用宿主自带的实现(id -:data:`CORE_ID`),插件实现的选择随 P2.5 的 ``plugins.slots`` 一起提供。 +三者都按 ``plugins.slots`` 中同名的键选择 Provider id。宿主自带 ``none`` +(重命名)、:data:`CORE_ID`(文件分类)与 ``hold`` / ``replace``(冲突策略); +选中的 id 未登记(插件停用或被熔断)时退回宿主实现。 整理完成后宿主发布 :class:`ab_sdk.events.FileRenamed` 与 :class:`ab_sdk.events.TorrentOrganized`,插件用 ``@subscribe`` 接收。 @@ -22,7 +22,7 @@ FileKind = Literal["media", "subtitle"] MediaKind = Literal["media", "subtitle", "ignore"] ConflictAction = Literal["hold", "replace"] -# media_files / conflict_policy 的宿主实现 id +# media_files 的宿主实现 id CORE_ID = "default" @@ -114,8 +114,6 @@ class ConflictRequest: target_path: str incoming: RevisionTask owners: tuple[RevisionTask, ...] - # 用户在设置中选择的版本冲突策略(bangumi_manage.revision_conflict_policy) - configured: ConflictAction # 唯一占用者与新种子是同一发布、且新种子版本号更高 strict_upgrade: bool diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index c2138249..0312f33b 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -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. diff --git a/backend/src/module/api/downloader.py b/backend/src/module/api/downloader.py index 040b9936..d6169338 100644 --- a/backend/src/module/api/downloader.py +++ b/backend/src/module/api/downloader.py @@ -4,13 +4,20 @@ 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, + DownloaderPool, + 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 +27,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 +40,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 +112,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 +120,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 +128,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 +157,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 +183,51 @@ 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 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: link tag。必须精确匹配数字 id: - # ab:renamed(处理完成标记)等同前缀标签不代表已关联番剧 - if re.search(r"ab:\d+", tags): + async with DownloaderPool() as downloaders: + for instance_id in downloader_ids(): + try: + client = await downloaders.get(instance_id) + except ConnectionError as e: + # 不可用的实例跳过,其它实例照常打标签 + logger.warning("Skip auto-tag on %s: %s", instance_id, e) continue + # Get all Bangumi torrents + torrents = await client.get_torrent_info( + category="Bangumi", status_filter=None + ) - # 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)) + for torrent in torrents: + torrent_hash = torrent["hash"] + torrent_name = torrent["name"] + save_path = torrent["save_path"] + tags = torrent.get("tags", "") - 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, - } - ) + # Skip if already has an ab: 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)) + + 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, diff --git a/backend/src/module/api/events.py b/backend/src/module/api/events.py index 4d84aa25..2d3070e4 100644 --- a/backend/src/module/api/events.py +++ b/backend/src/module/api/events.py @@ -22,11 +22,11 @@ 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 -from module.update import get_update_progress +from module.update.updater import get_update_progress logger = logging.getLogger(__name__) @@ -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: diff --git a/backend/src/module/api/rss.py b/backend/src/module/api/rss.py index 5152b466..5e6ec37b 100644 --- a/backend/src/module/api/rss.py +++ b/backend/src/module/api/rss.py @@ -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 @@ -30,7 +30,9 @@ async def get_rss(db: Database = Depends(get_db)): ) async def add_rss(rss: RSSItem, db: Database = Depends(get_db)): engine = RSSEngine(db) - result = await engine.add_rss(rss.url, rss.name, rss.aggregate, rss.parser) + result = await engine.add_rss( + rss.url, rss.name, rss.aggregate, rss.parser, rss.downloader_id + ) return u_response(result) @@ -140,9 +142,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 +160,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 +202,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) diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py index 0d771b48..0de376bd 100644 --- a/backend/src/module/api/setup.py +++ b/backend/src/module/api/setup.py @@ -405,8 +405,13 @@ async def complete_setup( # 2. Update configuration config_dict = settings.dict() - config_dict["downloader"] = { - "type": req.downloader_type, + # 向导填写的下载器写入默认实例(slots.downloader 指向的实例) + plugins = config_dict["plugins"] + default = next( + i for i in plugins["instances"] if i["id"] == plugins["slots"]["downloader"] + ) + default["provider"] = req.downloader_type + default["options"] = { "host": req.downloader_host, "username": req.downloader_username, "password": req.downloader_password, diff --git a/backend/src/module/api/update.py b/backend/src/module/api/update.py index b5d12bf3..f7dd0f50 100644 --- a/backend/src/module/api/update.py +++ b/backend/src/module/api/update.py @@ -21,7 +21,7 @@ from module.conf import settings from module.core import AppContext from module.notification import UpdateAppliedEvent from module.security.api import get_current_user -from module.update import updater +from module.update.updater import updater from .deps import get_context diff --git a/backend/src/module/checker/checker.py b/backend/src/module/checker/checker.py index 888cb7db..ec043faa 100644 --- a/backend/src/module/checker/checker.py +++ b/backend/src/module/checker/checker.py @@ -5,7 +5,7 @@ import semver from module.conf import VERSION, settings from module.models import Config -from module.update import version_check +from module.update.version_check import version_check logger = logging.getLogger(__name__) diff --git a/backend/src/module/conf/config.py b/backend/src/module/conf/config.py index 5856b48b..0e0fd644 100644 --- a/backend/src/module/conf/config.py +++ b/backend/src/module/conf/config.py @@ -6,6 +6,7 @@ from pathlib import Path from dotenv import load_dotenv from module.models.config import Config +from module.update.v4 import migrate_v3_config, migrate_v3_dict from .const import ENV_TO_ATTR @@ -68,9 +69,9 @@ CONFIG_PATH = ( class Settings(Config): """Runtime configuration singleton. - On construction, loads from ``CONFIG_PATH`` if the file exists (and - immediately re-saves to apply any migrations), otherwise bootstraps - defaults from environment variables via ``init()``. + On construction, migrates a 3.3 ``CONFIG_PATH`` to 4.0 (see + ``module.update.v4``), loads it and immediately re-saves it; otherwise + bootstraps defaults from environment variables via ``init()``. Use ``settings`` module-level instance rather than instantiating directly. """ @@ -78,6 +79,8 @@ class Settings(Config): def __init__(self): super().__init__() if CONFIG_PATH.exists(): + # 必须先于 load / save:否则 3.3 的下载器等字段会被当作未知字段丢弃 + migrate_v3_config(CONFIG_PATH) self.load() self.save() else: @@ -95,12 +98,7 @@ class Settings(Config): @staticmethod def _migrate_old_config(config: dict) -> dict: - """把 3.3.x 的配置改写为当前格式(更早版本的迁移已随 4.0 移除)。""" - bangumi_manage = config.get("bangumi_manage", {}) - # "normal" 早已是无操作的废弃方法,语义等同 "none" - if bangumi_manage.get("rename_method") == "normal": - bangumi_manage["rename_method"] = "none" - + """清洗字面写入的掩码哨兵(3.3 → 4.0 的字段迁移见 ``module.update.v4``)。""" _scrub_corrupted_masks(config) return config @@ -125,15 +123,17 @@ class Settings(Config): for key, section in ENV_TO_ATTR.items(): for env, attr in section.items(): if env in os.environ: + section = config_dict.setdefault(key, {}) if isinstance(attr, list): for _attr in attr: attr_name = _attr[0] if isinstance(_attr, tuple) else _attr - config_dict[key][attr_name] = self.__val_from_env( - env, _attr - ) + section[attr_name] = self.__val_from_env(env, _attr) else: attr_name = attr[0] if isinstance(attr, tuple) else attr - config_dict[key][attr_name] = self.__val_from_env(env, attr) + section[attr_name] = self.__val_from_env(env, attr) + # ENV_TO_ATTR 按 3.3 的位置写入(AB_DOWNLOADER_* → downloader、 + # AB_METHOD → bangumi_manage),再由迁移器移到默认下载器实例与 slots + migrate_v3_dict(config_dict) config_obj = Config.model_validate(config_dict) self.__dict__.update(config_obj.__dict__) logger.debug("Config loaded from env") diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py index dbc2f360..3da95aaa 100644 --- a/backend/src/module/core/context.py +++ b/backend/src/module/core/context.py @@ -23,12 +23,8 @@ from module.plugin import PluginManager from module.plugin.host import get_registry, set_bus, set_runner from module.rss import RSSAnalyser from module.searcher.searcher import reset_cache as reset_poster_cache -from module.update import ( - cache_image, - first_run, - migrate_legacy_auth_tokens, - run_migrations, -) +from module.update.auth import migrate_legacy_auth_tokens +from module.update.startup import cache_image, first_run, run_migrations from .loops import ( calendar_tick, @@ -275,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: diff --git a/backend/src/module/core/loops.py b/backend/src/module/core/loops.py index 87062c02..da405e96 100644 --- a/backend/src/module/core/loops.py +++ b/backend/src/module/core/loops.py @@ -9,11 +9,12 @@ 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 import updater +from module.update.updater import updater from .offset_scanner import OffsetScanner @@ -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: diff --git a/backend/src/module/database/migrations.py b/backend/src/module/database/migrations.py index 322c3db8..cb5d0878 100644 --- a/backend/src/module/database/migrations.py +++ b/backend/src/module/database/migrations.py @@ -100,6 +100,26 @@ class Migration: return self.statements +# 规则与订阅的 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: + # 表不存在时由 create_all 按模型建表,自带该列 + def check(inspector) -> bool: + return table not in inspector.get_table_names() or column_exists( + table, "downloader_id" + )(inspector) + + return check + + # 迁移按版本顺序执行;版本号与 3.2.x 的历史保持一致,旧数据库照常升级。 MIGRATIONS: tuple[Migration, ...] = ( Migration( @@ -799,6 +819,19 @@ MIGRATIONS: tuple[Migration, ...] = ( ), table_exists("plugin_kv"), ), + Migration( + 26, + "add downloader_id to bangumi, movie, rssitem and torrent", + tuple( + 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_COLUMNS)), + tuple( + (f"ALTER TABLE {table} ADD COLUMN {column}", _has_downloader_id(table)) + for table, column in _DOWNLOADER_ID_COLUMNS.items() + ), + ), ) # 由迁移列表派生,新增迁移时无需手动同步 diff --git a/backend/src/module/database/rename_operation.py b/backend/src/module/database/rename_operation.py index 625d9a8b..f44e2311 100644 --- a/backend/src/module/database/rename_operation.py +++ b/backend/src/module/database/rename_operation.py @@ -356,17 +356,23 @@ class RenameOperationDatabase: ) return list(result.scalars().all()) - async def list_active_replacements(self, limit: int = 100) -> list[RenameOperation]: - result = await self.session.execute( - select(RenameOperation) - .where( - RenameOperation.kind == "replacement", - col(RenameOperation.state).in_( - ("planned", "old_staged", "new_promoted", "old_removed") - ), + async def list_active_replacements( + self, downloader_type: str | None, limit: int = 100 + ) -> list[RenameOperation]: + """某个下载器实例(``downloader_type``)上未完成的版本替换事务; + ``None`` 时不限实例。""" + statement = select(RenameOperation).where( + RenameOperation.kind == "replacement", + col(RenameOperation.state).in_( + ("planned", "old_staged", "new_promoted", "old_removed") + ), + ) + if downloader_type is not None: + statement = statement.where( + RenameOperation.downloader_type == downloader_type ) - .order_by(col(RenameOperation.updated_at).asc()) - .limit(limit) + result = await self.session.execute( + statement.order_by(col(RenameOperation.updated_at).asc()).limit(limit) ) return list(result.scalars().all()) diff --git a/backend/src/module/downloader/__init__.py b/backend/src/module/downloader/__init__.py index abf2f5bb..1fdb1903 100644 --- a/backend/src/module/downloader/__init__.py +++ b/backend/src/module/downloader/__init__.py @@ -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", ] diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 63a878bd..76388acb 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -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 @@ -18,7 +20,7 @@ from .base import ( RenameOutcome, RenameResult, ) -from .path import gen_save_path +from .path import save_path_for logger = logging.getLogger(__name__) @@ -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") @@ -440,8 +463,10 @@ class DownloadClient: 返回 :class:`AddResult`,区分"新增成功 / 已添加过 / 投递失败", 调用方据此决定是否重试或发送失败通知。 """ - if not bangumi.save_path: - bangumi.save_path = gen_save_path(bangumi) + bangumi.save_path = save_path_for(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 +538,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 diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index a3c4b6f9..98b71141 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -54,9 +54,10 @@ class SuffixMediaFiles: def check_files(files: list[dict]): - media_files: MediaFiles = ( - plugin_host.get_registry().providers(points.MEDIA_FILES)[CORE_ID].factory() - ) + # 选中的实现未登记(插件停用或被熔断)时退回宿主实现 + providers = plugin_host.get_registry().providers(points.MEDIA_FILES) + entry = providers.get(settings.plugins.slots.media_files) or providers[CORE_ID] + media_files: MediaFiles = entry.factory() media_list = [] subtitle_list = [] for f in files: @@ -69,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 @@ -106,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. @@ -120,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 当作 @@ -131,10 +139,26 @@ 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) +def save_path_for(data: Bangumi | BangumiUpdate, root: str) -> str: + """番剧在某个下载器实例上的保存目录。 + + 已存的目录在该实例下载目录之下时沿用;否则它是按另一个实例的下载目录 + 生成的(默认实例切换、规则的实例被删除、订阅指向别的实例),按 ``root`` + 重新生成。用 PureWindowsPath 比较,``\\`` 与 ``/`` 分隔的目录都能认出。 + """ + if data.save_path and PureWindowsPath(data.save_path).is_relative_to( + PureWindowsPath(root) + ): + return data.save_path + return gen_save_path(data, root) + + def rule_name(data: Bangumi): name = ( f"[{data.group_name}] {data.official_title} S{data.season}" diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index 79fa14c4..8901eddf 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -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,19 @@ 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) + try: + client = await downloaders.get( + resolve_downloader_id(data.downloader_id) + ) + except ConnectionError as e: + # 实例不可用:跳过这条规则,下一轮重试 + logger.warning( + "Skip collecting %s: %s", data.official_title, e + ) + continue + await SeasonCollector(client).collect_season(data) data.eps_collect = True await db.bangumi.update_all(datas) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index dd746b1f..98908f97 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -7,7 +7,6 @@ from typing import Any from ab_sdk import points from ab_sdk.events import FileRenamed, OrganizedFile, TorrentOrganized from ab_sdk.rename import ( - CORE_ID, ConflictPolicy, ConflictRequest, FileKind, @@ -22,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 @@ -45,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 标签的 @@ -58,7 +66,7 @@ _skip_notified: set[tuple[str, str]] = set() def _rename_strategy(method: str | ProviderEntry) -> ProviderEntry: - """按 rename_method 取重命名策略;未登记(如 rename 插件未启用)时记录一次 + """按 slots.rename_strategy 取重命名策略;未登记(如 rename 插件未启用)时记录一次 日志并按 none 处理。已解析的策略原样返回:一轮重命名只解析一次,轮中插件 被停用也不会让同一种子的文件名与 ab:renamed 标签来自不同策略。""" if isinstance(method, ProviderEntry): @@ -500,7 +508,6 @@ class Renamer(RevisionSaga): ) for o in owners ), - configured=settings.bangumi_manage.revision_conflict_policy, strict_upgrade=bool( owner is not None and incoming_identity is not None @@ -591,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) @@ -626,17 +633,16 @@ class Renamer(RevisionSaga): torrent_hash=torrent_hash, bangumi_id=bangumi_id, files=files, - downloader_id=_DOWNLOADER_ID, + downloader_id=self.client.instance_id, ) ) @staticmethod def _conflict_policy() -> ConflictPolicy: - return ( - plugin_host.get_registry() - .providers(points.CONFLICT_POLICY)[CORE_ID] - .factory() - ) + # 选中的策略未登记(插件停用或被熔断)时按 hold 处理,不会误删旧版本 + policies = plugin_host.get_registry().providers(points.CONFLICT_POLICY) + entry = policies.get(settings.plugins.slots.conflict_policy) or policies["hold"] + return entry.factory() async def _batch_lookup_offsets( self, torrents_info: list[dict] @@ -654,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 } @@ -749,7 +759,7 @@ class Renamer(RevisionSaga): async def rename(self) -> list[Notification]: logger.debug("Start rename process.") - strategy = _rename_strategy(settings.bangumi_manage.rename_method) + strategy = _rename_strategy(settings.plugins.slots.rename_strategy) pending_infos = await self.client.get_torrent_info() # Owner counting and Saga recovery must see tasks outside the normal # Bangumi/completed filter (collections, paused tasks, changed category). @@ -760,8 +770,13 @@ class Renamer(RevisionSaga): for info in pending_infos: info_by_hash.setdefault(info["hash"], info) all_infos = list(info_by_hash.values()) + # 只有一个实例时不按实例过滤:键里含主机哈希,改了主机地址(同一个 + # 下载器)后进行中的事务仍要恢复;多个实例时只恢复本实例的 + instance_key = self._downloader_type() if len(downloader_ids()) > 1 else None async with Database() as db: - active_replacements = await db.rename_operation.list_active_replacements() + active_replacements = await db.rename_operation.list_active_replacements( + instance_key + ) active_replacement_ids = { operation.new_task_id for operation in active_replacements } @@ -811,7 +826,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, @@ -878,3 +895,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 diff --git a/backend/src/module/manager/revision_policy.py b/backend/src/module/manager/revision_policy.py index 3c26be5b..1ba7297b 100644 --- a/backend/src/module/manager/revision_policy.py +++ b/backend/src/module/manager/revision_policy.py @@ -7,7 +7,7 @@ import unicodedata from dataclasses import dataclass from pathlib import PurePosixPath -from ab_sdk.rename import ConflictDecision, ConflictRequest, Revision +from ab_sdk.rename import ConflictAction, ConflictDecision, ConflictRequest, Revision from module.parser.analyser.selector import parse_configured_release_title from module.parser.analyser.tokenizer import MediaType from module.parser.release_policy import preference_identity, preference_revision @@ -108,8 +108,11 @@ def revision_snapshot(identity: RevisionIdentity | None) -> Revision | None: class CoreConflictPolicy: - """宿主自带的 ``conflict_policy``:只在唯一占用者与新种子都是单文件、 - 且新种子是严格的版本升级时,按用户设置决定是否替换。""" + """宿主自带的 ``conflict_policy``(``hold`` / ``replace``):只在唯一占用者 + 与新种子都是单文件、且新种子是严格的版本升级时,按 ``action`` 决定是否替换。""" + + def __init__(self, action: ConflictAction) -> None: + self.action = action def decide(self, request: ConflictRequest) -> ConflictDecision: if len(request.owners) != 1: @@ -128,7 +131,7 @@ class CoreConflictPolicy: "hold", "existing and incoming releases are not a strict revision upgrade", ) - if request.configured == "replace": + if self.action == "replace": return ConflictDecision("replace") return ConflictDecision("hold", "revision conflict policy is hold") diff --git a/backend/src/module/manager/revision_saga.py b/backend/src/module/manager/revision_saga.py index 7634da60..cceb1609 100644 --- a/backend/src/module/manager/revision_saga.py +++ b/backend/src/module/manager/revision_saga.py @@ -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( diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index 7ed9fedf..795a9462 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -3,8 +3,8 @@ 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.path import gen_save_path +from module.downloader import DownloadClient, downloader_ids, resolve_downloader_id +from module.downloader.path import gen_save_path, save_path_for from module.downloader.rules import build_rss_rule from module.models import Bangumi, BangumiUpdate, ResponseModel from module.parser import TitleParser @@ -14,31 +14,68 @@ from module.parser.analyser.tmdb_parser import tmdb_parser logger = logging.getLogger(__name__) +def _save_paths(data: Bangumi | BangumiUpdate, root: str) -> set[str]: + """番剧的种子在某个实例上可能所在的目录:已存的目录(与之前相同), + 以及按该实例下载目录生成的目录(种子投递到了另一个实例)。""" + paths = {data.save_path, save_path_for(data, root)} + return {normalize_save_path(p) for p in paths if p} + + class TorrentManager: def __init__(self, db: Database): 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) + targets = _save_paths(data, client.instance.path) return [ torrent.get("hash", torrent.get("infohash_v1", "")) for torrent in torrents - if normalize_save_path(torrent.get("save_path")) == target_save_path + if normalize_save_path(torrent.get("save_path")) in targets ] - 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): - return ResponseModel( - status_code=500, - status=False, - msg_en=f"Failed to delete torrents for {data.official_title}", - msg_zh=f"删除 {data.official_title} 种子失败", + 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 + failed: list[str] = [] + # 逐实例隔离:一个实例不可用或删除失败,其它实例照常清理 + for instance_id in instance_ids: + try: + 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) + except ConnectionError as e: + logger.warning( + "[Manager] Can't delete torrents on %s: %s", instance_id, e ) + deleted = False + if not deleted: + failed.append(instance_id) + if failed: + return ResponseModel( + status_code=500, + status=False, + msg_en=f"Failed to delete torrents for {data.official_title} " + f"on {', '.join(failed)}", + msg_zh=f"删除 {data.official_title} 种子失败({', '.join(failed)})", + ) + if found: logger.info(f"Delete rule and torrents for {data.official_title}") return ResponseModel( status_code=200, @@ -83,6 +120,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 +131,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 +163,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 +204,48 @@ 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) + # 部分更新(未带 downloader_id)保持规则原来的实例 + new_instance = resolve_downloader_id( + data.downloader_id + if "downloader_id" in data.model_fields_set + else old_data.downloader_id + ) + new_path = gen_save_path( + data, settings.downloader_instance(new_instance).path + ) + # 换了下载器实例:旧种子留在原实例原位置,之后的新种子进新实例。 + # 没换:规则的种子可能经订阅进了别的实例,逐个实例按各自的 + # 下载目录移动 + if new_instance == old_instance: + for instance_id in await self._torrent_instances(old_data): + root = settings.downloader_instance(instance_id).path + moved_path = gen_save_path(data, root) + old_paths = _save_paths(old_data, root) + if old_paths == {normalize_save_path(moved_path)}: + continue + match_list = await self.__match_torrents_list(old_data, instance_id) + async with DownloadClient(instance_id) as client: + # Move existing torrents to new location if path changed + if match_list: + await client.move_torrent(match_list, moved_path) + logger.info( + f"Moved torrents from {old_paths} to {moved_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。 + # 只改有这条规则种子的实例或规则自己的实例:setRule + # 在别的 qB 上会新建一条启用的自动下载规则 + if old_data.rule_name and ( + match_list or instance_id == old_instance + ): + # Recreate the rule with the new save_path + rule = build_rss_rule(data, moved_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) diff --git a/backend/src/module/mcp/tools.py b/backend/src/module/mcp/tools.py index 7ab891ff..551f8e20 100644 --- a/backend/src/module/mcp/tools.py +++ b/backend/src/module/mcp/tools.py @@ -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"} diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index c70096a8..37dc72a7 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -61,6 +61,10 @@ class Bangumi(SQLModel, table=True): episode_type: str = Field( default="episode", alias="episode_type", title="剧集类型" ) # "episode" | "movie" | "special" + # 为空时用默认下载器实例(plugins.slots.downloader) + downloader_id: Optional[str] = Field( + default=None, alias="downloader_id", title="下载器实例" + ) class BangumiUpdate(SQLModel): @@ -84,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( diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index 823fc7c7..2ea89877 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -1,7 +1,10 @@ +from dataclasses import dataclass from os.path import expandvars from typing import Any, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ab_sdk.rename import CORE_ID def _expand(value: str | None) -> str: @@ -17,23 +20,25 @@ class Program(BaseModel): webui_port: int = Field(default=7892, description="WebUI port") -class Downloader(BaseModel): - """Download client connection settings. +class DownloaderOptions(BaseModel): + """下载器实例的连接参数(``plugins.instances`` 中 point 为 downloader 的 options)。 - Credential fields (``host``, ``username``, ``password``) are stored with a - trailing underscore and exposed via properties that expand ``$VAR`` - environment variable references at access time. + ``host`` / ``username`` / ``password`` 以带下划线的字段存储,通过属性读取, + 读取时展开 ``$VAR`` 环境变量引用。 """ - type: str = Field(default="qbittorrent", description="Downloader type") host_: str = Field( default="172.17.0.1:8080", alias="host", description="Downloader host" ) username_: str = Field( default="admin", alias="username", description="Downloader username" ) + # 等同 ab_sdk.secret_field();直接写 Field 以便 mypy 识别别名 password_: str = Field( - default="adminadmin", alias="password", description="Downloader password" + default="adminadmin", + alias="password", + description="Downloader password", + json_schema_extra={"secret": True}, ) path: str = Field(default="/downloads/Bangumi", description="Downloader path") ssl: bool = Field(default=False, description="Downloader ssl") @@ -51,6 +56,19 @@ class Downloader(BaseModel): return _expand(self.password_) +@dataclass(frozen=True) +class DownloaderInstance: + """一个下载器实例的只读视图(``type`` 即 Provider id),变量已展开。""" + + id: str + type: str + host: str + username: str + password: str + path: str + ssl: bool + + class RSSParser(BaseModel): """RSS feed parsing settings.""" @@ -68,13 +86,8 @@ class BangumiManage(BaseModel): enable: bool = Field(default=True, description="Enable bangumi manage") eps_complete: bool = Field(default=False, description="Enable eps complete") - rename_method: str = Field(default="pn", description="Rename method") group_tag: bool = Field(default=False, description="Enable group tag") remove_bad_torrent: bool = Field(default=False, description="Remove bad torrent") - revision_conflict_policy: Literal["hold", "replace"] = Field( - default="hold", - description="How to handle a higher revision targeting an existing episode", - ) # 关闭后 refresh_rss 不再把未匹配种子入库(孤儿记录);代价是这些条目 # 每轮会被重新内存匹配(廉价),好处是后补规则能立即接住仍在源里的旧集 track_orphans: bool = Field( @@ -349,6 +362,46 @@ class Update(BaseModel): auto_check: bool = Field(default=True, description="Auto-check for updates") +DOWNLOADER_POINT = "downloader" +DEFAULT_DOWNLOADER_ID = "default" + + +class PluginInstance(BaseModel): + """多实例扩展点(目前只有下载器)的一个实例:用 ``provider`` 实现,``options`` + 为该实现的配置。秘密字段按 ``secret_field`` 规则掩码。""" + + id: str = Field(min_length=1, description="Instance id") + point: str = Field(description="Extension point") + provider: str = Field(description="Provider id") + options: dict[str, Any] = Field(default_factory=dict) + + +def _default_instances() -> list[PluginInstance]: + return [ + PluginInstance( + id=DEFAULT_DOWNLOADER_ID, + point=DOWNLOADER_POINT, + provider="qbittorrent", + options=DownloaderOptions().model_dump(by_alias=True), + ) + ] + + +class Slots(BaseModel): + """Provider 选择:扩展点 → Provider id;下载器选的是实例 id。""" + + downloader: str = Field( + default=DEFAULT_DOWNLOADER_ID, description="Default downloader instance id" + ) + rename_strategy: str = Field(default="pn", description="Rename method") + # 内置的 hold / replace 只在新种子是唯一占用者的严格版本升级时才可能替换 + conflict_policy: str = Field( + default="hold", + description="How to handle a higher revision targeting an existing episode", + ) + media_files: str = Field(default=CORE_ID, description="Media file classifier") + + class Plugins(BaseModel): """插件系统配置。 @@ -369,13 +422,32 @@ class Plugins(BaseModel): default_factory=dict, description="Explicit hook order per extension point (plugin ids)", ) + slots: Slots = Field(default_factory=Slots) + instances: list[PluginInstance] = Field(default_factory=_default_instances) + + @model_validator(mode="after") + def _check_instances(self) -> "Plugins": + ids = [i.id for i in self.instances] + if len(ids) != len(set(ids)): + raise ValueError(f"duplicate plugin instance id in {ids}") + for instance in self.instances: + if instance.point == DOWNLOADER_POINT: + DownloaderOptions.model_validate(instance.options) + if not any( + i.id == self.slots.downloader and i.point == DOWNLOADER_POINT + for i in self.instances + ): + raise ValueError( + f"slots.downloader {self.slots.downloader!r} " + "is not a downloader instance" + ) + return self class Config(BaseModel): """Root configuration model composed of all subsection models.""" program: Program = Program() - downloader: Downloader = Downloader() rss_parser: RSSParser = RSSParser() bangumi_manage: BangumiManage = BangumiManage() log: Log = Log() @@ -387,6 +459,33 @@ class Config(BaseModel): update: Update = Update() plugins: Plugins = Plugins() + @property + def downloader(self) -> DownloaderInstance: + """默认下载器实例(``plugins.slots.downloader``)。""" + return self.downloader_instance(self.plugins.slots.downloader) + + 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 + ), + 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, + type=instance.provider, + host=options.host, + username=options.username, + password=options.password, + path=options.path, + ssl=options.ssl, + ) + def model_dump(self, *args, by_alias=True, **kwargs): return super().model_dump(*args, by_alias=by_alias, **kwargs) diff --git a/backend/src/module/models/movie.py b/backend/src/module/models/movie.py index bd5413c6..5161e3ea 100644 --- a/backend/src/module/models/movie.py +++ b/backend/src/module/models/movie.py @@ -25,6 +25,10 @@ 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 | None = Field( + default=None, alias="downloader_id", title="下载器实例" + ) class MovieUpdate(SQLModel): @@ -39,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 diff --git a/backend/src/module/models/rss.py b/backend/src/module/models/rss.py index 8ea51ae8..ab4a4f24 100644 --- a/backend/src/module/models/rss.py +++ b/backend/src/module/models/rss.py @@ -13,6 +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: Optional[str] = Field(None, alias="downloader_id") class RSSUpdate(SQLModel): @@ -21,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") diff --git a/backend/src/module/models/torrent.py b/backend/src/module/models/torrent.py index 61b76f8e..47d63114 100644 --- a/backend/src/module/models/torrent.py +++ b/backend/src/module/models/torrent.py @@ -17,6 +17,8 @@ class Torrent(SQLModel, table=True): homepage: Optional[str] = Field(None, alias="homepage") downloaded: bool = Field(False, alias="downloaded") qb_hash: Optional[str] = Field(None, alias="qb_hash", index=True) + # 种子实际添加到的下载器实例 + downloader_id: str = Field("default", alias="downloader_id") class EpisodeFile(BaseModel): diff --git a/backend/src/module/plugin/host.py b/backend/src/module/plugin/host.py index 0482ef97..3753b7b4 100644 --- a/backend/src/module/plugin/host.py +++ b/backend/src/module/plugin/host.py @@ -25,7 +25,9 @@ logger = logging.getLogger(__name__) CORE = "core" POINTS = ( - ExtensionPoint(points.DOWNLOADER, "provider", "下载器后端(downloader.type)"), + ExtensionPoint( + points.DOWNLOADER, "provider", "下载器后端(plugins.instances[].provider)" + ), ExtensionPoint( points.NOTIFIER, "provider", "通知渠道(notification.providers[].type)" ), @@ -54,7 +56,9 @@ POINTS = ( ExtensionPoint(points.MESSAGE_TEMPLATE, "transform", "系统事件通知文案"), # --- P4 organize --- ExtensionPoint( - points.RENAME_STRATEGY, "provider", "重命名方式(bangumi_manage.rename_method)" + points.RENAME_STRATEGY, + "provider", + "重命名方式(plugins.slots.rename_strategy)", ), ExtensionPoint(points.MEDIA_FILES, "provider", "种子内文件分类(正片 / 字幕)"), ExtensionPoint(points.CONFLICT_POLICY, "provider", "目标路径被占用时保留或替换"), @@ -189,7 +193,8 @@ def _register_core(registry: ExtensionRegistry) -> None: _core(registry, points.RENAME_STRATEGY, NO_RENAME, NoRename()) _core(registry, points.MEDIA_FILES, CORE_ID, SuffixMediaFiles()) - _core(registry, points.CONFLICT_POLICY, CORE_ID, CoreConflictPolicy()) + _core(registry, points.CONFLICT_POLICY, "hold", CoreConflictPolicy("hold")) + _core(registry, points.CONFLICT_POLICY, "replace", CoreConflictPolicy("replace")) # --- P5 events/api --------------------------------------------------------- diff --git a/backend/src/module/rss/analyser.py b/backend/src/module/rss/analyser.py index ed4c9ae2..516805e6 100644 --- a/backend/src/module/rss/analyser.py +++ b/backend/src/module/rss/analyser.py @@ -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 diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index 4b10963a..5cb30579 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -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 ( @@ -95,6 +100,7 @@ class RSSEngine: name: str | None = None, aggregate: bool = True, parser: str = "mikan", + downloader_id: str | None = None, ): if not name: async with RequestContent() as req: @@ -106,7 +112,13 @@ class RSSEngine: msg_en="Failed to get RSS title.", msg_zh="无法获取 RSS 标题。", ) - rss_data = RSSItem(name=name, url=rss_link, aggregate=aggregate, parser=parser) + rss_data = RSSItem( + name=name, + url=rss_link, + aggregate=aggregate, + parser=parser, + downloader_id=downloader_id, + ) if await self.db.rss.add(rss_data): return ResponseModel( status=True, @@ -323,14 +335,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 +453,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 +506,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( diff --git a/backend/src/module/update/__init__.py b/backend/src/module/update/__init__.py index 366fd4fd..822cd3ba 100644 --- a/backend/src/module/update/__init__.py +++ b/backend/src/module/update/__init__.py @@ -1,10 +1,2 @@ -from .auth import migrate_legacy_auth_tokens -from .startup import cache_image, first_run, run_migrations -from .updater import ( - ApplyResult, - UpdateCheckResult, - Updater, - get_update_progress, - updater, -) -from .version_check import UnsupportedUpgradeError, version_check +# 不在包初始化时 import 子模块:module.conf 在构造 settings 之前要 import +# module.update.v4,而其它子模块依赖 module.conf,会形成循环。 diff --git a/backend/src/module/update/v4.py b/backend/src/module/update/v4.py new file mode 100644 index 00000000..fa312741 --- /dev/null +++ b/backend/src/module/update/v4.py @@ -0,0 +1,134 @@ +"""3.3 → 4.0 配置迁移(设计文档第 9 节)。 + +只迁移 4.0 中已有去处的字段: + +- ``downloader`` → ``plugins.instances`` 中 id 为 ``default`` 的下载器实例, + ``plugins.slots.downloader = "default"`` +- ``bangumi_manage.rename_method`` → ``plugins.slots.rename_strategy`` + (废弃的 ``normal`` 改为 ``none``) +- ``bangumi_manage.revision_conflict_policy`` → ``plugins.slots.conflict_policy`` + +在 ``Settings`` 读写配置文件之前运行。改写前把原文件备份为 ``<文件名>.v3.bak``; +失败时从备份恢复并抛出 :class:`ConfigMigrationError`,拒绝启动。 +本模块不能 import ``module.conf``:它在 ``settings`` 构造之前被调用。 +""" + +import json +import logging +import os +import shutil +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from module.models.config import DEFAULT_DOWNLOADER_ID, DOWNLOADER_POINT, Config + +logger = logging.getLogger(__name__) + +_SLOT_FIELDS = { + "rename_method": "rename_strategy", + "revision_conflict_policy": "conflict_policy", +} + + +class ConfigMigrationError(RuntimeError): + """配置迁移失败;配置文件已从备份恢复。""" + + +def _section(config: dict[str, Any], key: str) -> dict[str, Any]: + value = config.get(key) + if value is None: + return {} + if not isinstance(value, dict): + raise ConfigMigrationError(f"config migration failed at {key}: not an object") + return value + + +def migrate_v3_dict(config: dict[str, Any]) -> list[str]: + """就地把 3.3 的字段移到 4.0 的位置,返回被移动的字段(空列表表示无需迁移)。 + + 下载器字段合并到已有的 ``default`` 实例上,没有时新建;未出现的字段保持原样。 + """ + downloader = _section(config, "downloader") + bangumi_manage = _section(config, "bangumi_manage") + moved = [f"bangumi_manage.{key}" for key in _SLOT_FIELDS if key in bangumi_manage] + if "downloader" in config: + moved.insert(0, "downloader") + if not moved: + return moved + + plugins = config.setdefault("plugins", {}) + slots = plugins.setdefault("slots", {}) + if "downloader" in config: + config.pop("downloader") + options = dict(downloader) + instances = plugins.setdefault("instances", []) + instance = next( + (i for i in instances if i.get("id") == DEFAULT_DOWNLOADER_ID), None + ) + if instance is None: + instance = { + "id": DEFAULT_DOWNLOADER_ID, + "point": DOWNLOADER_POINT, + "provider": "qbittorrent", + "options": {}, + } + instances.append(instance) + instance["provider"] = options.pop("type", instance["provider"]) + instance["options"].update(options) + slots["downloader"] = DEFAULT_DOWNLOADER_ID + for old, slot in _SLOT_FIELDS.items(): + if old in bangumi_manage: + slots[slot] = bangumi_manage.pop(old) + if slots.get("rename_strategy") == "normal": + slots["rename_strategy"] = "none" + return moved + + +def _failed_field(error: Exception) -> str: + if isinstance(error, ValidationError): + first = error.errors(include_url=False)[0] + loc = ".".join(str(p) for p in first["loc"]) + return f"{loc}: {first['msg']}" + return str(error) + + +def migrate_v3_config(path: Path) -> bool: + """迁移 ``path`` 处的配置文件,返回是否改写了文件。已是 4.0 格式时什么也不做。""" + config = json.loads(path.read_text(encoding="utf-8")) + moved = migrate_v3_dict(config) + if not moved: + return False + # 已有的备份不覆盖(降级回 3.3 再升级时,第一份备份里才有真实的下载器 + # 凭据),本次备份另取 .v3.bak.1、.v3.bak.2 … + backup = path.with_name(path.name + ".v3.bak") + n = 0 + while backup.exists(): + n += 1 + backup = path.with_name(f"{path.name}.v3.bak.{n}") + shutil.copy2(path, backup) + try: + Config.model_validate(config) + # 先写临时文件再替换:中途崩溃不会留下半个配置文件 + tmp = path.with_name(path.name + ".tmp") + tmp.write_text( + json.dumps(config, indent=4, ensure_ascii=False), encoding="utf-8" + ) + os.replace(tmp, path) + except Exception as e: + shutil.copy2(backup, path) + message = ( + f"config migration to 4.0 failed at {_failed_field(e)}; " + f"{path.name} was restored from {backup.name}. Fix the field and restart. " + f"配置迁移失败,已从备份恢复 {path.name},请修正该字段后重新启动。" + ) + logger.critical(message) + raise ConfigMigrationError(message) from e + logger.info( + "Migrated %s to 4.0 (%s); the 3.3 file is kept as %s", + path.name, + ", ".join(moved), + backup.name, + ) + return True diff --git a/backend/src/test/e2e/downloader/test_release_image_smoke.py b/backend/src/test/e2e/downloader/test_release_image_smoke.py index 856ac450..71f3b11d 100644 --- a/backend/src/test/e2e/downloader/test_release_image_smoke.py +++ b/backend/src/test/e2e/downloader/test_release_image_smoke.py @@ -115,7 +115,7 @@ def test_native_image_version_and_config_survive_restart(downloader_stack): assert persisted["rss_parser"]["language"] == "jp" assert persisted["bangumi_manage"]["enable"] is False assert ( - persisted["downloader"]["host"] + persisted["plugins"]["instances"][0]["options"]["host"] == downloader_stack.environment["AB_E2E_QB_INTERNAL_URL"] ) assert persisted["network"]["tmdb_base_url"] == ( diff --git a/backend/src/test/factories.py b/backend/src/test/factories.py index b8d2bc43..f597d785 100644 --- a/backend/src/test/factories.py +++ b/backend/src/test/factories.py @@ -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 diff --git a/backend/src/test/fixtures/config_v3.3.json b/backend/src/test/fixtures/config_v3.3.json new file mode 100644 index 00000000..67c19a0f --- /dev/null +++ b/backend/src/test/fixtures/config_v3.3.json @@ -0,0 +1,99 @@ +{ + "program": { + "rss_time": 1800, + "rename_time": 60, + "webui_port": 7892 + }, + "downloader": { + "type": "qbittorrent", + "host": "$QB_HOST", + "username": "ab", + "password": "qb-secret", + "path": "/media/downloads/Bangumi", + "ssl": true + }, + "rss_parser": { + "enable": true, + "filter": [ + "720", + "\\d+-\\d+" + ], + "language": "zh", + "engine": "classic" + }, + "bangumi_manage": { + "enable": true, + "eps_complete": false, + "rename_method": "advance", + "group_tag": true, + "remove_bad_torrent": false, + "revision_conflict_policy": "replace", + "track_orphans": true + }, + "log": { + "debug_enable": false + }, + "network": { + "tmdb_base_url": "https://api.themoviedb.org", + "tmdb_api_key": "", + "bgm_base_url": "https://api.bgm.tv" + }, + "proxy": { + "enable": false, + "type": "http", + "host": "", + "port": 0, + "username": "", + "password": "" + }, + "notification": { + "enable": true, + "providers": [ + { + "type": "telegram", + "enabled": true, + "token": "tg-token", + "chat_id": "42" + } + ], + "base_url": "", + "type": null, + "token": null, + "chat_id": null + }, + "llm": { + "enable": false, + "provider": "openai", + "api_key": "", + "model": "gpt-5-mini", + "base_url": "", + "mode": "fallback", + "timeout": 20.0, + "cache_ttl": 900, + "max_concurrency": 2, + "failure_threshold": 3, + "failure_backoff": 300, + "providers": {} + }, + "experimental_openai": { + "enable": false, + "api_key": "", + "api_base": "https://api.openai.com/v1", + "api_type": "openai", + "api_version": "2023-05-15", + "model": "gpt-3.5-turbo", + "deployment_id": "" + }, + "security": { + "login_whitelist": [], + "login_tokens": [], + "mcp_whitelist": [], + "mcp_tokens": [], + "webauthn_rp_id": "", + "webauthn_origin": "" + }, + "update": { + "channel": "stable", + "auto_check": true + } +} diff --git a/backend/src/test/test_api_bangumi_extended.py b/backend/src/test/test_api_bangumi_extended.py index 9b1d302c..c5efaec2 100644 --- a/backend/src/test/test_api_bangumi_extended.py +++ b/backend/src/test/test_api_bangumi_extended.py @@ -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.""" diff --git a/backend/src/test/test_api_config.py b/backend/src/test/test_api_config.py index a528f514..85a67b3b 100644 --- a/backend/src/test/test_api_config.py +++ b/backend/src/test/test_api_config.py @@ -61,13 +61,6 @@ def mock_settings(): settings.program.rss_time = 900 settings.program.rename_time = 60 settings.program.webui_port = 7892 - settings.downloader = MagicMock() - settings.downloader.type = "qbittorrent" - settings.downloader.host = "172.17.0.1:8080" - settings.downloader.username = "admin" - settings.downloader.password = "adminadmin" - settings.downloader.path = "/downloads/Bangumi" - settings.downloader.ssl = False settings.rss_parser = MagicMock() settings.rss_parser.enable = True settings.rss_parser.filter = ["720", r"\d+-\d"] @@ -76,7 +69,6 @@ def mock_settings(): settings.bangumi_manage = MagicMock() settings.bangumi_manage.enable = True settings.bangumi_manage.eps_complete = False - settings.bangumi_manage.rename_method = "pn" settings.bangumi_manage.group_tag = False settings.bangumi_manage.remove_bad_torrent = False settings.log = MagicMock() @@ -126,7 +118,8 @@ class TestGetConfig: assert response.status_code == 200 data = response.json() assert "program" in data - assert "downloader" in data + assert "downloader" not in data + assert data["plugins"]["instances"][0]["id"] == "default" assert "rss_parser" in data assert data["program"]["rss_time"] == 900 assert data["program"]["webui_port"] == 7892 @@ -190,7 +183,6 @@ class TestUpdateConfig: "bangumi_manage": { "enable": True, "eps_complete": False, - "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False, }, @@ -246,7 +238,6 @@ class TestUpdateConfig: "bangumi_manage": { "enable": True, "eps_complete": False, - "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False, }, @@ -398,7 +389,7 @@ class TestSanitizeDict: assert response.status_code == 200 data = response.json() # Downloader password should be masked - assert data["downloader"]["password"] == "********" + assert data["plugins"]["instances"][0]["options"]["password"] == "********" # Unset (empty) secrets must NOT be masked: a phantom mask makes the # UI show a password where none exists (TG report) assert data["llm"]["api_key"] == "" @@ -614,19 +605,26 @@ class TestRestoreMasked: """PATCH /config/update must not overwrite a real password with '********'.""" mock_settings.dict.return_value = { "program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892}, - "downloader": { - "type": "qbittorrent", - "host": "192.168.1.1:8080", - "username": "admin", - "password": "realpassword", - "path": "/downloads", - "ssl": True, + "plugins": { + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "qbittorrent", + "options": { + "host": "192.168.1.1:8080", + "username": "admin", + "password": "realpassword", + "path": "/downloads", + "ssl": True, + }, + } + ] }, "rss_parser": {"enable": True, "filter": [], "language": "zh"}, "bangumi_manage": { "enable": True, "eps_complete": False, - "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False, }, @@ -648,19 +646,26 @@ class TestRestoreMasked: } payload = { "program": {"rss_time": 900, "rename_time": 60, "webui_port": 7892}, - "downloader": { - "type": "qbittorrent", - "host": "192.168.1.1:8080", - "username": "admin", - "password": "********", - "path": "/downloads", - "ssl": False, + "plugins": { + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "qbittorrent", + "options": { + "host": "192.168.1.1:8080", + "username": "admin", + "password": "********", + "path": "/downloads", + "ssl": False, + }, + } + ] }, "rss_parser": {"enable": True, "filter": [], "language": "zh"}, "bangumi_manage": { "enable": True, "eps_complete": False, - "rename_method": "pn", "group_tag": False, "remove_bad_torrent": False, }, @@ -685,8 +690,9 @@ class TestRestoreMasked: assert response.status_code == 200 saved = mock_settings.save.call_args[1]["config_dict"] - assert saved["downloader"]["password"] == "realpassword" - assert saved["downloader"]["ssl"] is False + options = saved["plugins"]["instances"][0]["options"] + assert options["password"] == "realpassword" + assert options["ssl"] is False class TestListLLMModels: diff --git a/backend/src/test/test_api_downloader.py b/backend/src/test/test_api_downloader.py index 9fddbb66..7fd61d61 100644 --- a/backend/src/test/test_api_downloader.py +++ b/backend/src/test/test_api_downloader.py @@ -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 ) @@ -463,7 +464,7 @@ class TestAutoTagTorrents: }, ] - 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 ) @@ -499,7 +500,7 @@ class TestAutoTagTorrents: }, ] - 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 ) diff --git a/backend/src/test/test_api_events.py b/backend/src/test/test_api_events.py index 36acdbf7..f5ebe5c5 100644 --- a/backend/src/test/test_api_events.py +++ b/backend/src/test/test_api_events.py @@ -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"), ): diff --git a/backend/src/test/test_config.py b/backend/src/test/test_config.py index ca62283f..2a848796 100644 --- a/backend/src/test/test_config.py +++ b/backend/src/test/test_config.py @@ -9,7 +9,7 @@ import pytest from module.conf.config import Settings from module.models.config import ( Config, - Downloader, + DownloaderOptions, NotificationProvider, RSSParser, Security, @@ -61,10 +61,10 @@ class TestConfigDefaults: """BangumiManage has correct default values.""" config = Config() assert config.bangumi_manage.enable is True - assert config.bangumi_manage.rename_method == "pn" + assert config.plugins.slots.rename_strategy == "pn" assert config.bangumi_manage.group_tag is False assert config.bangumi_manage.remove_bad_torrent is False - assert config.bangumi_manage.revision_conflict_policy == "hold" + assert config.plugins.slots.conflict_policy == "hold" assert config.bangumi_manage.eps_complete is False def test_proxy_defaults(self): @@ -85,14 +85,65 @@ class TestConfigDefaults: # --------------------------------------------------------------------------- +class TestPluginInstances: + @pytest.mark.parametrize( + "plugins", + [ + {"slots": {"downloader": "missing"}}, + { + "instances": [ + {"id": "default", "point": "downloader", "provider": "qb"}, + {"id": "default", "point": "downloader", "provider": "aria2"}, + ] + }, + { + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "qbittorrent", + "options": {"ssl": "sometimes"}, + } + ] + }, + ], + ) + def test_invalid_downloader_instances_rejected(self, plugins): + with pytest.raises(ValueError): + Config.model_validate({"plugins": plugins}) + + def test_downloader_view_follows_slot(self): + config = Config.model_validate( + { + "plugins": { + "slots": {"downloader": "nas"}, + "instances": [ + {"id": "default", "point": "downloader", "provider": "qb"}, + { + "id": "nas", + "point": "downloader", + "provider": "aria2", + "options": {"host": "nas:6800"}, + }, + ], + } + } + ) + assert (config.downloader.id, config.downloader.type) == ("nas", "aria2") + assert config.downloader.host == "nas:6800" + # 未填写的字段取默认值 + assert config.downloader.path == "/downloads/Bangumi" + + class TestConfigSerialization: def test_dict_uses_alias(self): """Config.dict() uses field aliases (by_alias=True).""" config = Config() d = config.dict() - # Downloader uses alias 'host' not 'host_' - assert "host" in d["downloader"] - assert "host_" not in d["downloader"] + # 下载器实例的 options 用别名 host 而不是 host_ + options = d["plugins"]["instances"][0]["options"] + assert "host" in options + assert "host_" not in options def test_roundtrip_json(self, tmp_path): """Config can be serialized to JSON and loaded back.""" @@ -128,18 +179,6 @@ class TestMigrateOldConfig: assert result["program"]["rss_time"] == 900 assert result["program"]["rename_time"] == 60 - def test_normal_rename_method_becomes_none(self): - """废弃的 normal 重命名方法迁移为语义相同的 none。""" - config = {"bangumi_manage": {"rename_method": "normal"}} - result = Settings._migrate_old_config(config) - assert result["bangumi_manage"]["rename_method"] == "none" - - @pytest.mark.parametrize("method", ["pn", "advance", "none"]) - def test_supported_rename_methods_untouched(self, method): - config = {"bangumi_manage": {"rename_method": method}} - result = Settings._migrate_old_config(config) - assert result["bangumi_manage"]["rename_method"] == method - # --------------------------------------------------------------------------- # Settings.load from file @@ -191,7 +230,8 @@ class TestSettingsLoad: with open(config_file) as f: data = json.load(f) assert "program" in data - assert "downloader" in data + assert "downloader" not in data + assert data["plugins"]["instances"][0]["provider"] == "qbittorrent" # 新配置文件包含 llm 段 assert "llm" in data assert data["rss_parser"]["engine"] == "classic" @@ -222,18 +262,34 @@ class TestSettingsLoad: class TestEnvOverrides: - def test_downloader_host_from_env(self, tmp_path): - """AB_DOWNLOADER_HOST env var overrides downloader host.""" + def test_downloader_env_maps_onto_default_instance(self, tmp_path): + """AB_DOWNLOADER_* / AB_DOWNLOAD_PATH / AB_METHOD 写入默认下载器实例与 slots。""" config_file = tmp_path / "config.json" - env = {"AB_DOWNLOADER_HOST": "192.168.1.100:9090"} + env = { + "AB_DOWNLOADER_HOST": "192.168.1.100:9090", + "AB_DOWNLOADER_USERNAME": "ab", + "AB_DOWNLOADER_PASSWORD": "pw", + "AB_DOWNLOAD_PATH": "/data/Bangumi", + "AB_METHOD": "Advance", + } with patch.dict(os.environ, env, clear=False): with patch("module.conf.config.CONFIG_PATH", config_file): s = Settings.__new__(Settings) Config.__init__(s) s.init() - assert "192.168.1.100:9090" in s.downloader.host + assert (s.downloader.host, s.downloader.username, s.downloader.password) == ( + "192.168.1.100:9090", + "ab", + "pw", + ) + assert s.downloader.path == "/data/Bangumi" + assert s.downloader.type == "qbittorrent" + assert s.plugins.slots.rename_strategy == "advance" + saved = json.loads(config_file.read_text()) + assert "downloader" not in saved + assert len(saved["plugins"]["instances"]) == 1 def test_rss_parser_engine_from_env(self, tmp_path): """AB_RSS_PARSER_ENGINE selects a supported parser engine.""" @@ -260,7 +316,7 @@ class TestEnvOverrides: Config.__init__(s) s.init() - assert s.bangumi_manage.revision_conflict_policy == "replace" + assert s.plugins.slots.conflict_policy == "replace" # --------------------------------------------------------------------------- @@ -401,26 +457,26 @@ class TestNotificationProviders: class TestDownloaderEnvExpansion: def test_host_expands_env_var(self, monkeypatch): - """Downloader.host expands $VAR references.""" + """DownloaderOptions.host expands $VAR references.""" monkeypatch.setenv("QB_HOST", "192.168.5.10:8080") - d = Downloader(host="$QB_HOST") + d = DownloaderOptions(host="$QB_HOST") assert d.host == "192.168.5.10:8080" def test_username_expands_env_var(self, monkeypatch): - """Downloader.username expands $VAR references.""" + """DownloaderOptions.username expands $VAR references.""" monkeypatch.setenv("QB_USER", "myuser") - d = Downloader(username="$QB_USER") + d = DownloaderOptions(username="$QB_USER") assert d.username == "myuser" def test_password_expands_env_var(self, monkeypatch): - """Downloader.password expands $VAR references.""" + """DownloaderOptions.password expands $VAR references.""" monkeypatch.setenv("QB_PASS", "s3cret") - d = Downloader(password="$QB_PASS") + d = DownloaderOptions(password="$QB_PASS") assert d.password == "s3cret" def test_literal_host_not_expanded(self): """Literal host strings without $ are returned as-is.""" - d = Downloader(host="localhost:8080") + d = DownloaderOptions(host="localhost:8080") assert d.host == "localhost:8080" @@ -446,7 +502,7 @@ class TestDefaultSettings: assert Config().rss_parser.engine == "classic" def test_revision_conflict_policy_defaults_to_hold(self): - assert Config().bangumi_manage.revision_conflict_policy == "hold" + assert Config().plugins.slots.conflict_policy == "hold" class TestNetworkBaseUrls: diff --git a/backend/src/test/test_context.py b/backend/src/test/test_context.py index 94c86cf6..c7ce8542 100644 --- a/backend/src/test/test_context.py +++ b/backend/src/test/test_context.py @@ -9,7 +9,7 @@ from module.conf import settings as real_settings from module.core.context import AppContext from module.models import ResponseModel from module.notification import DownloaderUnavailableEvent -from module.update import UnsupportedUpgradeError +from module.update.version_check import UnsupportedUpgradeError @pytest.fixture diff --git a/backend/src/test/test_integration.py b/backend/src/test/test_integration.py index ba75ce26..1af60381 100644 --- a/backend/src/test/test_integration.py +++ b/backend/src/test/test_integration.py @@ -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 @@ -210,7 +210,7 @@ class TestRenameFlow: with patch.object(renamer._parser, "torrent_parser", return_value=ep): with patch("module.manager.renamer.settings") as mock_mgr_settings: - mock_mgr_settings.bangumi_manage.rename_method = "pn" + mock_mgr_settings.plugins.slots.rename_strategy = "pn" mock_mgr_settings.bangumi_manage.remove_bad_torrent = False with patch("module.downloader.path.settings") as mock_path_settings: mock_path_settings.downloader.path = "/downloads/Bangumi" @@ -273,7 +273,7 @@ class TestRenameFlow: with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): with patch("module.manager.renamer.settings") as mock_mgr_settings: - mock_mgr_settings.bangumi_manage.rename_method = "pn" + mock_mgr_settings.plugins.slots.rename_strategy = "pn" mock_mgr_settings.bangumi_manage.remove_bad_torrent = False with patch("module.downloader.path.settings") as mock_path_settings: mock_path_settings.downloader.path = "/downloads/Bangumi" diff --git a/backend/src/test/test_loops.py b/backend/src/test/test_loops.py index 0473e51e..54cd1604 100644 --- a/backend/src/test/test_loops.py +++ b/backend/src/test/test_loops.py @@ -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 diff --git a/backend/src/test/test_mcp_tools.py b/backend/src/test/test_mcp_tools.py index 5d683cfd..cc53a869 100644 --- a/backend/src/test/test_mcp_tools.py +++ b/backend/src/test/test_mcp_tools.py @@ -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", {}) diff --git a/backend/src/test/test_migrations_module.py b/backend/src/test/test_migrations_module.py index 893e2317..e2d3d0ee 100644 --- a/backend/src/test/test_migrations_module.py +++ b/backend/src/test/test_migrations_module.py @@ -704,6 +704,40 @@ class TestRunMigrations: assert get_schema_version(conn) == CURRENT_SCHEMA_VERSION assert prefix == "legacy_cccccccc" + @pytest.mark.parametrize("preexisting", [None, "torrent"]) + 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: + if preexisting: + conn.execute( + text( + f"ALTER TABLE {preexisting} ADD COLUMN downloader_id VARCHAR " + "DEFAULT 'default'" + ) + ) + conn.execute( + text("INSERT INTO bangumi (id, official_title) VALUES (1, 'a')") + ) + conn.execute(text("INSERT INTO rssitem (id, name) VALUES (1, 'r')")) + conn.execute(text("INSERT INTO torrent (id, name) VALUES (1, 't')")) + _run_through_version(engine, 25, monkeypatch) + with engine.begin() as conn: + conn.execute(text("INSERT INTO movie (official_title) VALUES ('m')")) + + 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) == [expected.get(table, "default")], table + def test_is_idempotent(self): engine = _make_v0_engine() run_migrations(engine) diff --git a/backend/src/test/test_multi_downloader.py b/backend/src/test/test_multi_downloader.py new file mode 100644 index 00000000..b3aa758b --- /dev/null +++ b/backend/src/test/test_multi_downloader.py @@ -0,0 +1,532 @@ +"""多下载器实例(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": "", + } + + +def test_download_client_qb_and_aria2_side_by_side(monkeypatch): + """qB 与 aria2 实例并存:各自的后端、地址与版本替换事务命名空间。""" + from module.downloader.client.aria2_downloader import Aria2Downloader + from module.downloader.client.qb_downloader import QbDownloader + + plugins = Plugins( + instances=[ + PluginInstance( + id="qb", + point="downloader", + provider="qbittorrent", + options={"host": "qb:8080"}, + ), + PluginInstance( + id="ar", + point="downloader", + provider="aria2", + options={"host": "ar:6800"}, + ), + ], + slots=Slots(downloader="qb"), + ) + monkeypatch.setattr(settings, "plugins", plugins) + qb, ar = DownloadClient("qb"), DownloadClient("ar") + assert isinstance(qb.client, QbDownloader) + assert isinstance(ar.client, Aria2Downloader) + assert ar.client.host.endswith("ar:6800") + assert Renamer(qb)._downloader_type() != Renamer(ar)._downloader_type() + + +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/") + + @pytest.mark.parametrize( + "default, bangumi_instance, rss_instance", + [("b", None, None), ("b", "gone", None), ("a", None, "b")], + ) + async def test_refresh_rss_save_path_of_other_instance_rerooted( + self, + two_instances, + db_engine, + monkeypatch, + default, + bangumi_instance, + rss_instance, + ): + """默认实例切换、规则的实例被删除、订阅指向另一实例:已存的保存目录 + 属于实例 a,新种子进实例 b 时必须换成 b 的下载目录。""" + monkeypatch.setattr(two_instances.slots, "downloader", default) + 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="/a/Bangumi/Test Anime (2024)/Season 1", + 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) + + (added,) = _mock("b")._torrents.values() + assert added["save_path"] == "/b/Bangumi/Test Anime (2024)/Season 1" + (rule,) = await engine.db.bangumi.search_all() + assert rule.save_path == "/b/Bangumi/Test Anime (2024)/Season 1" + + 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_rename_single_instance_replacement_after_host_change_recovered( + monkeypatch, +): + """只有一个实例时,改了主机地址(同一个 qB)后仍要恢复进行中的版本替换。""" + from module.models.rename_operation import RenameOperation + + monkeypatch.setattr( + settings, + "plugins", + Plugins(instances=[_instance("a", "/a/Bangumi")], slots=Slots(downloader="a")), + ) + async with Database() as db: + db.add( + RenameOperation( + kind="replacement", + state="old_staged", + downloader_type="mock:old-host-hash", + new_task_id="gone", + 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("a") as client: + await Renamer(client).rename() + recover.assert_awaited_once() + + +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_without_downloader_field_keeps_rule_instance( + self, two_instances + ): + """只改其它字段的部分更新(未带 downloader_id)不能把规则改回默认实例。""" + from module.manager import TorrentManager + from module.models import BangumiUpdate + + bangumi_id = await self._rule_with_torrent_on_a() + 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) + fields = bangumi.model_dump(exclude={"id", "downloader_id"}) + await TorrentManager(db).update_rule(bangumi_id, BangumiUpdate(**fields)) + 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" + + 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 == {} + + async def test_update_rule_moves_torrents_on_instance_of_torrent_rows( + self, two_instances + ): + """规则未指定实例,种子经订阅进了实例 b:改季度要移动 b 上的种子。""" + from module.manager import TorrentManager + from module.models import BangumiUpdate + + _mock("b")._torrents["hb"] = _completed( + "B", "hb", "/b/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="B", url="u-b", bangumi_id=1, downloader_id="b") + ) + old = await db.bangumi.search_id(1) + assert old is not None + update = BangumiUpdate(**old.model_dump(exclude={"id"})) + update.season = 2 + await TorrentManager(db).update_rule(1, update) + + moved = _mock("b")._torrents["hb"]["save_path"] + assert moved == "/b/Bangumi/Test Anime (2024)/Season 2" + + async def test_delete_rule_unavailable_instance_others_still_deleted( + self, two_instances + ): + from module.manager import TorrentManager + + bangumi_id = await self._rule_with_torrent_on_a() + _mock("b")._torrents["new"] = _completed( + "New", "new", "/b/Bangumi/Test Anime (2024)/Season 1" + ) + _mock("a").auth = AsyncMock(return_value=False) # type: ignore[method-assign] + async with Database() as db: + await db.torrent.add( + Torrent(name="New", url="u-new", bangumi_id=1, downloader_id="b") + ) + resp = await TorrentManager(db).delete_rule(bangumi_id, file=True) + + assert _mock("b")._torrents == {} + assert resp.status is False and resp.status_code == 500 + + +async def test_eps_complete_unavailable_instance_other_rules_collected(two_instances): + from module.manager import collector + + async with Database() as db: + await db.bangumi.add(make_bangumi(official_title="On B", downloader_id="b")) + await db.bangumi.add(make_bangumi(official_title="On A", title_raw="A raw")) + _mock("b").auth = AsyncMock(return_value=False) # type: ignore[method-assign] + + with patch.object( + collector.SeasonCollector, "collect_season", AsyncMock() + ) as collect: + await collector.eps_complete() + + assert [c.args[0].official_title for c in collect.await_args_list] == ["On A"] + async with Database() as db: + rules = {b.official_title: b.eps_collect for b in await db.bangumi.search_all()} + assert rules == {"On B": False, "On A": True} + + +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"}, + ], + } + + def test_auto_tag_unavailable_instance_skipped(self, two_instances, authed_client): + _mock("a")._torrents["ha"] = _completed("A", "ha", "/a/Bangumi/A/Season 1") + _mock("b").auth = AsyncMock(return_value=False) # type: ignore[method-assign] + resp = authed_client.post("/api/v1/downloader/torrents/tag/auto") + assert resp.status_code == 200 + assert [t["hash"] for t in resp.json()["unmatched"]] == ["ha"] + + def test_add_rss_keeps_downloader_instance(self, two_instances, authed_client): + resp = authed_client.post( + "/api/v1/rss/add", + json={"url": "https://e/rss", "name": "Feed", "downloader_id": "b"}, + ) + assert resp.status_code == 200 + (feed,) = authed_client.get("/api/v1/rss").json() + assert feed["downloader_id"] == "b" diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index 2756bf91..53bd40ca 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -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 diff --git a/backend/src/test/test_organize.py b/backend/src/test/test_organize.py index dc49be6c..c7f70a71 100644 --- a/backend/src/test/test_organize.py +++ b/backend/src/test/test_organize.py @@ -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" @@ -102,7 +103,7 @@ async def run(renamer: Renamer, method: str) -> None: patch("module.manager.renamer.settings") as mock_settings, patch("module.downloader.path.settings") as mock_path_settings, ): - mock_settings.bangumi_manage.rename_method = method + mock_settings.plugins.slots.rename_strategy = method mock_settings.bangumi_manage.remove_bad_torrent = False mock_path_settings.downloader.path = "/downloads/Bangumi" await renamer.rename() @@ -276,8 +277,8 @@ def _task(file_count: int = 1, revision: int | None = 1) -> RevisionTask: def test_core_conflict_policy_decide_matches_3x_reasons( owners, incoming, configured, strict, action, reason ): - decision = CoreConflictPolicy().decide( - ConflictRequest("t.mkv", incoming, owners, configured, strict) + decision = CoreConflictPolicy(configured).decide( + ConflictRequest("t.mkv", incoming, owners, strict) ) assert (decision.action, decision.reason) == (action, reason) diff --git a/backend/src/test/test_plugin_extensions.py b/backend/src/test/test_plugin_extensions.py index 85407237..7ade3b4a 100644 --- a/backend/src/test/test_plugin_extensions.py +++ b/backend/src/test/test_plugin_extensions.py @@ -71,13 +71,21 @@ class FakeDownloader: return AddResult.ADDED +def _default_downloader(): + return next( + i + for i in settings.plugins.instances + if i.id == settings.plugins.slots.downloader + ) + + class TestDownloader: def test_plugin_downloader_is_used_by_facade(self, registry, monkeypatch): from module.downloader import DownloadClient add_plugin_provider(registry, points.DOWNLOADER, "fake", FakeDownloader) - monkeypatch.setattr(settings.downloader, "type", "fake") - monkeypatch.setattr(settings.downloader, "host_", "example:1234") + monkeypatch.setattr(_default_downloader(), "provider", "fake") + monkeypatch.setitem(_default_downloader().options, "host", "example:1234") client = DownloadClient() assert isinstance(client.client, FakeDownloader) assert client.client.conn.host == "example:1234" @@ -86,7 +94,7 @@ class TestDownloader: def test_unknown_type_raises(self, registry, monkeypatch): from module.downloader import DownloadClient - monkeypatch.setattr(settings.downloader, "type", "nope") + monkeypatch.setattr(_default_downloader(), "provider", "nope") with pytest.raises(Exception, match="Unsupported downloader type"): DownloadClient() @@ -576,7 +584,7 @@ class TestPluginChangeRebuildsState: from module.downloader import DownloadClient add_plugin_provider(registry, points.DOWNLOADER, "fake", FakeDownloader) - monkeypatch.setattr(settings.downloader, "type", "fake") + monkeypatch.setattr(_default_downloader(), "provider", "fake") first = DownloadClient().client assert DownloadClient().client is first # 插件重载:同 id 的新登记项 diff --git a/backend/src/test/test_plugin_ingest.py b/backend/src/test/test_plugin_ingest.py index 1a09d27d..f84fa84b 100644 --- a/backend/src/test/test_plugin_ingest.py +++ b/backend/src/test/test_plugin_ingest.py @@ -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 diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index ae7760cc..4b2587fa 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -350,7 +350,7 @@ class TestRenameSingleFile: mock_settings.downloader.path = "/downloads/Bangumi" mock_settings.bangumi_manage.group_tag = False mock_settings.bangumi_manage.remove_bad_torrent = False - mock_settings.bangumi_manage.rename_method = "pn" + mock_settings.plugins.slots.rename_strategy = "pn" with patch( "module.downloader.download_client.DownloadClient._DownloadClient__getClient", return_value=mock_qb_client, @@ -391,7 +391,7 @@ class TestRenameSingleFile: patch("module.manager.renamer.settings") as mock_settings, patch("module.downloader.path.settings") as mock_path_settings, ): - mock_settings.bangumi_manage.rename_method = method + mock_settings.plugins.slots.rename_strategy = method mock_settings.bangumi_manage.remove_bad_torrent = remove_bad_torrent mock_path_settings.downloader.path = "/downloads/Bangumi" return await renamer.rename() @@ -847,7 +847,7 @@ class TestRenameFlow: ) with patch.object(renamer._parser, "torrent_parser", return_value=ep): with patch("module.manager.renamer.settings") as mock_settings: - mock_settings.bangumi_manage.rename_method = "pn" + mock_settings.plugins.slots.rename_strategy = "pn" mock_settings.bangumi_manage.remove_bad_torrent = False with patch("module.downloader.path.settings") as mock_path_settings: mock_path_settings.downloader.path = "/downloads/Bangumi" @@ -884,7 +884,7 @@ class TestRenameFlow: with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): with patch("module.manager.renamer.settings") as mock_settings: - mock_settings.bangumi_manage.rename_method = "pn" + mock_settings.plugins.slots.rename_strategy = "pn" mock_settings.bangumi_manage.remove_bad_torrent = False with patch("module.downloader.path.settings") as mock_path_settings: mock_path_settings.downloader.path = "/downloads/Bangumi" @@ -916,7 +916,7 @@ class TestRenameFlow: AsyncMock(return_value={"h1": (0, "movie")}), ): with patch("module.manager.renamer.settings") as mock_settings: - mock_settings.bangumi_manage.rename_method = "advance" + mock_settings.plugins.slots.rename_strategy = "advance" mock_settings.bangumi_manage.remove_bad_torrent = False with patch("module.downloader.path.settings") as mock_path_settings: mock_path_settings.downloader.path = "/downloads/Bangumi" @@ -941,7 +941,7 @@ class TestRenameFlow: {"name": "info.nfo"}, ] with patch("module.manager.renamer.settings") as mock_settings: - mock_settings.bangumi_manage.rename_method = "pn" + mock_settings.plugins.slots.rename_strategy = "pn" with patch("module.downloader.path.settings") as mock_path_settings: mock_path_settings.downloader.path = "/downloads/Bangumi" result = await renamer.rename() @@ -1022,7 +1022,7 @@ class TestRevisionConflictFlow: renamer.client.client.torrents_rename_file.return_value = RenameResult( RenameOutcome.RENAMED ) - test_settings.bangumi_manage.rename_method = "pn" + test_settings.plugins.slots.rename_strategy = "pn" other = Renamer(renamer.client) with ( @@ -1108,8 +1108,8 @@ class TestRevisionConflictFlow: return [{"name": self.V2}] renamer.client.client.torrents_files.side_effect = files - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "hold" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "hold" with ( patch("module.manager.renamer.settings", test_settings), @@ -1160,8 +1160,8 @@ class TestRevisionConflictFlow: renamer.client.client.torrents_files.side_effect = files renamer.client.client.torrents_rename_file.side_effect = rename renamer.client.client.torrents_delete.side_effect = delete - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "replace" with ( patch("module.manager.renamer.settings", test_settings), @@ -1202,8 +1202,8 @@ class TestRevisionConflictFlow: renamer.client.client.torrents_files.side_effect = files renamer.client.client.torrents_rename_file.side_effect = rename renamer.client.client.torrents_delete.return_value = True - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "replace" with ( patch("module.manager.renamer.settings", test_settings), @@ -1241,8 +1241,8 @@ class TestRevisionConflictFlow: return [{"name": self.V2}, {"name": "尼古喵喵 - 01.ass"}] renamer.client.client.torrents_files.side_effect = files - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "replace" with ( patch("module.manager.renamer.settings", test_settings), @@ -1281,8 +1281,8 @@ class TestRevisionConflictFlow: renamer.client.client.torrents_files.side_effect = files renamer.client.client.torrents_rename_file.side_effect = rename - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "replace" with ( patch("module.manager.renamer.settings", test_settings), @@ -1310,8 +1310,8 @@ class TestRevisionConflictFlow: return [{"name": self.V2}] renamer.client.client.torrents_files.side_effect = files - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "replace" with ( patch("module.manager.renamer.settings", test_settings), @@ -1345,9 +1345,9 @@ class TestRevisionConflictFlow: renamer.client.client.torrents_rename_file.return_value = RenameResult( RenameOutcome.DESTINATION_EXISTS, detail="target exists" ) - test_settings.bangumi_manage.rename_method = "pn" + test_settings.plugins.slots.rename_strategy = "pn" test_settings.bangumi_manage.remove_bad_torrent = True - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.conflict_policy = "replace" def parse(torrent_path, **kwargs): episode = 1 if "01" in torrent_path else 2 @@ -1408,8 +1408,8 @@ class TestRevisionConflictFlow: renamer.client.client.torrents_files.side_effect = files renamer.client.client.torrents_rename_file.side_effect = rename renamer.client.client.torrents_delete.return_value = False - test_settings.bangumi_manage.rename_method = "pn" - test_settings.bangumi_manage.revision_conflict_policy = "replace" + test_settings.plugins.slots.rename_strategy = "pn" + test_settings.plugins.slots.conflict_policy = "replace" with ( patch("module.manager.renamer.settings", test_settings), diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 9c5df32c..1576a7f9 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -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 diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py index 60304976..369bf022 100644 --- a/backend/src/test/test_setup.py +++ b/backend/src/test/test_setup.py @@ -6,6 +6,7 @@ from fastapi.testclient import TestClient from module.api.deps import get_context from module.api.setup import SENTINEL_PATH, router +from module.models.config import Config from module.models.user import User from module.security.api import get_auth_service from module.security.password import get_password_hash @@ -56,12 +57,10 @@ def mock_first_run(): with ( patch("module.api.setup.SENTINEL_PATH") as mock_sentinel, patch("module.api.setup.settings") as mock_settings, - patch("module.api.setup.Config") as mock_config, ): mock_sentinel.exists.return_value = False - mock_settings.dict.return_value = {"test": "default"} - mock_config.return_value.dict.return_value = {"test": "default"} - yield + mock_settings.dict.side_effect = lambda: Config().dict() + yield mock_settings @pytest.fixture @@ -350,6 +349,34 @@ class TestSetupComplete: "notification_chat_id": "", } + def test_complete_writes_downloader_to_default_instance( + self, client, mock_first_run, auth_service + ): + auth_service.authenticate_session.return_value = User( + id=7, username="admin", password="hashed-password", enabled=True + ) + client.cookies.set("token", "pre-setup-session") + payload = self._payload() | {"downloader_type": "aria2"} + response = client.post("/api/v1/setup/complete", json=payload) + + assert response.status_code == 200 + saved = mock_first_run.save.call_args.args[0] + assert "downloader" not in saved + assert saved["plugins"]["instances"] == [ + { + "id": "default", + "point": "downloader", + "provider": "aria2", + "options": { + "host": "localhost:8080", + "username": "admin", + "password": "admin", + "path": "/downloads", + "ssl": False, + }, + } + ] + def test_complete_routes_through_ctx_reload_settings( self, client, mock_first_run, mock_ctx, auth_service ): diff --git a/backend/src/test/test_update_v4.py b/backend/src/test/test_update_v4.py new file mode 100644 index 00000000..ae1c8679 --- /dev/null +++ b/backend/src/test/test_update_v4.py @@ -0,0 +1,147 @@ +"""3.3 → 4.0 配置迁移(module.update.v4)。""" + +import json +import shutil +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from module.conf.config import Settings +from module.models.config import DownloaderInstance +from module.update.v4 import ConfigMigrationError, migrate_v3_config, migrate_v3_dict + +FIXTURE = Path(__file__).parent / "fixtures" / "config_v3.3.json" + + +@pytest.fixture +def v3_config(tmp_path: Path) -> Path: + path = tmp_path / "config.json" + shutil.copy(FIXTURE, path) + return path + + +def test_settings_v33_config_round_trip_migrated(v3_config, monkeypatch): + monkeypatch.setenv("QB_HOST", "nas:8080") + with patch("module.conf.config.CONFIG_PATH", v3_config): + settings = Settings() + + assert settings.downloader == DownloaderInstance( + id="default", + type="qbittorrent", + host="nas:8080", + username="ab", + password="qb-secret", + path="/media/downloads/Bangumi", + ssl=True, + ) + assert settings.plugins.slots.downloader == "default" + assert settings.plugins.slots.rename_strategy == "advance" + assert settings.plugins.slots.conflict_policy == "replace" + # 其它字段原样保留 + assert settings.program.rss_time == 1800 + assert settings.bangumi_manage.group_tag is True + assert settings.notification.providers[0].token == "tg-token" + + saved = json.loads(v3_config.read_text()) + assert "downloader" not in saved + assert "rename_method" not in saved["bangumi_manage"] + assert "revision_conflict_policy" not in saved["bangumi_manage"] + # $VAR 引用原样保存,读取时才展开 + assert saved["plugins"]["instances"][0]["options"]["host"] == "$QB_HOST" + backup = v3_config.with_name("config.json.v3.bak") + assert backup.read_bytes() == FIXTURE.read_bytes() + + +def test_migrate_v3_config_second_run_noop(v3_config): + assert migrate_v3_config(v3_config) is True + backup = v3_config.with_name("config.json.v3.bak") + migrated, backed_up = v3_config.read_bytes(), backup.read_bytes() + + assert migrate_v3_config(v3_config) is False + assert v3_config.read_bytes() == migrated + assert backup.read_bytes() == backed_up == FIXTURE.read_bytes() + + +def test_migrate_v4_config_untouched_without_backup(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"program": {"rss_time": 600}})) + + assert migrate_v3_config(path) is False + assert not path.with_name("config.json.v3.bak").exists() + + +@pytest.mark.parametrize( + ("section", "key", "value", "field"), + [ + ("downloader", "ssl", "sometimes", "ssl"), + ("bangumi_manage", "rename_method", 5, "plugins.slots.rename_strategy"), + ], +) +def test_migrate_v3_config_invalid_field_restores_and_refuses( + v3_config, section, key, value, field +): + config = json.loads(v3_config.read_text()) + config[section][key] = value + original = json.dumps(config, indent=4) + v3_config.write_text(original) + + with pytest.raises(ConfigMigrationError, match=field): + migrate_v3_config(v3_config) + + assert v3_config.read_text() == original + assert v3_config.with_name("config.json.v3.bak").read_text() == original + assert not v3_config.with_name("config.json.tmp").exists() + + +@pytest.mark.parametrize( + ("method", "expected"), + [("normal", "none"), ("none", "none"), ("pn", "pn"), ("template", "template")], +) +def test_migrate_v3_dict_rename_method_moves_to_slot(method, expected): + config: dict[str, Any] = {"bangumi_manage": {"rename_method": method}} + + assert migrate_v3_dict(config) == ["bangumi_manage.rename_method"] + assert config["plugins"]["slots"] == {"rename_strategy": expected} + assert config["bangumi_manage"] == {} + + +def test_migrate_v3_dict_downloader_merges_into_existing_default_instance(): + config: dict[str, Any] = { + "downloader": {"host": "qb:8080"}, + "plugins": { + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "aria2", + "options": {"host": "old", "path": "/dl"}, + } + ] + }, + } + + migrate_v3_dict(config) + + assert config["plugins"]["instances"] == [ + { + "id": "default", + "point": "downloader", + "provider": "aria2", + "options": {"host": "qb:8080", "path": "/dl"}, + } + ] + assert config["plugins"]["slots"] == {"downloader": "default"} + + +def test_migrate_v3_config_existing_backup_kept_new_backup_beside_it(v3_config): + """降级回 3.3 再升级:第一次迁移留下的备份(含真实凭据)不能被覆盖。""" + first = v3_config.with_name("config.json.v3.bak") + first.write_text("original 3.3 config") + + assert migrate_v3_config(v3_config) is True + + assert first.read_text() == "original 3.3 config" + second = v3_config.with_name("config.json.v3.bak.1") + assert second.read_bytes() == FIXTURE.read_bytes() diff --git a/docs/config/downloader.md b/docs/config/downloader.md index d9d0a9f8..4222f7bc 100644 --- a/docs/config/downloader.md +++ b/docs/config/downloader.md @@ -14,6 +14,17 @@ 修改下载器设置后,需要点击底部的 **保存并重启**。保存后下载器连接会重新建立。 +## 多个下载器 + +设置页顶部列出所有下载器实例。点击一个实例即可在下方编辑它;输入新的 id 后点击 **添加** 新建实例,类型可以选 qBittorrent、aria2 或插件提供的下载器。**设为默认** 改变默认实例,默认实例不能删除。 + +- 规则编辑(高级选项)与添加订阅中可以选择下载器,留空跟随默认实例。只有一个下载器时不显示该选项。 +- 新种子投递到规则选择的下载器;规则未选择时用订阅选择的,再退回默认实例。由订阅新建的规则继承订阅的选择。 +- 每个种子记录它被添加到的下载器,之后的重命名、删除都在该下载器上进行。规则改用另一个下载器后,已有的种子留在原下载器的原位置,不会迁移。 +- 重命名按下载器逐个进行。某个下载器连不上时跳过它,并在它第一次连不上时发送「下载器连接异常」通知,其它下载器照常处理。 +- 下载器页与种子列表在有多个下载器时标出每个种子所在的下载器。 +- 删除下载器后,选择它的规则与订阅改用默认实例,其中已有的种子不受影响。 + ## 常见问题 ### 下载器地址 @@ -43,11 +54,27 @@ AB 中的下载路径用于生成保存位置和后续整理路径。请填写** ## `config.json` 配置选项 -配置节:`downloader` +下载器是 `plugins.instances` 中 `point` 为 `downloader` 的实例,`plugins.slots.downloader` 是默认实例的 id(默认为 `default`)。在设置页新建实例时,id 只能包含字母、数字、`_` 与 `-`: + +```json +"plugins": { + "slots": { "downloader": "default" }, + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "qbittorrent", + "options": { "host": "172.17.0.1:8080", "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", "ssl": false } + } + ] +} +``` + +3.3 的 `downloader` 配置节在升级到 4.0 后第一次启动时自动迁移到 `default` 实例,原文件备份为 `config.json.v3.bak`。环境变量 `AB_DOWNLOADER_HOST`、`AB_DOWNLOADER_USERNAME`、`AB_DOWNLOADER_PASSWORD`、`AB_DOWNLOAD_PATH` 照常生效,写入默认实例。 | 参数 | 说明 | 类型 | WebUI 选项 | 默认值 | | --- | --- | --- | --- | --- | -| `type` | 下载器类型 | 字符串 | 下载器类型 | `qbittorrent` | +| `provider` | 下载器类型 | 字符串 | 下载器类型 | `qbittorrent` | | `host` | 下载器地址 | 字符串 | 下载器地址 | `172.17.0.1:8080` | | `username` | 下载器用户名 | 字符串 | 用户名 | `admin` | | `password` | 下载器密码或 aria2 RPC secret | 字符串 | 密码 | `adminadmin` | diff --git a/docs/config/manager.md b/docs/config/manager.md index 8206cc25..b92db477 100644 --- a/docs/config/manager.md +++ b/docs/config/manager.md @@ -26,11 +26,12 @@ | --- | --- | --- | --- | --- | | `enable` | 启用番剧管理器 | 布尔值 | 启用 | `true` | | `eps_complete` | 启用剧集补全 | 布尔值 | 番剧补全 | `false` | -| `rename_method` | 重命名方式 | 字符串 | 重命名方式 | `pn` | | `group_tag` | 添加字幕组标签 | 布尔值 | 添加组标签 | `false` | | `remove_bad_torrent` | 删除错误种子 | 布尔值 | 删除坏种 | `false` | | `track_orphans` | 记录未匹配种子 | 布尔值 | 记录未匹配种子 | `true` | +重命名方式与版本冲突策略是 Provider 选择,保存在 `plugins.slots`:`rename_strategy`(重命名方式,默认 `pn`)与 `conflict_policy`(`hold` / `replace`,默认 `hold`)。3.3 的 `bangumi_manage.rename_method` 与 `revision_conflict_policy` 在升级到 4.0 后第一次启动时自动迁移。 + ## 模板重命名 选择 `template` 后,文件名由内置插件 `rename` 的模板生成。在 **设置 → 插件 → 重命名** 中修改「文件名模板」,默认值为: diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 57137f13..e1f92105 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -307,7 +307,7 @@ class MyPlugin(Plugin): ### 重命名方式(rename_strategy) -下载完成后,AB 为种子里的每个正片与字幕调用设置项「重命名方式」(`bangumi_manage.rename_method`)对应的 Provider。宿主只自带 `none`(保留原名);`pn`、`advance` 与 `template` 由内置插件「重命名」(`rename`,默认启用)提供。插件登记的 id 会出现在 设置 → 番剧管理设置 → 重命名方式 的下拉框里。 +下载完成后,AB 为种子里的每个正片与字幕调用设置项「重命名方式」(`plugins.slots.rename_strategy`)对应的 Provider。宿主只自带 `none`(保留原名);`pn`、`advance` 与 `template` 由内置插件「重命名」(`rename`,默认启用)提供。插件登记的 id 会出现在 设置 → 番剧管理设置 → 重命名方式 的下拉框里。 ```python from ab_sdk import Plugin, points, provider @@ -344,9 +344,9 @@ class MyRename(Plugin): ### 文件分类与版本冲突(media_files / conflict_policy) - `media_files`:`classify(path) -> "media" | "subtitle" | "ignore"`,决定种子内哪些文件按正片、字幕重命名。宿主实现按扩展名判断(`.mp4` / `.mkv` 为正片,`.ass` / `.srt` 为字幕)。 -- `conflict_policy`:`decide(ConflictRequest) -> ConflictDecision("hold" | "replace")`,新种子的规范文件名已被另一个种子占用时决定保留旧的还是替换。宿主实现沿用设置项「版本冲突策略」。宿主只在「唯一占用者、双方都是单文件种子、双方解析身份完整」时执行替换,其它情况一律按 `hold` 处理。 +- `conflict_policy`:`decide(ConflictRequest) -> ConflictDecision("hold" | "replace")`,新种子的规范文件名已被另一个种子占用时决定保留旧的还是替换。宿主自带 `hold` 与 `replace` 两个实现,即设置项「版本冲突策略」的两个选项。宿主只在「唯一占用者、双方都是单文件种子、双方解析身份完整」时执行替换,其它情况一律按 `hold` 处理。 -这两个扩展点目前只使用宿主实现(id 为 `default`)。插件可以登记自己的实现,但要等多下载器版本的 `plugins.slots` 提供后才能被选用。 +两者按 `plugins.slots.media_files`(默认 `default`)与 `plugins.slots.conflict_policy`(默认 `hold`)选择 Provider id。选中的 id 未登记(插件停用或被熔断)时,退回宿主的 `default` 与 `hold`。 ### 整理事件(file.renamed / torrent.organized) @@ -356,7 +356,7 @@ class MyRename(Plugin): | `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` 元组)、`downloader_id` | 一个种子整理完成;重命名方式为 `none` 时同样发布,`files` 为原路径 | - 路径是**下载器视角**的绝对路径,保存目录与种子内路径以 `/` 拼接(Windows 下载器的 `\` 也统一为 `/`)。AB 与下载器看到的目录不同(如分别运行在不同容器)时,订阅者需要自己做路径映射。 -- `bangumi_id` 来自种子的 `ab:` 标签,旧种子可能为 None。`downloader_id` 目前固定为 `"default"`,多下载器版本会给出实例 id。 +- `bangumi_id` 来自种子的 `ab:` 标签,旧种子可能为 None。`downloader_id` 是种子所在下载器实例的 id(`plugins.instances`),多个实例的路径视角可能不同。 - `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次,订阅者必须幂等。 - 这两个事件只发布到事件总线,不进入通知中心。 @@ -467,7 +467,7 @@ AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent` | `rss_failure` | `RssFailureEvent` | RSS 订阅从正常变为连接异常 | | `download_failure` | `DownloadFailureEvent` | 种子重试后仍添加失败 | | `offset_review` | `OffsetReviewEvent` | 番剧的季度 / 集数偏移需要人工确认 | -| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封 | +| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封;`instance_id` 为下载器实例 id,每个实例从可用变为不可用时发布一次 | | `update_available` | `UpdateAvailableEvent` | 检查到新版本 | | `update_applied` / `update_failed` | `UpdateAppliedEvent` | 在线更新成功 / 失败(`kind` 随 `success` 变化) | | `llm_auth_failure` | `LLMAuthFailureEvent` | 订阅类 LLM 提供商凭据失效 | diff --git a/docs/en/config/downloader.md b/docs/en/config/downloader.md index 883eca31..25fb3d39 100644 --- a/docs/en/config/downloader.md +++ b/docs/en/config/downloader.md @@ -14,6 +14,17 @@ After changing downloader settings, click **Save & restart** so the downloader client is recreated. +## Multiple downloaders + +The top of the settings section lists every downloader instance. Click an instance to edit it below. Type a new id and click **Add** to create an instance; its type can be qBittorrent, aria2 or a downloader from a plugin. **Set default** changes the default instance. You cannot delete the default instance. + +- The rule editor (advanced options) and the add-subscription dialog have a downloader field. Leave it empty to use the default instance. The field is hidden when there is only one downloader. +- A new torrent goes to the downloader of its rule. If the rule has none, it goes to the downloader of the subscription, then to the default instance. A rule created from a subscription takes the downloader of the subscription. +- Each torrent records the downloader it was added to. Rename and delete use that downloader. When a rule moves to another downloader, its existing torrents stay where they are. +- Rename runs once per downloader. An unreachable downloader is skipped, and a "Downloader unavailable" notification is sent the first time it becomes unreachable. The other downloaders continue. +- With more than one downloader, the downloader page and the torrent lists show the downloader of each torrent. +- After you delete a downloader, rules and subscriptions that used it use the default instance. Torrents already in the deleted downloader are not touched. + ## Downloader Address ::: warning @@ -35,11 +46,27 @@ Use the path from the downloader's point of view: ## `config.json` -Section: `downloader` +A downloader is an entry in `plugins.instances` whose `point` is `downloader`. `plugins.slots.downloader` is the id of the default instance (`default` by default). When you add an instance on the settings page, its id can contain only letters, digits, `_` and `-`: + +```json +"plugins": { + "slots": { "downloader": "default" }, + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "qbittorrent", + "options": { "host": "172.17.0.1:8080", "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", "ssl": false } + } + ] +} +``` + +On the first start after an upgrade to 4.0, the 3.3 `downloader` section moves to the `default` instance automatically. The original file is kept as `config.json.v3.bak`. The environment variables `AB_DOWNLOADER_HOST`, `AB_DOWNLOADER_USERNAME`, `AB_DOWNLOADER_PASSWORD` and `AB_DOWNLOAD_PATH` still work and set the default instance. | Key | Description | Type | WebUI field | Default | | --- | --- | --- | --- | --- | -| `type` | Downloader type | string | Downloader Type | `qbittorrent` | +| `provider` | Downloader type | string | Downloader Type | `qbittorrent` | | `host` | Downloader address | string | Host | `172.17.0.1:8080` | | `username` | Downloader username | string | Username | `admin` | | `password` | Downloader password or aria2 RPC secret | string | Password | `adminadmin` | diff --git a/docs/en/config/manager.md b/docs/en/config/manager.md index 4f9ceba8..1129d9c7 100644 --- a/docs/en/config/manager.md +++ b/docs/en/config/manager.md @@ -84,7 +84,8 @@ Section: `bangumi_manage` | --- | --- | --- | --- | --- | | `enable` | Enable manager | boolean | Enable | `true` | | `eps_complete` | Enable episode completion | boolean | EPS complete | `false` | -| `rename_method` | Rename method | string | Rename Method | `pn` | | `group_tag` | Add subgroup tags | boolean | Add Group Tag | `false` | | `remove_bad_torrent` | Delete errored torrents | boolean | Delete Bad Torrent | `false` | | `track_orphans` | Track unmatched torrents | boolean | Track Unmatched Torrents | `true` | + +The rename method and the revision conflict policy are provider choices. They are stored in `plugins.slots`: `rename_strategy` (rename method, default `pn`) and `conflict_policy` (`hold` / `replace`, default `hold`). On the first start after an upgrade to 4.0, the 3.3 keys `bangumi_manage.rename_method` and `revision_conflict_policy` move there automatically. diff --git a/docs/ja/config/downloader.md b/docs/ja/config/downloader.md index 66887453..bacc91db 100644 --- a/docs/ja/config/downloader.md +++ b/docs/ja/config/downloader.md @@ -14,6 +14,17 @@ 変更後は **保存して再起動** をクリックしてください。 +## 複数のダウンローダー + +設定セクションの上部にすべてのダウンローダーインスタンスが表示されます。インスタンスをクリックすると下で編集できます。新しい id を入力して **追加** をクリックするとインスタンスを作成できます。種類は qBittorrent、aria2、またはプラグインのダウンローダーです。**既定に設定** で既定インスタンスを変更します。既定インスタンスは削除できません。 + +- ルール編集(詳細設定)と購読の追加でダウンローダーを選べます。空欄なら既定インスタンスを使います。ダウンローダーが 1 つだけのときは表示されません。 +- 新しいトレントはルールのダウンローダーに追加されます。ルールに指定がなければ購読のダウンローダー、それもなければ既定インスタンスです。購読から作成されたルールは購読の選択を引き継ぎます。 +- 各トレントは追加先のダウンローダーを記録し、リネームと削除はそのダウンローダーで行います。ルールを別のダウンローダーに変えても、既存のトレントは元の場所に残ります。 +- リネームはダウンローダーごとに実行されます。接続できないダウンローダーはスキップされ、初めて接続できなくなったときに「ダウンローダー接続エラー」が通知されます。他のダウンローダーは通常どおり処理されます。 +- ダウンローダーが複数あるとき、ダウンローダーページとトレント一覧に各トレントのダウンローダーが表示されます。 +- ダウンローダーを削除すると、それを選んでいたルールと購読は既定インスタンスを使います。削除したダウンローダー内の既存のトレントには影響しません。 + ## アドレスの注意 ::: warning @@ -27,11 +38,27 @@ Docker Bridgeモードでは、ダウンローダーとAutoBangumiが同じネ ## `config.json` -セクション:`downloader` +ダウンローダーは `plugins.instances` のうち `point` が `downloader` のインスタンスです。`plugins.slots.downloader` は既定インスタンスの id(既定値 `default`)です。設定画面でインスタンスを追加するとき、id に使えるのは英数字、`_`、`-` だけです。 + +```json +"plugins": { + "slots": { "downloader": "default" }, + "instances": [ + { + "id": "default", + "point": "downloader", + "provider": "qbittorrent", + "options": { "host": "172.17.0.1:8080", "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", "ssl": false } + } + ] +} +``` + +3.3 の `downloader` セクションは、4.0 へのアップグレード後の初回起動時に `default` インスタンスへ自動で移行され、元のファイルは `config.json.v3.bak` として保存されます。環境変数 `AB_DOWNLOADER_HOST`、`AB_DOWNLOADER_USERNAME`、`AB_DOWNLOADER_PASSWORD`、`AB_DOWNLOAD_PATH` は引き続き既定インスタンスに反映されます。 | キー | 説明 | 型 | WebUI項目 | 既定値 | | --- | --- | --- | --- | --- | -| `type` | ダウンローダー種類 | 文字列 | ダウンローダー種類 | `qbittorrent` | +| `provider` | ダウンローダー種類 | 文字列 | ダウンローダー種類 | `qbittorrent` | | `host` | ダウンローダーアドレス | 文字列 | ホスト | `172.17.0.1:8080` | | `username` | ユーザー名 | 文字列 | ユーザー名 | `admin` | | `password` | パスワードまたはaria2 RPC secret | 文字列 | パスワード | `adminadmin` | diff --git a/docs/ja/config/manager.md b/docs/ja/config/manager.md index fe15da91..b58dce1e 100644 --- a/docs/ja/config/manager.md +++ b/docs/ja/config/manager.md @@ -82,7 +82,8 @@ | --- | --- | --- | --- | --- | | `enable` | 管理機能を有効化 | 真偽値 | 有効化 | `true` | | `eps_complete` | 話数補完を有効化 | 真偽値 | 話数補完 | `false` | -| `rename_method` | リネーム方式 | 文字列 | リネーム方式 | `pn` | | `group_tag` | グループタグ追加 | 真偽値 | グループタグ追加 | `false` | | `remove_bad_torrent` | エラーTorrent削除 | 真偽値 | 不良Torrent削除 | `false` | | `track_orphans` | 未一致Torrentを記録 | 真偽値 | 未一致Torrentを記録 | `true` | + +リネーム方式とリビジョン競合ポリシーは Provider の選択として `plugins.slots` に保存されます:`rename_strategy`(リネーム方式、既定値 `pn`)と `conflict_policy`(`hold` / `replace`、既定値 `hold`)。3.3 の `bangumi_manage.rename_method` と `revision_conflict_policy` は、4.0 へのアップグレード後の初回起動時に自動で移行されます。 diff --git a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md index 18a65b82..4ffaa3f5 100644 --- a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md +++ b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md @@ -97,6 +97,70 @@ - **用户文档**:`docs/{,en/,ja/}config/manager.md` 增加 `template`、`hardlink`(含 `path_map` 与 Docker 下硬链接不能跨文件系统的说明)与 `media-server-refresh` 三节,按本分支的设计重写;`CHANGELOG.md` 增加 P4 条目。 - 移植时发现并修复:vulture 白名单缺少 `rename` / `hardlink` 内置插件的入口(CI 的 vulture 检查会失败);VitePress 不给行内代码加 `v-pre`,文档里行内代码中的 Jinja2 示例在构建时报 `_ctx.pad is not a function`,现在用 `::: v-pre` 容器包住(含本设计文档第 3.6 节与插件开发文档)。 +### 实施中的调整(P2.5) + +配置模型(第 4.4 节): + +- **`downloader` 配置节移出运行时模型**。下载器是 `plugins.instances` 中的 `PluginInstance`(`id`、`point`、`provider`、`options`),第 4.4 节草图中的 `plugin` 字段改为 `point` + `provider`,`default?` 标记改为由 `slots.downloader` 指定默认实例。新配置默认带一个 qB 实例 `default`,默认值与 3.3 相同。 +- **`plugins.slots` 是带默认值的类型化模型**(`downloader="default"`、`rename_strategy="pn"`、`conflict_policy="hold"`、`media_files="default"`),不是任意 `dict`,也不接受其它键。原因是 WebUI 需要固定的键来绑定,新安装也要有完整的值。`title_parser` / `metadata_provider` 链等到对应扩展点实施时再加。 +- **`plugins` 段的校验**:实例 id 唯一;下载器实例的 `options` 按 `DownloaderOptions`(host / username / password / path / ssl,`$VAR` 展开规则不变)校验;`slots.downloader` 必须指向一个下载器实例。PATCH /config 的请求体不符合时返回 422。 +- **`Config.downloader` 保留为只读属性**,返回 `slots.downloader` 所指实例的冻结视图 `DownloaderInstance`(`type` 即 `provider`);`Config.downloader_instance(id)` 按 id 取实例。它不参与序列化,也不接受输入。第一部分的调用方(下载门面、`path.py`、checker、`revision_saga._downloader_type`、`AppContext`)因此不变,`_downloader_type` 的取值也不变(存量 `rename_operation` 行依赖它)。按实例路由在 P2.5 第二部分。 +- **秘密字段**:`DownloaderOptions.password` 带 `secret` 标记(与 `secret_field()` 相同;直接写 `Field`,因为 mypy 的 pydantic 插件看不到经 `**kwargs` 传入的别名)。GET / PATCH /config 没有另加按 schema 的实例掩码:现有的按键名掩码已覆盖 `password`,列表项的掩码还原按身份匹配,身份包含实例 `id`。对下载器来说按 schema 掩码的结果完全相同。插件下载器有了自己的 options schema 后再加。 + +slots 解析: + +- `rename_strategy` 读 `slots.rename_strategy`,未登记时仍记录一次日志并按 `none` 处理。`media_files` 读 `slots.media_files`,未登记时退回 `default`。`conflict_policy` 读 `slots.conflict_policy`,未登记时退回 `hold`,不会误删旧版本。后两者不记日志,因为每个文件都会解析一次。 +- **冲突策略改为两个 Provider**:第 9 节把 `revision_conflict_policy` 迁到 `slots.conflict_policy`,slot 的值就是 Provider id。因此宿主以 `core` 登记 `hold` 与 `replace` 两个 `CoreConflictPolicy`,不再登记 `default`;`ConflictRequest` 删除 P4 加入的 `configured` 字段。`ab_sdk` 升至 0.5.0。`GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选。 + +迁移器(第 9 节,`module/update/v4.py`): + +- 按源字段是否存在触发:`downloader`、`bangumi_manage.rename_method`、`bangumi_manage.revision_conflict_policy`。字段是「移动」而不是复制,第二次运行看不到源字段,什么也不做,也不会用 4.0 格式的文件覆盖 `.v3.bak`。下载器字段合并到已有的 `default` 实例上,没有时新建。 +- `normal → none` 并入 `rename_method → slots.rename_strategy` 的迁移,`Settings._migrate_old_config` 中的对应分支随之删除(第 8.3 节)。掩码哨兵清洗保留在 `_migrate_old_config`。 +- 只有需要迁移时才备份。新文件先写到 `<文件名>.tmp` 再 `os.replace`。迁移后的字典先用 `Config.model_validate` 校验;任何失败都从备份恢复原文件,记录 `critical` 日志并抛出 `ConfigMigrationError`。日志与异常消息写明出错字段(取 pydantic 错误的 `loc`),因为此时 `setup_logger` 还没有运行。配置文件本身不是合法 JSON 时由 `json` 直接报错,与之前相同。 +- **接线**:迁移器在 `Settings.__init__` 中、`load()` / `save()` 之前调用,所以 `import module.conf` 失败即拒绝启动。`module/update/__init__.py` 原先在包初始化时 import 依赖 `module.conf` 的子模块,`module.conf` 无法 import `module.update.v4`。现在包初始化不 import 任何子模块,6 处调用方改为直接 import 子模块。没有用模块级 `__getattr__` 懒加载,因为 mypy 会把这些名字推断为 `Any`。 +- **版本闸门不动**,仍在 `AppContext.startup` 中。P0 发现的「`Settings()` 在版本闸门之前改写 config.json」由备份解决:低于 3.3 的配置仍会先被迁移、再被闸门拒绝,原文件保存在 `config.json.v3.bak`,退回 3.3 时要用它恢复。`Settings` 加载后仍立即保存一次,与之前相同。 +- **环境变量**:`ENV_TO_ATTR` 不变,仍按 3.3 的位置写入(`AB_DOWNLOADER_*` / `AB_DOWNLOAD_PATH` → `downloader`,`AB_METHOD` / `AB_REVISION_CONFLICT_POLICY` → `bangumi_manage`),再由同一个 `migrate_v3_dict` 移到默认实例与 slots。设置向导(`/setup/complete`)把下载器写入 `slots.downloader` 所指的实例。 + +数据库与 WebUI: + +- 迁移 v26 为 `bangumi`、`movie`、`rssitem`、`torrent` 增加 `downloader_id`。每张表一个守卫:列已存在,或表不存在(之后由 `create_all` 按模型建表,自带该列)时跳过。取值见下文第二部分(规则、订阅与电影为空表示跟随默认实例,种子默认 `default`)。 +- WebUI:「下载器设置」编辑 `slots.downloader` 所指的实例(`provider` 与 `options`),「番剧管理设置」中的重命名方式与版本冲突策略绑定到 `plugins.slots`。两个分区的未保存标记都以 `plugins` 配置段判断,所以修改其中一个,两个分区都会显示未保存。插件卡片保存后 `refreshGroup('plugins')` 会用服务端的值覆盖整个 `plugins` 段,包括尚未保存的下载器与 slots 修改(之前只影响插件 options)。 +- 用户文档 `docs/{,en/,ja/}config/{downloader,manager}.md` 与插件开发文档改为新的配置位置。 + +第二部分(路由、整理与 WebUI): + +- **`DownloadClient(instance_id=None)`**,`None` 为默认实例。客户端缓存与凭据闩锁按实例 id 分开;引用计数本来按具体客户端对象计,不变。第 3.5 节的统一工厂 `create(config: PluginConfig)` 未实施:工厂仍接收 P2 契约的 `DownloaderConnection`,实例 `options` 目前就是 `DownloaderOptions` 的字段。 +- **`DownloaderPool`**:一次操作(一轮 RSS、整季补全)内按实例 id 惰性进入 `DownloadClient`,退出时全部释放。进入失败的实例在这次操作内记住,之后直接抛 `ConnectionError`,不再重复登录。`RSSEngine.refresh_rss` 的参数由 `DownloadClient` 改为 `DownloaderPool`,测试改用 `test.factories.SingleClientPool`(27 处调用)。 +- **实例选择**:`resolve_downloader_id(*候选)` 取第一个已配置的实例 id,都为空时用 `slots.downloader`。指向已删除实例的候选记 warning 后跳过,因此删除实例后,选择它的规则与订阅改用默认实例。新种子按 规则 → 订阅 → 默认 选择;由订阅新建的规则在解析时继承订阅的 `downloader_id`(`rss/analyser.py`);手动收集与整季补全按 规则 → 默认。 +- **规则、订阅与电影的 `downloader_id` 为空表示跟随默认实例**。第一部分的 v26 给这三张表 `DEFAULT 'default'`,这样「规则 → 订阅」的继承永远走不到订阅,用户改默认实例后存量规则也仍钉在 `default`,与第 3.5 节「为空时用默认实例」矛盾。v26 尚未发布,所以直接修改它:这三张表加列不带默认值(存量为 `NULL`),`torrent` 仍为 `DEFAULT 'default'`(存量种子都在 3.3 的下载器中)。已经运行过第一部分 v26 的开发库需要手动把这三列置空。`BangumiUpdate`、`MovieUpdate`、`RSSUpdate` 加入该字段,`POST /rss/add` 保存它。 +- **种子行的 `downloader_id`** 由 `DownloadClient.add_torrent` 写入投递的实例;未匹配的孤儿种子保持 `default`。投递时实例不可用:种子不入库、下一轮重试,不发 `DownloadFailureEvent`(不可用由重命名轮次通知)。 +- **重命名逐实例运行**:`manager/renamer.py` 的 `rename_all()` 供 `loops.rename_tick` 与 apply-offset 使用。进入失败(连不上、凭据被拒、Provider 未登记)的实例跳过,其它实例照常处理;`DownloaderUnavailableEvent` 只在「可用 → 不可用」时产生一次(进程内集合),恢复后再次不可用会再通知。`Renamer` 自身抛出的异常仍中断本轮,与之前相同。apply-offset 触发的重命名丢弃事件(之前也丢弃);实例恰好在这一次变为不可用时,这次不可用不会通知。启动等待循环仍只检查默认实例。 +- **`DownloaderUnavailableEvent`** 增加 `instance_id`(默认 `"default"`),`dedup_key` 由 `downloader:` 改为 `downloader:`,payload 增加 `instance`。`ab_sdk` 仍为 0.5.0(本阶段未发布)。 +- **版本替换事务按实例过滤**:`list_active_replacements(downloader_type)` 只返回本实例的事务。否则实例 A 的轮次在 A 上查不到 B 的新种子,会进入破坏性的 `_recover_missing_replacement`。`_downloader_type()` 改读 `self.client.instance`。 +- **offset 查找**:同一 hash 有多条种子行时以本实例的行为准,其它实例的行作为后备。`path_to_bangumi` / `gen_save_path` 增加 `root`(实例的下载目录),重命名与新规则保存目录按实例计算。 +- `FileRenamed` / `TorrentOrganized` 带真实的 `downloader_id`;`hardlink` 的 `path_map` 本来就按它取映射,无需改动。 +- **规则换实例**:删除规则(删除文件)时按种子行记录的实例加上规则当前的实例逐个删除。更新规则时实例变了:旧种子不移动,原实例上的 qB RSS 规则不改,只按新实例的下载目录重算 `save_path`;实例不变且路径变化时才移动(路径不变时不再连接下载器)。 +- **API**:`GET /downloader/torrents` 汇总所有实例、每条带 `downloader_id`,不可用实例跳过,全部不可用时返回 503(原先连接异常直接 500)。暂停 / 恢复 / 删除 / 打标请求带 `downloader_id`(空为默认实例),未知 id 返回 404;自动打标逐实例进行。新增 `GET /downloader/instances`(`id`、`provider` 与默认 id),供规则 / 订阅选择下载器。SSE 的 downloader 帧同样汇总,超时按实例计算。MCP `list_downloads` 汇总所有实例并带 `downloader` 字段,全部不可用时返回空列表(原先抛错)。 +- **WebUI**:「下载器设置」列出所有实例(点击编辑、添加、删除、设为默认,默认实例不能删除;新 id 只接受字母、数字、`_`、`-`,这是前端限制)。规则编辑的高级选项与添加订阅新增下载器选择,留空为默认实例,只有一个实例时不显示;实例列表每次打开时请求,设置页增删实例后无需刷新。下载器页在多实例时加「下载器」列,规则 / 孤儿种子列表在已下载的种子上标出实例。批量操作仍按 hash 选择,按所在实例分组请求;同一 hash 同时在两个实例中时会作用于两个实例。 +- **`refreshGroup` 只刷新给定字段**:插件卡片保存后只刷新 `plugins.enabled` / `options`,不再覆盖未保存的实例与 slots 修改(第一部分记录的问题)。「下载器设置」与「番剧管理设置」仍共用 `plugins` 未保存标记。 + +审阅修正(覆盖上文相应条目): + +- **保存目录随实例重新生成**:`save_path_for(data, root)`(`downloader/path.py`)在已存的 `save_path` 位于该实例下载目录之下时沿用它,否则按该实例的下载目录重新生成(比较用 `PureWindowsPath`,`\` 与 `/` 都认)。`add_torrent` 每次投递都经过它并写回规则,所以默认实例切换、规则的实例被删除、订阅指向别的实例时,新种子都进目标实例的目录。代价:单实例用户修改下载目录后,存量规则的新种子也进新目录(3.3 沿用旧目录)。只在「属于另一个已配置实例」时重算做不到「实例被删除」的情况,所以按「不在本实例目录之下」判断。 +- **按实例匹配与移动**:`TorrentManager` 在每个实例上按两个目录匹配种子:已存的 `save_path`(与之前相同,单实例改过下载目录时仍能找到旧种子),以及 `save_path_for(规则, 该实例目录)`。更新规则且实例不变时,对种子行记录的每个实例(加上规则当前的实例)分别计算新目录并移动。qB RSS 规则只在有匹配种子的实例与规则自己的实例上改写,因为 `rss/setRule` 在别的 qB 上会新建一条启用的自动下载规则。这样「规则未指定实例、种子经订阅进了另一实例」时也会移动。规则自身的实例仍不带订阅后备,因为种子行已记录实际位置。 +- **删除逐实例隔离**:一个实例不可用或删除失败时,其它实例照常删除,最后返回 500 并列出失败的实例。删除规则时种子行已先删除,不可用实例上的种子之后没有记录(见第二部分结尾的推迟项)。 +- **整季补全与自动打标跳过不可用实例**:`eps_complete` 跳过该规则(不标记 `eps_collect`,下一轮重试),其它规则照常补全并保存;`POST /downloader/torrents/tag/auto` 改用 `DownloaderPool`,不可用实例跳过。 +- **版本替换事务的过滤只在多实例时生效**:只有一个下载器实例时 `list_active_replacements(None)` 返回全部事务,与 3.3 相同;否则改了主机地址(键里含主机哈希)后进行中的事务永远不会恢复。多实例下改主机地址仍有这个问题(见推迟项)。 +- **迁移备份不覆盖已有备份**:`.v3.bak` 已存在时依次取 `.v3.bak.1`、`.v3.bak.2` …,失败恢复与日志都用本次写的备份。降级回 3.3 再升级时,第一份备份里的真实下载器凭据因此保留。 + +未实施(推迟): + +- 第 10 节验收中的 qb + aria2 并存 **Docker e2e** 未加。以进程内测试替代:两个 mock 实例并列(按规则 / 订阅投递、在另一实例重命名、一个实例不可用、规则换实例后删除),以及 qB 与 aria2 实例各自得到对应后端与不同的 `downloader_type`。Docker 版需要在 `e2e/compose/downloader.yml` 加入固定 digest 的 aria2 镜像。 +- aria2 的 gid ↔ 番剧映射表(`database/aria2.py`)不区分实例;两个 aria2 实例的 gid 相同的概率很低,未处理。 +- `GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选;插件下载器的 options schema 与按 schema 掩码(见上文)。 +- 多实例时修改某个实例的主机地址,该实例上进行中的版本替换事务找不到(`_downloader_type` 键含主机哈希)。可选做法:键改为 `:` 并兼容旧键查询,或按「键不属于任何已配置实例」把孤儿事务交给同类型的唯一实例。 +- 删除规则(删除文件)时某个实例不可用:该实例上的种子不删,种子行却已删除,之后无法重试。可选做法:保留失败实例的种子行,或先删种子再删行(与「仅删数据库不依赖下载器」冲突)。 + ## 1. 背景与目标 AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。 @@ -643,7 +707,7 @@ organize: downloader.completed → media_files.classify → file_parser | **P0 清理** | 第 8 节:死代码、3.x 兼容层;`renamer.py` 先做纯搬移式拆分(不改行为) | 生产代码行数减少;vulture CI;测试全绿 | | **P1 插件运行时 + SDK 骨架** | `ab_sdk` 包(含 `ab_sdk.testing`)、`module/plugin/`(清单、加载、注册表、runner、熔断、EventBus、插件 KV)、`plugins` 配置段、`GET /api/v1/plugins`;SDK 边界测试 | 本地插件可加载、配置、随配置变更重载;已完成。签名目录来源与 LLM 安装器泛化、`dev_mode` 文件监听移到 P2 | | **P2 迁移已有注册表** | 下载器、通知、LLM、搜索站点、定时任务改为扩展点,内置实现以 `core` 登记;`/api/v1/plugins`(列表、启停、配置、Provider 列表)与 WebUI 插件卡片(JSON Schema 表单);`secret_field` 掩码;插件开发文档 | 已完成;内置行为不变(全量测试)。调整见第 0 节 | -| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 | +| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | 已完成:`plugins.instances` / `slots` 与 3.3 配置迁移器(`update/v4.py`);按实例缓存客户端与路由投递、重命名、删除;重命名逐实例运行,不可用实例跳过并通知;WebUI 多实例管理与规则 / 订阅的下载器选择;单实例行为不变(全量测试)。qb + aria2 Docker e2e 未加,以进程内并存测试替代,调整见第 0 节 | | **P3 流水线插件化:ingest** | `torrent.filter`、`title.parsed`、`torrent.adding`、`http.request` 钩子;`metadata_provider`(mikan / tmdb 以 `core` 登记);内置插件 `ingest-filters`(包含过滤) | 已完成;无插件时行为不变(全量测试)。`feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`ranker`、`save_path`、size 过滤、按订阅覆盖推迟,见第 0 节 | | **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 已完成:`renamer.py` 拆出 `revision_saga.py`;`rename_strategy` / `media_files` / `conflict_policy` 扩展点与 `file.renamed` / `torrent.organized` 事件;内置插件 `rename`(pn / advance / template,pn / advance / none 输出与 3.3 一致)、`hardlink`(默认停用)与 `media-server-refresh`。`file_parser`、`RenameStrategyContract`、补链设置按钮(P6)推迟,调整见第 0 节 | | **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 已完成:系统事件上总线、通知中心 SSE 改为事件推送、插件路由 / MCP 工具与资源 / 通知模板;status 等快照类 SSE 仍按节拍采样。调整见第 0 节 | diff --git a/webui/src/api/__tests__/downloader.test.ts b/webui/src/api/__tests__/downloader.test.ts index e8eb0422..1b80d37f 100644 --- a/webui/src/api/__tests__/downloader.test.ts +++ b/webui/src/api/__tests__/downloader.test.ts @@ -28,33 +28,36 @@ describe('Downloader API contract (path + HTTP method)', () => { it('should POST api/v1/downloader/torrents/pause with hashes when pausing', async () => { (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); - await apiDownloader.pause(['abc123', 'def456']); + await apiDownloader.pause(['abc123', 'def456'], 'default'); expect(axios.post).toHaveBeenCalledWith( 'api/v1/downloader/torrents/pause', { hashes: ['abc123', 'def456'], + downloader_id: 'default', } ); }); it('should POST api/v1/downloader/torrents/resume with hashes when resuming', async () => { (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); - await apiDownloader.resume(['abc123']); + await apiDownloader.resume(['abc123'], 'nas'); expect(axios.post).toHaveBeenCalledWith( 'api/v1/downloader/torrents/resume', { hashes: ['abc123'], + downloader_id: 'nas', } ); }); it('should POST api/v1/downloader/torrents/delete with hashes and delete_files when deleting', async () => { (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); - await apiDownloader.deleteTorrents(['abc123'], true); + await apiDownloader.deleteTorrents(['abc123'], 'default', true); expect(axios.post).toHaveBeenCalledWith( 'api/v1/downloader/torrents/delete', { hashes: ['abc123'], + downloader_id: 'default', delete_files: true, } ); @@ -62,11 +65,12 @@ describe('Downloader API contract (path + HTTP method)', () => { it('should default delete_files to false when deleting without the flag', async () => { (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); - await apiDownloader.deleteTorrents(['abc123']); + await apiDownloader.deleteTorrents(['abc123'], 'default'); expect(axios.post).toHaveBeenCalledWith( 'api/v1/downloader/torrents/delete', { hashes: ['abc123'], + downloader_id: 'default', delete_files: false, } ); diff --git a/webui/src/api/downloader.ts b/webui/src/api/downloader.ts index 3b6c479d..93f13083 100644 --- a/webui/src/api/downloader.ts +++ b/webui/src/api/downloader.ts @@ -1,4 +1,4 @@ -import type { QbTorrentInfo } from '#/downloader'; +import type { DownloaderInstances, QbTorrentInfo } from '#/downloader'; import type { ApiSuccess } from '#/api'; export const apiDownloader = { @@ -10,26 +10,37 @@ export const apiDownloader = { return data!; }, - async pause(hashes: string[]) { + async instances() { + const { data } = await axios.get( + 'api/v1/downloader/instances' + ); + return data!; + }, + + async pause(hashes: string[], downloaderId: string) { const { data } = await axios.post( 'api/v1/downloader/torrents/pause', - { hashes } + { hashes, downloader_id: downloaderId } ); return data!; }, - async resume(hashes: string[]) { + async resume(hashes: string[], downloaderId: string) { const { data } = await axios.post( 'api/v1/downloader/torrents/resume', - { hashes } + { hashes, downloader_id: downloaderId } ); return data!; }, - async deleteTorrents(hashes: string[], deleteFiles = false) { + async deleteTorrents( + hashes: string[], + downloaderId: string, + deleteFiles = false + ) { const { data } = await axios.post( 'api/v1/downloader/torrents/delete', - { hashes, delete_files: deleteFiles } + { hashes, downloader_id: downloaderId, delete_files: deleteFiles } ); return data!; }, diff --git a/webui/src/components/ab-add-rss.vue b/webui/src/components/ab-add-rss.vue index dc05bb9a..8402afd6 100644 --- a/webui/src/components/ab-add-rss.vue +++ b/webui/src/components/ab-add-rss.vue @@ -6,6 +6,7 @@ import type { RSS } from '#/rss'; import { rssTemplate } from '#/rss'; import { ruleTemplate } from '#/bangumi'; import { usePluginProviders } from '@/hooks/usePluginProviders'; +import { useDownloaderInstances } from '@/hooks/useDownloaderInstances'; /** v-model show */ const show = defineModel('show', { default: false }); @@ -19,6 +20,8 @@ const rule = defineModel('rule', { default: () => ({ ...ruleTemplate }), }); const pluginProviders = usePluginProviders(); +// 订阅的下载器实例:由它新建的规则继承;留空跟随默认实例 +const downloaders = useDownloaderInstances(show); // 内置元数据源 + 插件登记的元数据源(metadata_provider 扩展点) const parserTypes = computed(() => [ 'tmdb', @@ -206,6 +209,21 @@ function subscribe() { class="parser-select" /> + +
+ + +
diff --git a/webui/src/components/ab-edit-rule.vue b/webui/src/components/ab-edit-rule.vue index 7f4dbf49..2acc1ff9 100644 --- a/webui/src/components/ab-edit-rule.vue +++ b/webui/src/components/ab-edit-rule.vue @@ -2,6 +2,7 @@ import { NCheckbox, NSelect, NSpin, useMessage } from 'naive-ui'; import { onKeyStroke } from '@vueuse/core'; import type { BangumiRule, DetectOffsetResponse } from '#/bangumi'; +import { useDownloaderInstances } from '@/hooks/useDownloaderInstances'; const emit = defineEmits<{ (e: 'apply', rule: BangumiRule): void; @@ -91,6 +92,9 @@ const resolutionOptions = ['2160p', '1080p', '720p'].map((r) => ({ value: r, })); +// 下载器实例:留空跟随默认实例;只有一个实例时不显示 +const downloaders = useDownloaderInstances(show); + const selectMenuProps = { role: 'listbox' } as const; function selectOptionNodeProps() { @@ -334,6 +338,29 @@ function emitUnarchive() { /> +
+ + +
+