feat(4.0-P2.5): 下载器改为 plugins.instances 实例,slots 选择 Provider,新增 3.3 配置迁移器

- 运行时模型移除 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
This commit is contained in:
Estrella Pan
2026-10-08 12:07:53 +02:00
parent 78f0c0ab20
commit e442d35c76
42 changed files with 914 additions and 238 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
),
),
)
# 由迁移列表派生,新增迁移时无需手动同步

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,会形成循环。

View File

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

View File

@@ -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"] == (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 的新登记项

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -1,13 +1,17 @@
<script lang="ts" setup>
import { Info } from '@icon-park/vue-next';
import type { Downloader, DownloaderType } from '#/config';
import type { DownloaderOptions, DownloaderType } from '#/config';
import type { SettingItem } from '#/components';
import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t } = useMyI18n();
const { getSettingGroup } = useConfigStore();
const downloader = getSettingGroup('downloader');
const plugins = getSettingGroup('plugins');
// 编辑默认下载器实例(plugins.slots.downloader 指向的实例)
const downloader = computed(() =>
plugins.value.instances.find((i) => i.id === plugins.value.slots.downloader)
);
const builtinTypes: DownloaderType = ['qbittorrent', 'aria2'];
const pluginProviders = usePluginProviders();
// 插件提供的下载器 id 追加在内置选项之后
@@ -16,16 +20,7 @@ const downloaderType = computed(() => [
...pluginProviders.value.downloader,
]);
const items = computed<SettingItem<Downloader>[]>(() => [
{
configKey: 'type',
label: () => t('config.downloader_set.type'),
type: 'select',
css: 'w-115',
prop: {
items: downloaderType.value,
},
},
const items = computed<SettingItem<DownloaderOptions>[]>(() => [
{
configKey: 'host',
label: () => t('config.downloader_set.host'),
@@ -73,17 +68,24 @@ const items = computed<SettingItem<Downloader>[]>(() => [
<template>
<ab-fold-panel :title="$t('config.downloader_set.title')">
<div space-y-8>
<div v-if="downloader.type === 'aria2'" class="downloader-hint">
<div v-if="downloader" space-y-8>
<div v-if="downloader.provider === 'aria2'" class="downloader-hint">
<Info size="16" />
<span>{{ $t('config.downloader_set.aria2_hint') }}</span>
</div>
<ab-setting
v-model:data="downloader.provider"
:label="() => t('config.downloader_set.type')"
type="select"
css="w-115"
:prop="{ items: downloaderType }"
></ab-setting>
<ab-setting
v-for="i in items"
:key="i.configKey"
v-bind="i"
v-model:data="downloader[i.configKey]"
v-model:data="downloader.options[i.configKey]"
></ab-setting>
</div>
</ab-fold-panel>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type {
BangumiManage,
PluginSlots,
RenameMethod,
RevisionConflictPolicy,
} from '#/config';
@@ -11,6 +12,8 @@ const { t } = useMyI18n();
const { getSettingGroup } = useConfigStore();
const manage = getSettingGroup('bangumi_manage');
// 重命名方式与版本冲突策略是 Provider 选择,存放在 plugins.slots
const plugins = getSettingGroup('plugins');
const builtinMethods: RenameMethod = ['pn', 'advance', 'none'];
const pluginProviders = usePluginProviders();
// pn / advance 也由内置插件 rename 提供;插件新增的方式(如 template)追加在后
@@ -35,14 +38,15 @@ const revisionConflictOptions = computed<SelectItem[]>(() => [
},
]);
const items = computed<SettingItem<BangumiManage>[]>(() => [
const enableItem = computed<SettingItem<BangumiManage>>(() => ({
configKey: 'enable',
label: () => t('config.manage_set.enable'),
type: 'switch',
}));
const slotItems = computed<SettingItem<PluginSlots>[]>(() => [
{
configKey: 'enable',
label: () => t('config.manage_set.enable'),
type: 'switch',
},
{
configKey: 'rename_method',
configKey: 'rename_strategy',
label: () => t('config.manage_set.method'),
type: 'select',
prop: {
@@ -50,7 +54,7 @@ const items = computed<SettingItem<BangumiManage>[]>(() => [
},
},
{
configKey: 'revision_conflict_policy',
configKey: 'conflict_policy',
label: () => t('config.manage_set.revision_conflict_policy'),
description: t('config.manage_set.revision_conflict_hint'),
type: 'select',
@@ -59,6 +63,9 @@ const items = computed<SettingItem<BangumiManage>[]>(() => [
},
bottomLine: true,
},
]);
const items = computed<SettingItem<BangumiManage>[]>(() => [
{
configKey: 'eps_complete',
label: () => t('config.manage_set.eps'),
@@ -85,6 +92,13 @@ const items = computed<SettingItem<BangumiManage>[]>(() => [
<template>
<ab-fold-panel :title="$t('config.manage_set.title')">
<div space-y-8>
<ab-setting v-bind="enableItem" v-model:data="manage.enable"></ab-setting>
<ab-setting
v-for="i in slotItems"
:key="i.configKey"
v-bind="i"
v-model:data="plugins.slots[i.configKey]"
></ab-setting>
<ab-setting
v-for="i in items"
:key="i.configKey"

View File

@@ -93,14 +93,14 @@ const sections: ConfigSection[] = [
id: 'downloader',
titleKey: 'config.downloader_set.title',
component: ConfigDownload,
groups: ['downloader'],
groups: ['plugins'],
keywords: ['qbittorrent', 'host', 'username', 'password', 'ssl', 'path'],
},
{
id: 'manage',
titleKey: 'config.manage_set.title',
component: ConfigManage,
groups: ['bangumi_manage'],
groups: ['bangumi_manage', 'plugins'],
keywords: [
'rename',
'method',

View File

@@ -34,7 +34,9 @@ async function onDeleteSelected() {
}
const isNull = computed(() => {
return config.value.downloader.host === '';
const { slots, instances } = config.value.plugins;
const downloader = instances.find((i) => i.id === slots.downloader);
return !downloader?.options.host;
});
const { connected: sseConnected } = useEventStream();

View File

@@ -117,14 +117,6 @@ export const mockConfig = {
rename_time: 60,
webui_port: 7892,
},
downloader: {
type: 'qbittorrent',
host: '172.17.0.1:8080',
username: 'admin',
password: 'adminadmin',
path: '/downloads/Bangumi',
ssl: false,
},
rss_parser: {
enable: true,
engine: 'classic',
@@ -134,8 +126,6 @@ export const mockConfig = {
bangumi_manage: {
enable: true,
eps_complete: false,
rename_method: 'pn',
revision_conflict_policy: 'hold',
group_tag: false,
remove_bad_torrent: false,
track_orphans: true,
@@ -168,6 +158,32 @@ export const mockConfig = {
base_url: '',
mode: 'fallback',
},
plugins: {
allow_unsigned: false,
enabled: {},
options: {},
hook_order: {},
slots: {
downloader: 'default',
rename_strategy: 'pn',
conflict_policy: 'hold',
media_files: 'default',
},
instances: [
{
id: 'default',
point: 'downloader',
provider: 'qbittorrent',
options: {
host: '172.17.0.1:8080',
username: 'admin',
password: 'adminadmin',
path: '/downloads/Bangumi',
ssl: false,
},
},
],
},
};
// ============================================================================

View File

@@ -20,11 +20,11 @@ describe('dirtyConfigGroups', () => {
it('should report multiple changed groups', () => {
const current = clone(initConfig);
current.downloader.host = 'nas:8080';
current.plugins.instances[0].options.host = 'nas:8080';
current.proxy.enable = true;
expect(dirtyConfigGroups(initConfig, current)).toEqual([
'downloader',
'proxy',
'plugins',
]);
});

View File

@@ -44,9 +44,8 @@ export interface Program {
webui_port: number;
}
export interface Downloader {
/** 内置类型,或插件提供的下载器 id */
type: TupleToUnion<DownloaderType> | (string & {});
/** 下载器实例的连接参数(plugins.instances[].options) */
export interface DownloaderOptions {
host: string;
username: string;
password: string;
@@ -62,9 +61,6 @@ export interface RssParser {
export interface BangumiManage {
enable: boolean;
eps_complete: boolean;
/** 内置方式,或插件提供的重命名方式 id(如 template) */
rename_method: TupleToUnion<RenameMethod> | (string & {});
revision_conflict_policy: TupleToUnion<RevisionConflictPolicy>;
group_tag: boolean;
remove_bad_torrent: boolean;
track_orphans: boolean;
@@ -150,6 +146,24 @@ export interface Update {
auto_check: boolean;
}
/** 多实例扩展点(目前只有下载器)的一个实例 */
export interface PluginInstance {
id: string;
point: string;
/** 内置类型,或插件提供的 Provider id */
provider: TupleToUnion<DownloaderType> | (string & {});
options: Record<string, unknown>;
}
/** Provider 选择:扩展点 → Provider id;下载器选的是实例 id */
export interface PluginSlots {
downloader: string;
/** 内置方式,或插件提供的重命名方式 id(如 template) */
rename_strategy: TupleToUnion<RenameMethod> | (string & {});
conflict_policy: TupleToUnion<RevisionConflictPolicy> | (string & {});
media_files: string;
}
/** 插件系统配置;启用状态缺省时内置插件启用、其它来源禁用 */
export interface Plugins {
/** 允许加载未签名(本地目录 / pip)插件 */
@@ -158,11 +172,12 @@ export interface Plugins {
options: Record<string, Record<string, unknown>>;
/** 各扩展点的显式钩子顺序(插件 id 列表) */
hook_order: Record<string, string[]>;
slots: PluginSlots;
instances: PluginInstance[];
}
export interface Config {
program: Program;
downloader: Downloader;
rss_parser: RssParser;
bangumi_manage: BangumiManage;
log: Log;
@@ -181,14 +196,6 @@ export const initConfig: Config = {
rename_time: 0,
webui_port: 0,
},
downloader: {
type: 'qbittorrent',
host: '',
username: '',
password: '',
path: '',
ssl: false,
},
rss_parser: {
enable: true,
engine: 'classic',
@@ -198,8 +205,6 @@ export const initConfig: Config = {
bangumi_manage: {
enable: true,
eps_complete: true,
rename_method: 'pn',
revision_conflict_policy: 'hold',
group_tag: true,
remove_bad_torrent: true,
track_orphans: true,
@@ -255,5 +260,19 @@ export const initConfig: Config = {
enabled: {},
options: {},
hook_order: {},
slots: {
downloader: 'default',
rename_strategy: 'pn',
conflict_policy: 'hold',
media_files: 'default',
},
instances: [
{
id: 'default',
point: 'downloader',
provider: 'qbittorrent',
options: { host: '', username: '', password: '', path: '', ssl: false },
},
],
},
};