mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 21:55:22 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 由迁移列表派生,新增迁移时无需手动同步
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
@@ -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,会形成循环。
|
||||
|
||||
128
backend/src/module/update/v4.py
Normal file
128
backend/src/module/update/v4.py
Normal 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
|
||||
@@ -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"] == (
|
||||
|
||||
99
backend/src/test/fixtures/config_v3.3.json
vendored
Normal file
99
backend/src/test/fixtures/config_v3.3.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 的新登记项
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
135
backend/src/test/test_update_v4.py
Normal file
135
backend/src/test/test_update_v4.py
Normal 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"}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user