From e442d35c76b9876940799a3b71a88e0920becbbc Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 12:07:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(4.0-P2.5):=20=E4=B8=8B=E8=BD=BD=E5=99=A8?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20plugins.instances=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=EF=BC=8Cslots=20=E9=80=89=E6=8B=A9=20Provider=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=203.3=20=E9=85=8D=E7=BD=AE=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行时模型移除 downloader 配置节:下载器是 plugins.instances 中的实例(id / point / provider / options),plugins.slots.downloader 指向默认实例;Config.downloader 改为只读视图 - 重命名方式、文件分类、版本冲突策略按 plugins.slots 选择,未登记时退回 none / default / hold;冲突策略改为 core 的 hold / replace 两个 Provider,ConflictRequest 删除 configured,ab_sdk 升至 0.5.0 - module/update/v4.py:迁移 downloader、rename_method(normal 改为 none)、revision_conflict_policy;先备份为 config.json.v3.bak,失败时恢复备份并拒绝启动;在 Settings 读写配置前运行 - module.update 包初始化不再 import 子模块,避免 module.conf 引用迁移器时循环 import - AB_DOWNLOADER_* 等环境变量与设置向导写入默认实例 - 数据库迁移 v26:bangumi / movie / rssitem / torrent 增加 downloader_id,存量行为 default - WebUI:下载器设置编辑默认实例,重命名方式与版本冲突策略绑定 plugins.slots --- backend/src/ab_sdk/__init__.py | 2 +- backend/src/ab_sdk/points.py | 2 +- backend/src/ab_sdk/rename.py | 12 +- backend/src/module/api/events.py | 2 +- backend/src/module/api/setup.py | 9 +- backend/src/module/api/update.py | 2 +- backend/src/module/checker/checker.py | 2 +- backend/src/module/conf/config.py | 26 ++-- backend/src/module/core/context.py | 8 +- backend/src/module/core/loops.py | 2 +- backend/src/module/database/migrations.py | 31 ++++ backend/src/module/downloader/path.py | 7 +- backend/src/module/manager/renamer.py | 15 +- backend/src/module/manager/revision_policy.py | 11 +- backend/src/module/models/bangumi.py | 3 + backend/src/module/models/config.py | 122 ++++++++++++++-- backend/src/module/models/movie.py | 3 + backend/src/module/models/rss.py | 1 + backend/src/module/models/torrent.py | 2 + backend/src/module/plugin/host.py | 11 +- backend/src/module/update/__init__.py | 12 +- backend/src/module/update/v4.py | 128 +++++++++++++++++ .../downloader/test_release_image_smoke.py | 2 +- backend/src/test/fixtures/config_v3.3.json | 99 +++++++++++++ backend/src/test/test_api_config.py | 66 +++++---- backend/src/test/test_config.py | 120 +++++++++++----- backend/src/test/test_context.py | 2 +- backend/src/test/test_integration.py | 4 +- backend/src/test/test_migrations_module.py | 31 ++++ backend/src/test/test_organize.py | 6 +- backend/src/test/test_plugin_extensions.py | 16 ++- backend/src/test/test_renamer.py | 46 +++--- backend/src/test/test_setup.py | 35 ++++- backend/src/test/test_update_v4.py | 135 ++++++++++++++++++ .../setting/__tests__/config-manage.test.ts | 14 +- .../components/setting/config-download.vue | 32 +++-- .../src/components/setting/config-manage.vue | 30 ++-- webui/src/pages/index/config.vue | 4 +- webui/src/pages/index/downloader.vue | 4 +- webui/src/test/mocks/api.ts | 36 +++-- webui/src/utils/__tests__/config-diff.test.ts | 4 +- webui/types/config.ts | 53 ++++--- 42 files changed, 914 insertions(+), 238 deletions(-) create mode 100644 backend/src/module/update/v4.py create mode 100644 backend/src/test/fixtures/config_v3.3.json create mode 100644 backend/src/test/test_update_v4.py 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/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/events.py b/backend/src/module/api/events.py index 4d84aa25..ca00bbbc 100644 --- a/backend/src/module/api/events.py +++ b/backend/src/module/api/events.py @@ -26,7 +26,7 @@ from module.downloader import DownloadClient 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__) 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..3f474c67 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, diff --git a/backend/src/module/core/loops.py b/backend/src/module/core/loops.py index 87062c02..261fce9d 100644 --- a/backend/src/module/core/loops.py +++ b/backend/src/module/core/loops.py @@ -13,7 +13,7 @@ from module.downloader import DownloadClient from module.manager import Renamer, TorrentManager, eps_complete 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 diff --git a/backend/src/module/database/migrations.py b/backend/src/module/database/migrations.py index 322c3db8..f7ea4687 100644 --- a/backend/src/module/database/migrations.py +++ b/backend/src/module/database/migrations.py @@ -100,6 +100,20 @@ class Migration: return self.statements +# 存量行归属默认下载器实例(plugins.instances 中 id 为 default 的实例) +_DOWNLOADER_ID_TABLES = ("bangumi", "movie", "rssitem", "torrent") + + +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 +813,23 @@ 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 downloader_id VARCHAR DEFAULT 'default'" + for table in _DOWNLOADER_ID_TABLES + ), + all_checks(*(_has_downloader_id(table) for table in _DOWNLOADER_ID_TABLES)), + tuple( + ( + f"ALTER TABLE {table} ADD COLUMN downloader_id VARCHAR " + "DEFAULT 'default'", + _has_downloader_id(table), + ) + for table in _DOWNLOADER_ID_TABLES + ), + ), ) # 由迁移列表派生,新增迁移时无需手动同步 diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index a3c4b6f9..13dfa703 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: diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index dd746b1f..1dff7295 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, @@ -58,7 +57,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 +499,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 @@ -632,11 +630,10 @@ class Renamer(RevisionSaga): @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] @@ -749,7 +746,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). 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/models/bangumi.py b/backend/src/module/models/bangumi.py index c70096a8..33c48815 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -61,6 +61,9 @@ class Bangumi(SQLModel, table=True): episode_type: str = Field( default="episode", alias="episode_type", title="剧集类型" ) # "episode" | "movie" | "special" + downloader_id: str = Field( + default="default", alias="downloader_id", title="下载器实例" + ) class BangumiUpdate(SQLModel): diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index 823fc7c7..cc658012 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,28 @@ 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 + ) + 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..2eef8bef 100644 --- a/backend/src/module/models/movie.py +++ b/backend/src/module/models/movie.py @@ -25,6 +25,9 @@ class Movie(SQLModel, table=True): save_path: str | None = Field(default=None, alias="save_path", title="保存路径") rule_name: str | None = Field(default=None, alias="rule_name", title="规则名") filter: str = Field(default="", alias="filter", title="过滤器") + downloader_id: str = Field( + default="default", alias="downloader_id", title="下载器实例" + ) class MovieUpdate(SQLModel): diff --git a/backend/src/module/models/rss.py b/backend/src/module/models/rss.py index 8ea51ae8..739f25ef 100644 --- a/backend/src/module/models/rss.py +++ b/backend/src/module/models/rss.py @@ -13,6 +13,7 @@ class RSSItem(SQLModel, table=True): connection_status: Optional[str] = Field(None, alias="connection_status") last_checked_at: Optional[str] = Field(None, alias="last_checked_at") last_error: Optional[str] = Field(None, alias="last_error") + downloader_id: str = Field("default", alias="downloader_id") class RSSUpdate(SQLModel): 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/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..ba484ce7 --- /dev/null +++ b/backend/src/module/update/v4.py @@ -0,0 +1,128 @@ +"""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 + backup = path.with_name(path.name + ".v3.bak") + 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/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_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_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..7a54eb19 100644 --- a/backend/src/test/test_integration.py +++ b/backend/src/test/test_integration.py @@ -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_migrations_module.py b/backend/src/test/test_migrations_module.py index 893e2317..acd4bc74 100644 --- a/backend/src/test/test_migrations_module.py +++ b/backend/src/test/test_migrations_module.py @@ -704,6 +704,37 @@ 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_default(self, preexisting, monkeypatch): + """v26:存量番剧 / 电影 / 订阅 / 种子都归属默认下载器实例; + 已有该列的表(如被 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) + + with engine.connect() as conn: + for table in ("bangumi", "movie", "rssitem", "torrent"): + values = conn.execute( + text(f"SELECT downloader_id FROM {table}") + ).scalars() + assert list(values) == ["default"], table + def test_is_idempotent(self): engine = _make_v0_engine() run_migrations(engine) diff --git a/backend/src/test/test_organize.py b/backend/src/test/test_organize.py index dc49be6c..12c1e6e9 100644 --- a/backend/src/test/test_organize.py +++ b/backend/src/test/test_organize.py @@ -102,7 +102,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 +276,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_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_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..5b687b20 --- /dev/null +++ b/backend/src/test/test_update_v4.py @@ -0,0 +1,135 @@ +"""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"} diff --git a/webui/src/components/setting/__tests__/config-manage.test.ts b/webui/src/components/setting/__tests__/config-manage.test.ts index ca0dea78..162beb7a 100644 --- a/webui/src/components/setting/__tests__/config-manage.test.ts +++ b/webui/src/components/setting/__tests__/config-manage.test.ts @@ -22,16 +22,18 @@ vi.mock('@/store/config', async () => { const manageState = { enable: true, eps_complete: false, - rename_method: 'pn', - revision_conflict_policy: 'hold', group_tag: false, remove_bad_torrent: false, track_orphans: true, }; + const pluginsState = { + slots: { rename_strategy: 'pn', conflict_policy: 'hold' }, + }; return { - __manageState: manageState, + __pluginsState: pluginsState, useConfigStore: () => ({ - getSettingGroup: () => computed(() => manageState), + getSettingGroup: (key: string) => + computed(() => (key === 'plugins' ? pluginsState : manageState)), }), }; }); @@ -87,9 +89,9 @@ describe('config-manage', () => { await policy.vm.$emit('update:data', 'replace'); await nextTick(); const store = (await import('@/store/config')) as unknown as { - __manageState: { revision_conflict_policy: string }; + __pluginsState: { slots: { conflict_policy: string } }; }; - expect(store.__manageState.revision_conflict_policy).toBe('replace'); + expect(store.__pluginsState.slots.conflict_policy).toBe('replace'); }); it('should append plugin rename strategies when the rename plugin provides them', () => { diff --git a/webui/src/components/setting/config-download.vue b/webui/src/components/setting/config-download.vue index 0272d8de..1906615b 100644 --- a/webui/src/components/setting/config-download.vue +++ b/webui/src/components/setting/config-download.vue @@ -1,13 +1,17 @@