Merge pull request #1127 from EstrellaXD/refactor/4.0-p2.5-multi-downloader

feat(4.0-P2.5): 多下载器实例、按订阅选择下载器与 3.3 配置迁移
This commit is contained in:
Xingyu (Estrella) Pan
2026-10-08 17:53:50 +02:00
committed by GitHub
93 changed files with 2916 additions and 603 deletions

View File

@@ -15,6 +15,8 @@
- **新增(媒体库刷新)**:新增内置插件「媒体库刷新」(`media-server-refresh`):配置 Jellyfin / Emby / Plex 地址与 API Key 后,合并一段时间内整理完成的种子,请求刷新媒体库;未配置时不做任何事
- **重构(整理流水线)**:`manager/renamer.py` 拆为编排(`renamer.py`)与 revision 替换事务(`revision_saga.py`);删除仅测试使用的 `Renamer.rename_file` / `_lookup_offsets`、无调用方的 `release_replacement_lease`、`match_by_save_path`、`search_by_qb_hash`,以及在重命名链路中传递却从未使用的 `season_offset` 参数
- **修复(插件配置)**:插件配置模型的校验器(`field_validator`)报错时,保存接口返回 500 而不是 422(错误详情中的异常对象无法序列化)
- **破坏性变更(配置,多下载器)**:下载器改为 `plugins.instances` 中的实例,`plugins.slots.downloader` 指向默认实例;重命名方式与版本冲突策略移到 `plugins.slots.rename_strategy` / `conflict_policy`(另有 `media_files`),插件提供的实现也可以通过 slots 选用。升级后第一次启动时,3.3 的 `downloader`、`bangumi_manage.rename_method`、`bangumi_manage.revision_conflict_policy` 自动迁移,原文件备份为 `config.json.v3.bak`(已有备份时不覆盖,另存为 `config.json.v3.bak.1` 等);迁移失败时从备份恢复配置并拒绝启动,日志写明出错的字段。`AB_DOWNLOADER_*`、`AB_DOWNLOAD_PATH`、`AB_METHOD`、`AB_REVISION_CONFLICT_POLICY` 环境变量照常生效。番剧、电影、RSS 订阅与种子新增 `downloader_id` 列(数据库迁移 v26):存量种子归属 `default`,规则与订阅为空(跟随默认实例)。`ab_sdk` 升至 0.5.0:`ConflictRequest` 删除 `configured`,宿主的冲突策略改为 `hold` / `replace` 两个 Provider
- **新增(多下载器)**:设置 → 下载器设置 可以添加多个下载器实例(qBittorrent、aria2 或插件提供的下载器)并指定默认实例。规则与 RSS 订阅可以选择下载器,留空时用默认实例;由订阅新建的规则继承订阅的选择。每个种子记录它所在的下载器,重命名、删除都在该下载器上进行,规则改用另一个下载器后已有种子留在原处,新种子保存到该下载器的下载目录(默认下载器切换或下载器被删除时也是如此)。重命名逐个下载器运行,连不上的下载器被跳过,并在第一次连不上时通知(`DownloaderUnavailableEvent` 新增 `instance_id`,按实例去重)。下载器页与种子列表标出种子所在的下载器;`GET /api/v1/downloader/torrents` 汇总所有实例,暂停 / 恢复 / 删除 / 打标接口新增 `downloader_id`,新增 `GET /api/v1/downloader/instances`
- **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移
- **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST
- **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none`

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -100,6 +100,26 @@ class Migration:
return self.statements
# 规则与订阅的 downloader_id 为空表示「用默认实例」,存量行保持为空;
# 种子记录实际所在的实例,存量种子都在 3.3 的下载器,即实例 default
_DOWNLOADER_ID_COLUMNS = {
"bangumi": "downloader_id VARCHAR",
"movie": "downloader_id VARCHAR",
"rssitem": "downloader_id VARCHAR",
"torrent": "downloader_id VARCHAR DEFAULT 'default'",
}
def _has_downloader_id(table: str) -> AppliedCheck:
# 表不存在时由 create_all 按模型建表,自带该列
def check(inspector) -> bool:
return table not in inspector.get_table_names() or column_exists(
table, "downloader_id"
)(inspector)
return check
# 迁移按版本顺序执行;版本号与 3.2.x 的历史保持一致,旧数据库照常升级。
MIGRATIONS: tuple[Migration, ...] = (
Migration(
@@ -799,6 +819,19 @@ MIGRATIONS: tuple[Migration, ...] = (
),
table_exists("plugin_kv"),
),
Migration(
26,
"add downloader_id to bangumi, movie, rssitem and torrent",
tuple(
f"ALTER TABLE {table} ADD COLUMN {column}"
for table, column in _DOWNLOADER_ID_COLUMNS.items()
),
all_checks(*(_has_downloader_id(table) for table in _DOWNLOADER_ID_COLUMNS)),
tuple(
(f"ALTER TABLE {table} ADD COLUMN {column}", _has_downloader_id(table))
for table, column in _DOWNLOADER_ID_COLUMNS.items()
),
),
)
# 由迁移列表派生,新增迁移时无需手动同步

View File

@@ -356,17 +356,23 @@ class RenameOperationDatabase:
)
return list(result.scalars().all())
async def list_active_replacements(self, limit: int = 100) -> list[RenameOperation]:
result = await self.session.execute(
select(RenameOperation)
.where(
RenameOperation.kind == "replacement",
col(RenameOperation.state).in_(
("planned", "old_staged", "new_promoted", "old_removed")
),
async def list_active_replacements(
self, downloader_type: str | None, limit: int = 100
) -> list[RenameOperation]:
"""某个下载器实例(``downloader_type``)上未完成的版本替换事务;
``None`` 时不限实例。"""
statement = select(RenameOperation).where(
RenameOperation.kind == "replacement",
col(RenameOperation.state).in_(
("planned", "old_staged", "new_promoted", "old_removed")
),
)
if downloader_type is not None:
statement = statement.where(
RenameOperation.downloader_type == downloader_type
)
.order_by(col(RenameOperation.updated_at).asc())
.limit(limit)
result = await self.session.execute(
statement.order_by(col(RenameOperation.updated_at).asc()).limit(limit)
)
return list(result.scalars().all())

View File

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

View File

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

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:
@@ -69,12 +70,15 @@ def check_files(files: list[dict]):
return media_list, subtitle_list
def path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str = ""):
def path_to_bangumi(
save_path: PathLike[str] | str, torrent_name: str = "", root: str | None = None
):
"""``root`` 为种子所在下载器实例的下载目录,缺省为默认实例的。"""
# Use PureWindowsPath regardless of the host AB runs on: it accepts
# both "\" and "/" separators, so a qBittorrent-on-Windows save_path
# reaching a Linux AB still splits into segments correctly (#1016).
save_parts = PureWindowsPath(save_path).parts
download_parts = PureWindowsPath(settings.downloader.path).parts
download_parts = PureWindowsPath(root or settings.downloader.path).parts
# Get bangumi name and season
bangumi_name = ""
season = 1
@@ -106,9 +110,13 @@ def _media_folder(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
return "Unknown Bangumi"
def gen_save_path(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
def gen_save_path(
data: Bangumi | BangumiUpdate | Movie | MovieUpdate, root: str | None = None
) -> str:
"""Generate save path for a bangumi.
``root`` 为目标下载器实例的下载目录,缺省为默认实例的。
The save path uses the adjusted season number (season + season_offset)
so files are saved directly to the correct season folder.
@@ -120,7 +128,7 @@ def gen_save_path(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
episode_type = getattr(data, "episode_type", "episode")
if isinstance(data, (Movie, MovieUpdate)) or episode_type == "movie":
# 电影/剧场版:Title (Year)/Title (Year).ext,不建 Season 子目录
return str(Path(settings.downloader.path) / folder)
return str(Path(root or settings.downloader.path) / folder)
# Apply season_offset to get the adjusted season number for the folder
adjusted_season = data.season + getattr(data, "season_offset", 0)
# 季号下限:普通剧集最小为 1——偏移到 Season 0 会被 Plex/Jellyfin 当作
@@ -131,10 +139,26 @@ def gen_save_path(data: Bangumi | BangumiUpdate | Movie | MovieUpdate) -> str:
logger.warning(
f"Season offset would result in invalid season for {data.official_title}, using original season"
)
save_path = Path(settings.downloader.path) / folder / f"Season {adjusted_season}"
save_path = (
Path(root or settings.downloader.path) / folder / f"Season {adjusted_season}"
)
return str(save_path)
def save_path_for(data: Bangumi | BangumiUpdate, root: str) -> str:
"""番剧在某个下载器实例上的保存目录。
已存的目录在该实例下载目录之下时沿用;否则它是按另一个实例的下载目录
生成的(默认实例切换、规则的实例被删除、订阅指向别的实例),按 ``root``
重新生成。用 PureWindowsPath 比较,``\\`` 与 ``/`` 分隔的目录都能认出。
"""
if data.save_path and PureWindowsPath(data.save_path).is_relative_to(
PureWindowsPath(root)
):
return data.save_path
return gen_save_path(data, root)
def rule_name(data: Bangumi):
name = (
f"[{data.group_name}] {data.official_title} S{data.season}"

View File

@@ -2,7 +2,12 @@ import logging
from module.database import Database
from module.database.bangumi import release_fits_bangumi
from module.downloader import AddResult, DownloadClient
from module.downloader import (
AddResult,
DownloadClient,
DownloaderPool,
resolve_downloader_id,
)
from module.models import Bangumi, ResponseModel
from module.network import RequestContent
from module.rss import RSSEngine
@@ -129,10 +134,19 @@ async def eps_complete():
datas = await db.bangumi.not_complete()
if datas:
logger.info("Start collecting full season...")
async with DownloadClient() as client:
collector = SeasonCollector(client)
async with DownloaderPool() as downloaders:
for data in datas:
if not data.eps_collect:
await collector.collect_season(data)
try:
client = await downloaders.get(
resolve_downloader_id(data.downloader_id)
)
except ConnectionError as e:
# 实例不可用:跳过这条规则,下一轮重试
logger.warning(
"Skip collecting %s: %s", data.official_title, e
)
continue
await SeasonCollector(client).collect_season(data)
data.eps_collect = True
await db.bangumi.update_all(datas)

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,
@@ -22,10 +21,19 @@ from module.database.bangumi import (
match_bangumi_in_list,
normalize_save_path,
)
from module.downloader import DownloadClient, RenameOutcome, RenameResult
from module.downloader import (
DownloadClient,
RenameOutcome,
RenameResult,
downloader_ids,
)
from module.downloader.path import check_files, is_ep, path_to_bangumi
from module.models import EpisodeFile, Notification, SubtitleFile
from module.notification import RenameSkippedEvent, SystemEvent
from module.notification import (
DownloaderUnavailableEvent,
RenameSkippedEvent,
SystemEvent,
)
from module.parser import TitleParser
from module.plugin import host as plugin_host
from module.plugin.registry import ProviderEntry
@@ -45,8 +53,8 @@ from .revision_saga import (
logger = logging.getLogger(__name__)
# 下载器 id:多下载器(P2.5)之前只有一个
_DOWNLOADER_ID = "default"
# 上一轮不可用的下载器实例:只在「可用 → 不可用」时通知一次
_unavailable: set[str] = set()
# 已记录过「未登记」日志的重命名方式(每个进程只记一次,重新登记后清除)
_missing_methods: set[str] = set()
# 已发布过的 TorrentOrganized(种子 hash → 最终文件)。未打 ab:renamed 标签的
@@ -58,7 +66,7 @@ _skip_notified: set[tuple[str, str]] = set()
def _rename_strategy(method: str | ProviderEntry) -> ProviderEntry:
"""按 rename_method 取重命名策略;未登记(如 rename 插件未启用)时记录一次
"""按 slots.rename_strategy 取重命名策略;未登记(如 rename 插件未启用)时记录一次
日志并按 none 处理。已解析的策略原样返回:一轮重命名只解析一次,轮中插件
被停用也不会让同一种子的文件名与 ab:renamed 标签来自不同策略。"""
if isinstance(method, ProviderEntry):
@@ -500,7 +508,6 @@ class Renamer(RevisionSaga):
)
for o in owners
),
configured=settings.bangumi_manage.revision_conflict_policy,
strict_upgrade=bool(
owner is not None
and incoming_identity is not None
@@ -591,7 +598,7 @@ class Renamer(RevisionSaga):
old_path=_downloader_path(save_path, old),
new_path=_downloader_path(save_path, new),
file_kind=kind,
downloader_id=_DOWNLOADER_ID,
downloader_id=self.client.instance_id,
)
)
reason = self._skipped.pop(torrent_hash, None)
@@ -626,17 +633,16 @@ class Renamer(RevisionSaga):
torrent_hash=torrent_hash,
bangumi_id=bangumi_id,
files=files,
downloader_id=_DOWNLOADER_ID,
downloader_id=self.client.instance_id,
)
)
@staticmethod
def _conflict_policy() -> ConflictPolicy:
return (
plugin_host.get_registry()
.providers(points.CONFLICT_POLICY)[CORE_ID]
.factory()
)
# 选中的策略未登记(插件停用或被熔断)时按 hold 处理,不会误删旧版本
policies = plugin_host.get_registry().providers(points.CONFLICT_POLICY)
entry = policies.get(settings.plugins.slots.conflict_policy) or policies["hold"]
return entry.factory()
async def _batch_lookup_offsets(
self, torrents_info: list[dict]
@@ -654,6 +660,10 @@ class Renamer(RevisionSaga):
# Collect all hashes for batch query
hashes = [info["hash"] for info in torrents_info]
torrent_records = await db.torrent.search_by_qb_hashes(hashes)
# 同一 hash 在多个实例都有记录时,以本实例的记录为准(排在后面覆盖)
torrent_records.sort(
key=lambda r: r.downloader_id == self.client.instance_id
)
hash_to_bangumi_id = {
r.qb_hash: r.bangumi_id for r in torrent_records if r.bangumi_id
}
@@ -749,7 +759,7 @@ class Renamer(RevisionSaga):
async def rename(self) -> list[Notification]:
logger.debug("Start rename process.")
strategy = _rename_strategy(settings.bangumi_manage.rename_method)
strategy = _rename_strategy(settings.plugins.slots.rename_strategy)
pending_infos = await self.client.get_torrent_info()
# Owner counting and Saga recovery must see tasks outside the normal
# Bangumi/completed filter (collections, paused tasks, changed category).
@@ -760,8 +770,13 @@ class Renamer(RevisionSaga):
for info in pending_infos:
info_by_hash.setdefault(info["hash"], info)
all_infos = list(info_by_hash.values())
# 只有一个实例时不按实例过滤:键里含主机哈希,改了主机地址(同一个
# 下载器)后进行中的事务仍要恢复;多个实例时只恢复本实例的
instance_key = self._downloader_type() if len(downloader_ids()) > 1 else None
async with Database() as db:
active_replacements = await db.rename_operation.list_active_replacements()
active_replacements = await db.rename_operation.list_active_replacements(
instance_key
)
active_replacement_ids = {
operation.new_task_id for operation in active_replacements
}
@@ -811,7 +826,9 @@ class Renamer(RevisionSaga):
)
continue
media_list, subtitle_list = check_files(files)
bangumi_name, season = path_to_bangumi(save_path, torrent_name)
bangumi_name, season = path_to_bangumi(
save_path, torrent_name, self.client.instance.path
)
episode_offset, episode_type = offset_map[torrent_hash]
kwargs = {
"torrent_name": torrent_name,
@@ -878,3 +895,38 @@ class Renamer(RevisionSaga):
)
logger.debug("Rename process finished.")
return renamed_info
async def rename_all() -> tuple[list[Notification], list[SystemEvent]]:
"""逐个下载器实例运行一轮重命名。
进不去的实例(连不上、凭据被拒、Provider 未登记)跳过,不影响其它实例;
它从可用变为不可用时产生一条 :class:`DownloaderUnavailableEvent`。
"""
renamed: list[Notification] = []
events: list[SystemEvent] = []
for instance_id in downloader_ids():
client: DownloadClient | None = None
try:
client = DownloadClient(instance_id)
await client.__aenter__()
except Exception as e:
logger.warning("Downloader %s unavailable, skipped: %s", instance_id, e)
if instance_id not in _unavailable:
_unavailable.add(instance_id)
events.append(
DownloaderUnavailableEvent(
host=settings.downloader_instance(instance_id).host,
reason=(client and client.last_auth_error) or "unreachable",
instance_id=instance_id,
)
)
continue
_unavailable.discard(instance_id)
try:
renamer = Renamer(client)
renamed += await renamer.rename()
events += renamer.events
finally:
await client.__aexit__(None, None, None)
return renamed, events

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

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

View File

@@ -3,8 +3,8 @@ import logging
from module.conf import settings
from module.database import Database
from module.database.bangumi import normalize_save_path
from module.downloader import DownloadClient
from module.downloader.path import gen_save_path
from module.downloader import DownloadClient, downloader_ids, resolve_downloader_id
from module.downloader.path import gen_save_path, save_path_for
from module.downloader.rules import build_rss_rule
from module.models import Bangumi, BangumiUpdate, ResponseModel
from module.parser import TitleParser
@@ -14,31 +14,68 @@ from module.parser.analyser.tmdb_parser import tmdb_parser
logger = logging.getLogger(__name__)
def _save_paths(data: Bangumi | BangumiUpdate, root: str) -> set[str]:
"""番剧的种子在某个实例上可能所在的目录:已存的目录(与之前相同),
以及按该实例下载目录生成的目录(种子投递到了另一个实例)。"""
paths = {data.save_path, save_path_for(data, root)}
return {normalize_save_path(p) for p in paths if p}
class TorrentManager:
def __init__(self, db: Database):
self.db = db
@staticmethod
async def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:
async with DownloadClient() as client:
async def __match_torrents_list(
data: Bangumi | BangumiUpdate, instance_id: str | None = None
) -> list:
async with DownloadClient(instance_id) as client:
torrents = await client.get_torrent_info(status_filter=None)
target_save_path = normalize_save_path(data.save_path)
targets = _save_paths(data, client.instance.path)
return [
torrent.get("hash", torrent.get("infohash_v1", ""))
for torrent in torrents
if normalize_save_path(torrent.get("save_path")) == target_save_path
if normalize_save_path(torrent.get("save_path")) in targets
]
async def delete_torrents(self, data: Bangumi, client: DownloadClient):
hash_list = await self.__match_torrents_list(data)
if hash_list:
if not await client.delete_torrent(hash_list):
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Failed to delete torrents for {data.official_title}",
msg_zh=f"删除 {data.official_title} 种子失败",
async def _torrent_instances(self, data: Bangumi) -> list[str]:
"""番剧的种子所在的下载器实例:种子行记录的实例,加上规则当前的实例。
规则换过下载器时,旧种子仍在原实例上,删除要逐个实例处理。
"""
rows = await self.db.torrent.search_by_bangumi_id(data.id)
ids = {r.downloader_id for r in rows}
ids.add(resolve_downloader_id(data.downloader_id))
return sorted(ids & set(downloader_ids()))
async def delete_torrents(self, data: Bangumi, instance_ids: list[str]):
found = False
failed: list[str] = []
# 逐实例隔离:一个实例不可用或删除失败,其它实例照常清理
for instance_id in instance_ids:
try:
hash_list = await self.__match_torrents_list(data, instance_id)
if not hash_list:
continue
found = True
async with DownloadClient(instance_id) as client:
deleted = await client.delete_torrent(hash_list)
except ConnectionError as e:
logger.warning(
"[Manager] Can't delete torrents on %s: %s", instance_id, e
)
deleted = False
if not deleted:
failed.append(instance_id)
if failed:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Failed to delete torrents for {data.official_title} "
f"on {', '.join(failed)}",
msg_zh=f"删除 {data.official_title} 种子失败({', '.join(failed)})",
)
if found:
logger.info(f"Delete rule and torrents for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -83,6 +120,8 @@ class TorrentManager:
async def delete_rule(self, _id: int | str, file: bool = False):
data = await self.db.bangumi.search_id(int(_id))
if isinstance(data, Bangumi):
# 种子行删除前先记下种子所在的实例
instance_ids = await self._torrent_instances(data) if file else []
# Clean up torrent records so re-adding the same anime can re-download
await self.db.torrent.delete_by_bangumi_id(int(_id))
await self.db.bangumi.delete_one(int(_id))
@@ -92,16 +131,15 @@ class TorrentManager:
if file:
# Only the file-cleanup path needs the downloader, so an
# unreachable downloader shouldn't block a DB-only delete.
async with DownloadClient() as client:
torrent_message = await self.delete_torrents(data, client)
if torrent_message.status_code == 500:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Deleted rule for {data.official_title}, "
"but deleting its torrents failed.",
msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。",
)
torrent_message = await self.delete_torrents(data, instance_ids)
if torrent_message.status_code == 500:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Deleted rule for {data.official_title}, "
"but deleting its torrents failed.",
msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。",
)
logger.info(f"Delete rule for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -125,8 +163,9 @@ class TorrentManager:
if file:
# Only the file-cleanup path needs the downloader, so an
# unreachable downloader shouldn't block a DB-only disable.
async with DownloadClient() as client:
return await self.delete_torrents(data, client)
return await self.delete_torrents(
data, await self._torrent_instances(data)
)
logger.info(f"Disable rule for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -165,25 +204,48 @@ class TorrentManager:
async def update_rule(self, bangumi_id, data: BangumiUpdate):
old_data = await self.db.bangumi.search_id(bangumi_id)
if old_data:
# Move torrent
match_list = await self.__match_torrents_list(old_data)
async with DownloadClient() as client:
new_path = gen_save_path(data)
old_path = old_data.save_path
old_instance = resolve_downloader_id(old_data.downloader_id)
# 部分更新(未带 downloader_id)保持规则原来的实例
new_instance = resolve_downloader_id(
data.downloader_id
if "downloader_id" in data.model_fields_set
else old_data.downloader_id
)
new_path = gen_save_path(
data, settings.downloader_instance(new_instance).path
)
# 换了下载器实例:旧种子留在原实例原位置,之后的新种子进新实例。
# 没换:规则的种子可能经订阅进了别的实例,逐个实例按各自的
# 下载目录移动
if new_instance == old_instance:
for instance_id in await self._torrent_instances(old_data):
root = settings.downloader_instance(instance_id).path
moved_path = gen_save_path(data, root)
old_paths = _save_paths(old_data, root)
if old_paths == {normalize_save_path(moved_path)}:
continue
match_list = await self.__match_torrents_list(old_data, instance_id)
async with DownloadClient(instance_id) as client:
# Move existing torrents to new location if path changed
if match_list:
await client.move_torrent(match_list, moved_path)
logger.info(
f"Moved torrents from {old_paths} to {moved_path}"
)
# Move existing torrents to new location if path changed
if match_list and new_path != old_path:
await client.move_torrent(match_list, new_path)
logger.info(f"Moved torrents from {old_path} to {new_path}")
# Update qBittorrent RSS rule if save_path changed
if new_path != old_path and old_data.rule_name:
# Recreate the rule with the new save_path
rule = build_rss_rule(data, new_path)
await client.set_rss_rule(old_data.rule_name, rule)
logger.info(
f"Updated RSS rule {old_data.rule_name} with new save_path"
)
# Update qBittorrent RSS rule if save_path changed。
# 只改有这条规则种子的实例或规则自己的实例:setRule
# 在别的 qB 上会新建一条启用的自动下载规则
if old_data.rule_name and (
match_list or instance_id == old_instance
):
# Recreate the rule with the new save_path
rule = build_rss_rule(data, moved_path)
await client.set_rss_rule(old_data.rule_name, rule)
logger.info(
f"Updated RSS rule {old_data.rule_name} "
"with new save_path"
)
data.save_path = new_path
await self.db.bangumi.update(data, bangumi_id)

View File

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

View File

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

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,33 @@ class Config(BaseModel):
update: Update = Update()
plugins: Plugins = Plugins()
@property
def downloader(self) -> DownloaderInstance:
"""默认下载器实例(``plugins.slots.downloader``)。"""
return self.downloader_instance(self.plugins.slots.downloader)
def downloader_instance(self, instance_id: str) -> DownloaderInstance:
instance = next(
(
i
for i in self.plugins.instances
if i.id == instance_id and i.point == DOWNLOADER_POINT
),
None,
)
if instance is None:
raise KeyError(f"unknown downloader instance {instance_id!r}")
options = DownloaderOptions.model_validate(instance.options)
return DownloaderInstance(
id=instance.id,
type=instance.provider,
host=options.host,
username=options.username,
password=options.password,
path=options.path,
ssl=options.ssl,
)
def model_dump(self, *args, by_alias=True, **kwargs):
return super().model_dump(*args, by_alias=by_alias, **kwargs)

View File

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

View File

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

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

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

View File

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

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,134 @@
"""3.3 → 4.0 配置迁移(设计文档第 9 节)。
只迁移 4.0 中已有去处的字段:
- ``downloader`` → ``plugins.instances`` 中 id 为 ``default`` 的下载器实例,
``plugins.slots.downloader = "default"``
- ``bangumi_manage.rename_method`` → ``plugins.slots.rename_strategy``
(废弃的 ``normal`` 改为 ``none``)
- ``bangumi_manage.revision_conflict_policy`` → ``plugins.slots.conflict_policy``
在 ``Settings`` 读写配置文件之前运行。改写前把原文件备份为 ``<文件名>.v3.bak``;
失败时从备份恢复并抛出 :class:`ConfigMigrationError`,拒绝启动。
本模块不能 import ``module.conf``:它在 ``settings`` 构造之前被调用。
"""
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Any
from pydantic import ValidationError
from module.models.config import DEFAULT_DOWNLOADER_ID, DOWNLOADER_POINT, Config
logger = logging.getLogger(__name__)
_SLOT_FIELDS = {
"rename_method": "rename_strategy",
"revision_conflict_policy": "conflict_policy",
}
class ConfigMigrationError(RuntimeError):
"""配置迁移失败;配置文件已从备份恢复。"""
def _section(config: dict[str, Any], key: str) -> dict[str, Any]:
value = config.get(key)
if value is None:
return {}
if not isinstance(value, dict):
raise ConfigMigrationError(f"config migration failed at {key}: not an object")
return value
def migrate_v3_dict(config: dict[str, Any]) -> list[str]:
"""就地把 3.3 的字段移到 4.0 的位置,返回被移动的字段(空列表表示无需迁移)。
下载器字段合并到已有的 ``default`` 实例上,没有时新建;未出现的字段保持原样。
"""
downloader = _section(config, "downloader")
bangumi_manage = _section(config, "bangumi_manage")
moved = [f"bangumi_manage.{key}" for key in _SLOT_FIELDS if key in bangumi_manage]
if "downloader" in config:
moved.insert(0, "downloader")
if not moved:
return moved
plugins = config.setdefault("plugins", {})
slots = plugins.setdefault("slots", {})
if "downloader" in config:
config.pop("downloader")
options = dict(downloader)
instances = plugins.setdefault("instances", [])
instance = next(
(i for i in instances if i.get("id") == DEFAULT_DOWNLOADER_ID), None
)
if instance is None:
instance = {
"id": DEFAULT_DOWNLOADER_ID,
"point": DOWNLOADER_POINT,
"provider": "qbittorrent",
"options": {},
}
instances.append(instance)
instance["provider"] = options.pop("type", instance["provider"])
instance["options"].update(options)
slots["downloader"] = DEFAULT_DOWNLOADER_ID
for old, slot in _SLOT_FIELDS.items():
if old in bangumi_manage:
slots[slot] = bangumi_manage.pop(old)
if slots.get("rename_strategy") == "normal":
slots["rename_strategy"] = "none"
return moved
def _failed_field(error: Exception) -> str:
if isinstance(error, ValidationError):
first = error.errors(include_url=False)[0]
loc = ".".join(str(p) for p in first["loc"])
return f"{loc}: {first['msg']}"
return str(error)
def migrate_v3_config(path: Path) -> bool:
"""迁移 ``path`` 处的配置文件,返回是否改写了文件。已是 4.0 格式时什么也不做。"""
config = json.loads(path.read_text(encoding="utf-8"))
moved = migrate_v3_dict(config)
if not moved:
return False
# 已有的备份不覆盖(降级回 3.3 再升级时,第一份备份里才有真实的下载器
# 凭据),本次备份另取 .v3.bak.1、.v3.bak.2 …
backup = path.with_name(path.name + ".v3.bak")
n = 0
while backup.exists():
n += 1
backup = path.with_name(f"{path.name}.v3.bak.{n}")
shutil.copy2(path, backup)
try:
Config.model_validate(config)
# 先写临时文件再替换:中途崩溃不会留下半个配置文件
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(
json.dumps(config, indent=4, ensure_ascii=False), encoding="utf-8"
)
os.replace(tmp, path)
except Exception as e:
shutil.copy2(backup, path)
message = (
f"config migration to 4.0 failed at {_failed_field(e)}; "
f"{path.name} was restored from {backup.name}. Fix the field and restart. "
f"配置迁移失败,已从备份恢复 {path.name},请修正该字段后重新启动。"
)
logger.critical(message)
raise ConfigMigrationError(message) from e
logger.info(
"Migrated %s to 4.0 (%s); the 3.3 file is kept as %s",
path.name,
", ".join(moved),
backup.name,
)
return True

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

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

View File

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

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

View File

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

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

View File

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

View File

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

@@ -6,7 +6,7 @@ from module.database import Database
from module.downloader import AddResult
from module.models import EpisodeFile, Torrent
from module.rss.engine import RSSEngine
from test.factories import make_bangumi, make_rss_item
from test.factories import SingleClientPool, make_bangumi, make_rss_item
# ---------------------------------------------------------------------------
# RSS → Download Flow
@@ -58,7 +58,7 @@ class TestRssToDownloadFlow:
mock_client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
# 5. Execute refresh_rss
await engine.refresh_rss(mock_client)
await engine.refresh_rss(SingleClientPool(mock_client))
# 6. Verify: matched torrents were downloaded
assert mock_client.add_torrent.call_count == 2
@@ -109,7 +109,7 @@ class TestRssToDownloadFlow:
mock_get.return_value = torrents
mock_client = AsyncMock()
mock_client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await engine.refresh_rss(mock_client)
await engine.refresh_rss(SingleClientPool(mock_client))
# Only 1080p should be downloaded (720p is filtered)
assert mock_client.add_torrent.call_count == 1
@@ -149,7 +149,7 @@ class TestRssToDownloadFlow:
mock_get.return_value = torrents
mock_client = AsyncMock()
mock_client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
await engine.refresh_rss(mock_client)
await engine.refresh_rss(SingleClientPool(mock_client))
# Only ep02 should be downloaded (ep01 already exists)
assert mock_client.add_torrent.call_count == 1
@@ -210,7 +210,7 @@ class TestRenameFlow:
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
with patch("module.manager.renamer.settings") as mock_mgr_settings:
mock_mgr_settings.bangumi_manage.rename_method = "pn"
mock_mgr_settings.plugins.slots.rename_strategy = "pn"
mock_mgr_settings.bangumi_manage.remove_bad_torrent = False
with patch("module.downloader.path.settings") as mock_path_settings:
mock_path_settings.downloader.path = "/downloads/Bangumi"
@@ -273,7 +273,7 @@ class TestRenameFlow:
with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser):
with patch("module.manager.renamer.settings") as mock_mgr_settings:
mock_mgr_settings.bangumi_manage.rename_method = "pn"
mock_mgr_settings.plugins.slots.rename_strategy = "pn"
mock_mgr_settings.bangumi_manage.remove_bad_torrent = False
with patch("module.downloader.path.settings") as mock_path_settings:
mock_path_settings.downloader.path = "/downloads/Bangumi"

View File

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

View File

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

View File

@@ -704,6 +704,40 @@ class TestRunMigrations:
assert get_schema_version(conn) == CURRENT_SCHEMA_VERSION
assert prefix == "legacy_cccccccc"
@pytest.mark.parametrize("preexisting", [None, "torrent"])
def test_v26_downloader_id_existing_rows_follow_default(
self, preexisting, monkeypatch
):
"""v26:存量种子归属实例 default;番剧 / 电影 / 订阅为空(跟随默认实例)。
已有该列的表(如被 create_all 提前建出)跳过,其它表照常补列。"""
engine = _make_v0_engine()
with engine.begin() as conn:
if preexisting:
conn.execute(
text(
f"ALTER TABLE {preexisting} ADD COLUMN downloader_id VARCHAR "
"DEFAULT 'default'"
)
)
conn.execute(
text("INSERT INTO bangumi (id, official_title) VALUES (1, 'a')")
)
conn.execute(text("INSERT INTO rssitem (id, name) VALUES (1, 'r')"))
conn.execute(text("INSERT INTO torrent (id, name) VALUES (1, 't')"))
_run_through_version(engine, 25, monkeypatch)
with engine.begin() as conn:
conn.execute(text("INSERT INTO movie (official_title) VALUES ('m')"))
run_migrations(engine)
expected = {"bangumi": None, "movie": None, "rssitem": None}
with engine.connect() as conn:
for table in ("bangumi", "movie", "rssitem", "torrent"):
values = conn.execute(
text(f"SELECT downloader_id FROM {table}")
).scalars()
assert list(values) == [expected.get(table, "default")], table
def test_is_idempotent(self):
engine = _make_v0_engine()
run_migrations(engine)

View File

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

View File

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

View File

@@ -79,6 +79,7 @@ def add_strategy(registry, provider_id: str, target_name, plugin_id: str = "ext"
@pytest.fixture
def renamer(mock_qb_client):
with patch("module.downloader.download_client.settings") as mock_settings:
mock_settings.downloader.id = "default"
mock_settings.downloader.type = "qbittorrent"
mock_settings.downloader.host = "localhost:8080"
mock_settings.downloader.path = "/downloads/Bangumi"
@@ -102,7 +103,7 @@ async def run(renamer: Renamer, method: str) -> None:
patch("module.manager.renamer.settings") as mock_settings,
patch("module.downloader.path.settings") as mock_path_settings,
):
mock_settings.bangumi_manage.rename_method = method
mock_settings.plugins.slots.rename_strategy = method
mock_settings.bangumi_manage.remove_bad_torrent = False
mock_path_settings.downloader.path = "/downloads/Bangumi"
await renamer.rename()
@@ -276,8 +277,8 @@ def _task(file_count: int = 1, revision: int | None = 1) -> RevisionTask:
def test_core_conflict_policy_decide_matches_3x_reasons(
owners, incoming, configured, strict, action, reason
):
decision = CoreConflictPolicy().decide(
ConflictRequest("t.mkv", incoming, owners, configured, strict)
decision = CoreConflictPolicy(configured).decide(
ConflictRequest("t.mkv", incoming, owners, strict)
)
assert (decision.action, decision.reason) == (action, reason)

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

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

View File

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

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

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,147 @@
"""3.3 → 4.0 配置迁移(module.update.v4)。"""
import json
import shutil
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from module.conf.config import Settings
from module.models.config import DownloaderInstance
from module.update.v4 import ConfigMigrationError, migrate_v3_config, migrate_v3_dict
FIXTURE = Path(__file__).parent / "fixtures" / "config_v3.3.json"
@pytest.fixture
def v3_config(tmp_path: Path) -> Path:
path = tmp_path / "config.json"
shutil.copy(FIXTURE, path)
return path
def test_settings_v33_config_round_trip_migrated(v3_config, monkeypatch):
monkeypatch.setenv("QB_HOST", "nas:8080")
with patch("module.conf.config.CONFIG_PATH", v3_config):
settings = Settings()
assert settings.downloader == DownloaderInstance(
id="default",
type="qbittorrent",
host="nas:8080",
username="ab",
password="qb-secret",
path="/media/downloads/Bangumi",
ssl=True,
)
assert settings.plugins.slots.downloader == "default"
assert settings.plugins.slots.rename_strategy == "advance"
assert settings.plugins.slots.conflict_policy == "replace"
# 其它字段原样保留
assert settings.program.rss_time == 1800
assert settings.bangumi_manage.group_tag is True
assert settings.notification.providers[0].token == "tg-token"
saved = json.loads(v3_config.read_text())
assert "downloader" not in saved
assert "rename_method" not in saved["bangumi_manage"]
assert "revision_conflict_policy" not in saved["bangumi_manage"]
# $VAR 引用原样保存,读取时才展开
assert saved["plugins"]["instances"][0]["options"]["host"] == "$QB_HOST"
backup = v3_config.with_name("config.json.v3.bak")
assert backup.read_bytes() == FIXTURE.read_bytes()
def test_migrate_v3_config_second_run_noop(v3_config):
assert migrate_v3_config(v3_config) is True
backup = v3_config.with_name("config.json.v3.bak")
migrated, backed_up = v3_config.read_bytes(), backup.read_bytes()
assert migrate_v3_config(v3_config) is False
assert v3_config.read_bytes() == migrated
assert backup.read_bytes() == backed_up == FIXTURE.read_bytes()
def test_migrate_v4_config_untouched_without_backup(tmp_path):
path = tmp_path / "config.json"
path.write_text(json.dumps({"program": {"rss_time": 600}}))
assert migrate_v3_config(path) is False
assert not path.with_name("config.json.v3.bak").exists()
@pytest.mark.parametrize(
("section", "key", "value", "field"),
[
("downloader", "ssl", "sometimes", "ssl"),
("bangumi_manage", "rename_method", 5, "plugins.slots.rename_strategy"),
],
)
def test_migrate_v3_config_invalid_field_restores_and_refuses(
v3_config, section, key, value, field
):
config = json.loads(v3_config.read_text())
config[section][key] = value
original = json.dumps(config, indent=4)
v3_config.write_text(original)
with pytest.raises(ConfigMigrationError, match=field):
migrate_v3_config(v3_config)
assert v3_config.read_text() == original
assert v3_config.with_name("config.json.v3.bak").read_text() == original
assert not v3_config.with_name("config.json.tmp").exists()
@pytest.mark.parametrize(
("method", "expected"),
[("normal", "none"), ("none", "none"), ("pn", "pn"), ("template", "template")],
)
def test_migrate_v3_dict_rename_method_moves_to_slot(method, expected):
config: dict[str, Any] = {"bangumi_manage": {"rename_method": method}}
assert migrate_v3_dict(config) == ["bangumi_manage.rename_method"]
assert config["plugins"]["slots"] == {"rename_strategy": expected}
assert config["bangumi_manage"] == {}
def test_migrate_v3_dict_downloader_merges_into_existing_default_instance():
config: dict[str, Any] = {
"downloader": {"host": "qb:8080"},
"plugins": {
"instances": [
{
"id": "default",
"point": "downloader",
"provider": "aria2",
"options": {"host": "old", "path": "/dl"},
}
]
},
}
migrate_v3_dict(config)
assert config["plugins"]["instances"] == [
{
"id": "default",
"point": "downloader",
"provider": "aria2",
"options": {"host": "qb:8080", "path": "/dl"},
}
]
assert config["plugins"]["slots"] == {"downloader": "default"}
def test_migrate_v3_config_existing_backup_kept_new_backup_beside_it(v3_config):
"""降级回 3.3 再升级:第一次迁移留下的备份(含真实凭据)不能被覆盖。"""
first = v3_config.with_name("config.json.v3.bak")
first.write_text("original 3.3 config")
assert migrate_v3_config(v3_config) is True
assert first.read_text() == "original 3.3 config"
second = v3_config.with_name("config.json.v3.bak.1")
assert second.read_bytes() == FIXTURE.read_bytes()

View File

@@ -14,6 +14,17 @@
修改下载器设置后,需要点击底部的 **保存并重启**。保存后下载器连接会重新建立。
## 多个下载器
设置页顶部列出所有下载器实例。点击一个实例即可在下方编辑它;输入新的 id 后点击 **添加** 新建实例,类型可以选 qBittorrent、aria2 或插件提供的下载器。**设为默认** 改变默认实例,默认实例不能删除。
- 规则编辑(高级选项)与添加订阅中可以选择下载器,留空跟随默认实例。只有一个下载器时不显示该选项。
- 新种子投递到规则选择的下载器;规则未选择时用订阅选择的,再退回默认实例。由订阅新建的规则继承订阅的选择。
- 每个种子记录它被添加到的下载器,之后的重命名、删除都在该下载器上进行。规则改用另一个下载器后,已有的种子留在原下载器的原位置,不会迁移。
- 重命名按下载器逐个进行。某个下载器连不上时跳过它,并在它第一次连不上时发送「下载器连接异常」通知,其它下载器照常处理。
- 下载器页与种子列表在有多个下载器时标出每个种子所在的下载器。
- 删除下载器后,选择它的规则与订阅改用默认实例,其中已有的种子不受影响。
## 常见问题
### 下载器地址
@@ -43,11 +54,27 @@ AB 中的下载路径用于生成保存位置和后续整理路径。请填写**
## `config.json` 配置选项
配置节:`downloader`
下载器是 `plugins.instances` 中 `point` 为 `downloader` 的实例,`plugins.slots.downloader` 是默认实例的 id(默认为 `default`)。在设置页新建实例时,id 只能包含字母、数字、`_` 与 `-`:
```json
"plugins": {
"slots": { "downloader": "default" },
"instances": [
{
"id": "default",
"point": "downloader",
"provider": "qbittorrent",
"options": { "host": "172.17.0.1:8080", "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", "ssl": false }
}
]
}
```
3.3 的 `downloader` 配置节在升级到 4.0 后第一次启动时自动迁移到 `default` 实例,原文件备份为 `config.json.v3.bak`。环境变量 `AB_DOWNLOADER_HOST`、`AB_DOWNLOADER_USERNAME`、`AB_DOWNLOADER_PASSWORD`、`AB_DOWNLOAD_PATH` 照常生效,写入默认实例。
| 参数 | 说明 | 类型 | WebUI 选项 | 默认值 |
| --- | --- | --- | --- | --- |
| `type` | 下载器类型 | 字符串 | 下载器类型 | `qbittorrent` |
| `provider` | 下载器类型 | 字符串 | 下载器类型 | `qbittorrent` |
| `host` | 下载器地址 | 字符串 | 下载器地址 | `172.17.0.1:8080` |
| `username` | 下载器用户名 | 字符串 | 用户名 | `admin` |
| `password` | 下载器密码或 aria2 RPC secret | 字符串 | 密码 | `adminadmin` |

View File

@@ -26,11 +26,12 @@
| --- | --- | --- | --- | --- |
| `enable` | 启用番剧管理器 | 布尔值 | 启用 | `true` |
| `eps_complete` | 启用剧集补全 | 布尔值 | 番剧补全 | `false` |
| `rename_method` | 重命名方式 | 字符串 | 重命名方式 | `pn` |
| `group_tag` | 添加字幕组标签 | 布尔值 | 添加组标签 | `false` |
| `remove_bad_torrent` | 删除错误种子 | 布尔值 | 删除坏种 | `false` |
| `track_orphans` | 记录未匹配种子 | 布尔值 | 记录未匹配种子 | `true` |
重命名方式与版本冲突策略是 Provider 选择,保存在 `plugins.slots`:`rename_strategy`(重命名方式,默认 `pn`)与 `conflict_policy`(`hold` / `replace`,默认 `hold`)。3.3 的 `bangumi_manage.rename_method` 与 `revision_conflict_policy` 在升级到 4.0 后第一次启动时自动迁移。
## 模板重命名
选择 `template` 后,文件名由内置插件 `rename` 的模板生成。在 **设置 → 插件 → 重命名** 中修改「文件名模板」,默认值为:

View File

@@ -307,7 +307,7 @@ class MyPlugin(Plugin):
### 重命名方式(rename_strategy)
下载完成后,AB 为种子里的每个正片与字幕调用设置项「重命名方式」(`bangumi_manage.rename_method`)对应的 Provider。宿主只自带 `none`(保留原名);`pn`、`advance` 与 `template` 由内置插件「重命名」(`rename`,默认启用)提供。插件登记的 id 会出现在 设置 → 番剧管理设置 → 重命名方式 的下拉框里。
下载完成后,AB 为种子里的每个正片与字幕调用设置项「重命名方式」(`plugins.slots.rename_strategy`)对应的 Provider。宿主只自带 `none`(保留原名);`pn`、`advance` 与 `template` 由内置插件「重命名」(`rename`,默认启用)提供。插件登记的 id 会出现在 设置 → 番剧管理设置 → 重命名方式 的下拉框里。
```python
from ab_sdk import Plugin, points, provider
@@ -344,9 +344,9 @@ class MyRename(Plugin):
### 文件分类与版本冲突(media_files / conflict_policy)
- `media_files`:`classify(path) -> "media" | "subtitle" | "ignore"`,决定种子内哪些文件按正片、字幕重命名。宿主实现按扩展名判断(`.mp4` / `.mkv` 为正片,`.ass` / `.srt` 为字幕)。
- `conflict_policy`:`decide(ConflictRequest) -> ConflictDecision("hold" | "replace")`,新种子的规范文件名已被另一个种子占用时决定保留旧的还是替换。宿主实现沿用设置项「版本冲突策略」。宿主只在「唯一占用者、双方都是单文件种子、双方解析身份完整」时执行替换,其它情况一律按 `hold` 处理。
- `conflict_policy`:`decide(ConflictRequest) -> ConflictDecision("hold" | "replace")`,新种子的规范文件名已被另一个种子占用时决定保留旧的还是替换。宿主自带 `hold` 与 `replace` 两个实现,即设置项「版本冲突策略」的两个选项。宿主只在「唯一占用者、双方都是单文件种子、双方解析身份完整」时执行替换,其它情况一律按 `hold` 处理。
这两个扩展点目前只使用宿主实现(id 为 `default`)。插件可以登记自己的实现,但要等多下载器版本的 `plugins.slots` 提供后才能被选用。
两者按 `plugins.slots.media_files`(默认 `default`)与 `plugins.slots.conflict_policy`(默认 `hold`)选择 Provider id。选中的 id 未登记(插件停用或被熔断)时,退回宿主的 `default` 与 `hold`。
### 整理事件(file.renamed / torrent.organized)
@@ -356,7 +356,7 @@ class MyRename(Plugin):
| `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` 元组)、`downloader_id` | 一个种子整理完成;重命名方式为 `none` 时同样发布,`files` 为原路径 |
- 路径是**下载器视角**的绝对路径,保存目录与种子内路径以 `/` 拼接(Windows 下载器的 `\` 也统一为 `/`)。AB 与下载器看到的目录不同(如分别运行在不同容器)时,订阅者需要自己做路径映射。
- `bangumi_id` 来自种子的 `ab:<id>` 标签,旧种子可能为 None。`downloader_id` 目前固定为 `"default"`,多下载器版本会给出实例 id。
- `bangumi_id` 来自种子的 `ab:<id>` 标签,旧种子可能为 None。`downloader_id` 是种子所在下载器实例的 id(`plugins.instances`),多个实例的路径视角可能不同。
- `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次,订阅者必须幂等。
- 这两个事件只发布到事件总线,不进入通知中心。
@@ -467,7 +467,7 @@ AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent`
| `rss_failure` | `RssFailureEvent` | RSS 订阅从正常变为连接异常 |
| `download_failure` | `DownloadFailureEvent` | 种子重试后仍添加失败 |
| `offset_review` | `OffsetReviewEvent` | 番剧的季度 / 集数偏移需要人工确认 |
| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封 |
| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封;`instance_id` 为下载器实例 id,每个实例从可用变为不可用时发布一次 |
| `update_available` | `UpdateAvailableEvent` | 检查到新版本 |
| `update_applied` / `update_failed` | `UpdateAppliedEvent` | 在线更新成功 / 失败(`kind` 随 `success` 变化) |
| `llm_auth_failure` | `LLMAuthFailureEvent` | 订阅类 LLM 提供商凭据失效 |

View File

@@ -14,6 +14,17 @@
After changing downloader settings, click **Save & restart** so the downloader client is recreated.
## Multiple downloaders
The top of the settings section lists every downloader instance. Click an instance to edit it below. Type a new id and click **Add** to create an instance; its type can be qBittorrent, aria2 or a downloader from a plugin. **Set default** changes the default instance. You cannot delete the default instance.
- The rule editor (advanced options) and the add-subscription dialog have a downloader field. Leave it empty to use the default instance. The field is hidden when there is only one downloader.
- A new torrent goes to the downloader of its rule. If the rule has none, it goes to the downloader of the subscription, then to the default instance. A rule created from a subscription takes the downloader of the subscription.
- Each torrent records the downloader it was added to. Rename and delete use that downloader. When a rule moves to another downloader, its existing torrents stay where they are.
- Rename runs once per downloader. An unreachable downloader is skipped, and a "Downloader unavailable" notification is sent the first time it becomes unreachable. The other downloaders continue.
- With more than one downloader, the downloader page and the torrent lists show the downloader of each torrent.
- After you delete a downloader, rules and subscriptions that used it use the default instance. Torrents already in the deleted downloader are not touched.
## Downloader Address
::: warning
@@ -35,11 +46,27 @@ Use the path from the downloader's point of view:
## `config.json`
Section: `downloader`
A downloader is an entry in `plugins.instances` whose `point` is `downloader`. `plugins.slots.downloader` is the id of the default instance (`default` by default). When you add an instance on the settings page, its id can contain only letters, digits, `_` and `-`:
```json
"plugins": {
"slots": { "downloader": "default" },
"instances": [
{
"id": "default",
"point": "downloader",
"provider": "qbittorrent",
"options": { "host": "172.17.0.1:8080", "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", "ssl": false }
}
]
}
```
On the first start after an upgrade to 4.0, the 3.3 `downloader` section moves to the `default` instance automatically. The original file is kept as `config.json.v3.bak`. The environment variables `AB_DOWNLOADER_HOST`, `AB_DOWNLOADER_USERNAME`, `AB_DOWNLOADER_PASSWORD` and `AB_DOWNLOAD_PATH` still work and set the default instance.
| Key | Description | Type | WebUI field | Default |
| --- | --- | --- | --- | --- |
| `type` | Downloader type | string | Downloader Type | `qbittorrent` |
| `provider` | Downloader type | string | Downloader Type | `qbittorrent` |
| `host` | Downloader address | string | Host | `172.17.0.1:8080` |
| `username` | Downloader username | string | Username | `admin` |
| `password` | Downloader password or aria2 RPC secret | string | Password | `adminadmin` |

View File

@@ -84,7 +84,8 @@ Section: `bangumi_manage`
| --- | --- | --- | --- | --- |
| `enable` | Enable manager | boolean | Enable | `true` |
| `eps_complete` | Enable episode completion | boolean | EPS complete | `false` |
| `rename_method` | Rename method | string | Rename Method | `pn` |
| `group_tag` | Add subgroup tags | boolean | Add Group Tag | `false` |
| `remove_bad_torrent` | Delete errored torrents | boolean | Delete Bad Torrent | `false` |
| `track_orphans` | Track unmatched torrents | boolean | Track Unmatched Torrents | `true` |
The rename method and the revision conflict policy are provider choices. They are stored in `plugins.slots`: `rename_strategy` (rename method, default `pn`) and `conflict_policy` (`hold` / `replace`, default `hold`). On the first start after an upgrade to 4.0, the 3.3 keys `bangumi_manage.rename_method` and `revision_conflict_policy` move there automatically.

View File

@@ -14,6 +14,17 @@
変更後は **保存して再起動** をクリックしてください。
## 複数のダウンローダー
設定セクションの上部にすべてのダウンローダーインスタンスが表示されます。インスタンスをクリックすると下で編集できます。新しい id を入力して **追加** をクリックするとインスタンスを作成できます。種類は qBittorrent、aria2、またはプラグインのダウンローダーです。**既定に設定** で既定インスタンスを変更します。既定インスタンスは削除できません。
- ルール編集(詳細設定)と購読の追加でダウンローダーを選べます。空欄なら既定インスタンスを使います。ダウンローダーが 1 つだけのときは表示されません。
- 新しいトレントはルールのダウンローダーに追加されます。ルールに指定がなければ購読のダウンローダー、それもなければ既定インスタンスです。購読から作成されたルールは購読の選択を引き継ぎます。
- 各トレントは追加先のダウンローダーを記録し、リネームと削除はそのダウンローダーで行います。ルールを別のダウンローダーに変えても、既存のトレントは元の場所に残ります。
- リネームはダウンローダーごとに実行されます。接続できないダウンローダーはスキップされ、初めて接続できなくなったときに「ダウンローダー接続エラー」が通知されます。他のダウンローダーは通常どおり処理されます。
- ダウンローダーが複数あるとき、ダウンローダーページとトレント一覧に各トレントのダウンローダーが表示されます。
- ダウンローダーを削除すると、それを選んでいたルールと購読は既定インスタンスを使います。削除したダウンローダー内の既存のトレントには影響しません。
## アドレスの注意
::: warning
@@ -27,11 +38,27 @@ Docker Bridgeモードでは、ダウンローダーとAutoBangumiが同じネ
## `config.json`
セクション:`downloader`
ダウンローダーは `plugins.instances` のうち `point` が `downloader` のインスタンスです。`plugins.slots.downloader` は既定インスタンスの id(既定値 `default`)です。設定画面でインスタンスを追加するとき、id に使えるのは英数字、`_`、`-` だけです。
```json
"plugins": {
"slots": { "downloader": "default" },
"instances": [
{
"id": "default",
"point": "downloader",
"provider": "qbittorrent",
"options": { "host": "172.17.0.1:8080", "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", "ssl": false }
}
]
}
```
3.3 の `downloader` セクションは、4.0 へのアップグレード後の初回起動時に `default` インスタンスへ自動で移行され、元のファイルは `config.json.v3.bak` として保存されます。環境変数 `AB_DOWNLOADER_HOST`、`AB_DOWNLOADER_USERNAME`、`AB_DOWNLOADER_PASSWORD`、`AB_DOWNLOAD_PATH` は引き続き既定インスタンスに反映されます。
| キー | 説明 | 型 | WebUI項目 | 既定値 |
| --- | --- | --- | --- | --- |
| `type` | ダウンローダー種類 | 文字列 | ダウンローダー種類 | `qbittorrent` |
| `provider` | ダウンローダー種類 | 文字列 | ダウンローダー種類 | `qbittorrent` |
| `host` | ダウンローダーアドレス | 文字列 | ホスト | `172.17.0.1:8080` |
| `username` | ユーザー名 | 文字列 | ユーザー名 | `admin` |
| `password` | パスワードまたはaria2 RPC secret | 文字列 | パスワード | `adminadmin` |

View File

@@ -82,7 +82,8 @@
| --- | --- | --- | --- | --- |
| `enable` | 管理機能を有効化 | 真偽値 | 有効化 | `true` |
| `eps_complete` | 話数補完を有効化 | 真偽値 | 話数補完 | `false` |
| `rename_method` | リネーム方式 | 文字列 | リネーム方式 | `pn` |
| `group_tag` | グループタグ追加 | 真偽値 | グループタグ追加 | `false` |
| `remove_bad_torrent` | エラーTorrent削除 | 真偽値 | 不良Torrent削除 | `false` |
| `track_orphans` | 未一致Torrentを記録 | 真偽値 | 未一致Torrentを記録 | `true` |
リネーム方式とリビジョン競合ポリシーは Provider の選択として `plugins.slots` に保存されます:`rename_strategy`(リネーム方式、既定値 `pn`)と `conflict_policy`(`hold` / `replace`、既定値 `hold`)。3.3 の `bangumi_manage.rename_method` と `revision_conflict_policy` は、4.0 へのアップグレード後の初回起動時に自動で移行されます。

View File

@@ -97,6 +97,70 @@
- **用户文档**:`docs/{,en/,ja/}config/manager.md` 增加 `template`、`hardlink`(含 `path_map` 与 Docker 下硬链接不能跨文件系统的说明)与 `media-server-refresh` 三节,按本分支的设计重写;`CHANGELOG.md` 增加 P4 条目。
- 移植时发现并修复:vulture 白名单缺少 `rename` / `hardlink` 内置插件的入口(CI 的 vulture 检查会失败);VitePress 不给行内代码加 `v-pre`,文档里行内代码中的 Jinja2 示例在构建时报 `_ctx.pad is not a function`,现在用 `::: v-pre` 容器包住(含本设计文档第 3.6 节与插件开发文档)。
### 实施中的调整(P2.5)
配置模型(第 4.4 节):
- **`downloader` 配置节移出运行时模型**。下载器是 `plugins.instances` 中的 `PluginInstance`(`id`、`point`、`provider`、`options`),第 4.4 节草图中的 `plugin` 字段改为 `point` + `provider`,`default?` 标记改为由 `slots.downloader` 指定默认实例。新配置默认带一个 qB 实例 `default`,默认值与 3.3 相同。
- **`plugins.slots` 是带默认值的类型化模型**(`downloader="default"`、`rename_strategy="pn"`、`conflict_policy="hold"`、`media_files="default"`),不是任意 `dict`,也不接受其它键。原因是 WebUI 需要固定的键来绑定,新安装也要有完整的值。`title_parser` / `metadata_provider` 链等到对应扩展点实施时再加。
- **`plugins` 段的校验**:实例 id 唯一;下载器实例的 `options` 按 `DownloaderOptions`(host / username / password / path / ssl,`$VAR` 展开规则不变)校验;`slots.downloader` 必须指向一个下载器实例。PATCH /config 的请求体不符合时返回 422。
- **`Config.downloader` 保留为只读属性**,返回 `slots.downloader` 所指实例的冻结视图 `DownloaderInstance`(`type` 即 `provider`);`Config.downloader_instance(id)` 按 id 取实例。它不参与序列化,也不接受输入。第一部分的调用方(下载门面、`path.py`、checker、`revision_saga._downloader_type`、`AppContext`)因此不变,`_downloader_type` 的取值也不变(存量 `rename_operation` 行依赖它)。按实例路由在 P2.5 第二部分。
- **秘密字段**:`DownloaderOptions.password` 带 `secret` 标记(与 `secret_field()` 相同;直接写 `Field`,因为 mypy 的 pydantic 插件看不到经 `**kwargs` 传入的别名)。GET / PATCH /config 没有另加按 schema 的实例掩码:现有的按键名掩码已覆盖 `password`,列表项的掩码还原按身份匹配,身份包含实例 `id`。对下载器来说按 schema 掩码的结果完全相同。插件下载器有了自己的 options schema 后再加。
slots 解析:
- `rename_strategy` 读 `slots.rename_strategy`,未登记时仍记录一次日志并按 `none` 处理。`media_files` 读 `slots.media_files`,未登记时退回 `default`。`conflict_policy` 读 `slots.conflict_policy`,未登记时退回 `hold`,不会误删旧版本。后两者不记日志,因为每个文件都会解析一次。
- **冲突策略改为两个 Provider**:第 9 节把 `revision_conflict_policy` 迁到 `slots.conflict_policy`,slot 的值就是 Provider id。因此宿主以 `core` 登记 `hold` 与 `replace` 两个 `CoreConflictPolicy`,不再登记 `default`;`ConflictRequest` 删除 P4 加入的 `configured` 字段。`ab_sdk` 升至 0.5.0。`GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选。
迁移器(第 9 节,`module/update/v4.py`):
- 按源字段是否存在触发:`downloader`、`bangumi_manage.rename_method`、`bangumi_manage.revision_conflict_policy`。字段是「移动」而不是复制,第二次运行看不到源字段,什么也不做,也不会用 4.0 格式的文件覆盖 `.v3.bak`。下载器字段合并到已有的 `default` 实例上,没有时新建。
- `normal → none` 并入 `rename_method → slots.rename_strategy` 的迁移,`Settings._migrate_old_config` 中的对应分支随之删除(第 8.3 节)。掩码哨兵清洗保留在 `_migrate_old_config`。
- 只有需要迁移时才备份。新文件先写到 `<文件名>.tmp` 再 `os.replace`。迁移后的字典先用 `Config.model_validate` 校验;任何失败都从备份恢复原文件,记录 `critical` 日志并抛出 `ConfigMigrationError`。日志与异常消息写明出错字段(取 pydantic 错误的 `loc`),因为此时 `setup_logger` 还没有运行。配置文件本身不是合法 JSON 时由 `json` 直接报错,与之前相同。
- **接线**:迁移器在 `Settings.__init__` 中、`load()` / `save()` 之前调用,所以 `import module.conf` 失败即拒绝启动。`module/update/__init__.py` 原先在包初始化时 import 依赖 `module.conf` 的子模块,`module.conf` 无法 import `module.update.v4`。现在包初始化不 import 任何子模块,6 处调用方改为直接 import 子模块。没有用模块级 `__getattr__` 懒加载,因为 mypy 会把这些名字推断为 `Any`。
- **版本闸门不动**,仍在 `AppContext.startup` 中。P0 发现的「`Settings()` 在版本闸门之前改写 config.json」由备份解决:低于 3.3 的配置仍会先被迁移、再被闸门拒绝,原文件保存在 `config.json.v3.bak`,退回 3.3 时要用它恢复。`Settings` 加载后仍立即保存一次,与之前相同。
- **环境变量**:`ENV_TO_ATTR` 不变,仍按 3.3 的位置写入(`AB_DOWNLOADER_*` / `AB_DOWNLOAD_PATH` → `downloader`,`AB_METHOD` / `AB_REVISION_CONFLICT_POLICY` → `bangumi_manage`),再由同一个 `migrate_v3_dict` 移到默认实例与 slots。设置向导(`/setup/complete`)把下载器写入 `slots.downloader` 所指的实例。
数据库与 WebUI:
- 迁移 v26 为 `bangumi`、`movie`、`rssitem`、`torrent` 增加 `downloader_id`。每张表一个守卫:列已存在,或表不存在(之后由 `create_all` 按模型建表,自带该列)时跳过。取值见下文第二部分(规则、订阅与电影为空表示跟随默认实例,种子默认 `default`)。
- WebUI:「下载器设置」编辑 `slots.downloader` 所指的实例(`provider` 与 `options`),「番剧管理设置」中的重命名方式与版本冲突策略绑定到 `plugins.slots`。两个分区的未保存标记都以 `plugins` 配置段判断,所以修改其中一个,两个分区都会显示未保存。插件卡片保存后 `refreshGroup('plugins')` 会用服务端的值覆盖整个 `plugins` 段,包括尚未保存的下载器与 slots 修改(之前只影响插件 options)。
- 用户文档 `docs/{,en/,ja/}config/{downloader,manager}.md` 与插件开发文档改为新的配置位置。
第二部分(路由、整理与 WebUI):
- **`DownloadClient(instance_id=None)`**,`None` 为默认实例。客户端缓存与凭据闩锁按实例 id 分开;引用计数本来按具体客户端对象计,不变。第 3.5 节的统一工厂 `create(config: PluginConfig)` 未实施:工厂仍接收 P2 契约的 `DownloaderConnection`,实例 `options` 目前就是 `DownloaderOptions` 的字段。
- **`DownloaderPool`**:一次操作(一轮 RSS、整季补全)内按实例 id 惰性进入 `DownloadClient`,退出时全部释放。进入失败的实例在这次操作内记住,之后直接抛 `ConnectionError`,不再重复登录。`RSSEngine.refresh_rss` 的参数由 `DownloadClient` 改为 `DownloaderPool`,测试改用 `test.factories.SingleClientPool`(27 处调用)。
- **实例选择**:`resolve_downloader_id(*候选)` 取第一个已配置的实例 id,都为空时用 `slots.downloader`。指向已删除实例的候选记 warning 后跳过,因此删除实例后,选择它的规则与订阅改用默认实例。新种子按 规则 → 订阅 → 默认 选择;由订阅新建的规则在解析时继承订阅的 `downloader_id`(`rss/analyser.py`);手动收集与整季补全按 规则 → 默认。
- **规则、订阅与电影的 `downloader_id` 为空表示跟随默认实例**。第一部分的 v26 给这三张表 `DEFAULT 'default'`,这样「规则 → 订阅」的继承永远走不到订阅,用户改默认实例后存量规则也仍钉在 `default`,与第 3.5 节「为空时用默认实例」矛盾。v26 尚未发布,所以直接修改它:这三张表加列不带默认值(存量为 `NULL`),`torrent` 仍为 `DEFAULT 'default'`(存量种子都在 3.3 的下载器中)。已经运行过第一部分 v26 的开发库需要手动把这三列置空。`BangumiUpdate`、`MovieUpdate`、`RSSUpdate` 加入该字段,`POST /rss/add` 保存它。
- **种子行的 `downloader_id`** 由 `DownloadClient.add_torrent` 写入投递的实例;未匹配的孤儿种子保持 `default`。投递时实例不可用:种子不入库、下一轮重试,不发 `DownloadFailureEvent`(不可用由重命名轮次通知)。
- **重命名逐实例运行**:`manager/renamer.py` 的 `rename_all()` 供 `loops.rename_tick` 与 apply-offset 使用。进入失败(连不上、凭据被拒、Provider 未登记)的实例跳过,其它实例照常处理;`DownloaderUnavailableEvent` 只在「可用 → 不可用」时产生一次(进程内集合),恢复后再次不可用会再通知。`Renamer` 自身抛出的异常仍中断本轮,与之前相同。apply-offset 触发的重命名丢弃事件(之前也丢弃);实例恰好在这一次变为不可用时,这次不可用不会通知。启动等待循环仍只检查默认实例。
- **`DownloaderUnavailableEvent`** 增加 `instance_id`(默认 `"default"`),`dedup_key` 由 `downloader:<host>` 改为 `downloader:<instance_id>`,payload 增加 `instance`。`ab_sdk` 仍为 0.5.0(本阶段未发布)。
- **版本替换事务按实例过滤**:`list_active_replacements(downloader_type)` 只返回本实例的事务。否则实例 A 的轮次在 A 上查不到 B 的新种子,会进入破坏性的 `_recover_missing_replacement`。`_downloader_type()` 改读 `self.client.instance`。
- **offset 查找**:同一 hash 有多条种子行时以本实例的行为准,其它实例的行作为后备。`path_to_bangumi` / `gen_save_path` 增加 `root`(实例的下载目录),重命名与新规则保存目录按实例计算。
- `FileRenamed` / `TorrentOrganized` 带真实的 `downloader_id`;`hardlink` 的 `path_map` 本来就按它取映射,无需改动。
- **规则换实例**:删除规则(删除文件)时按种子行记录的实例加上规则当前的实例逐个删除。更新规则时实例变了:旧种子不移动,原实例上的 qB RSS 规则不改,只按新实例的下载目录重算 `save_path`;实例不变且路径变化时才移动(路径不变时不再连接下载器)。
- **API**:`GET /downloader/torrents` 汇总所有实例、每条带 `downloader_id`,不可用实例跳过,全部不可用时返回 503(原先连接异常直接 500)。暂停 / 恢复 / 删除 / 打标请求带 `downloader_id`(空为默认实例),未知 id 返回 404;自动打标逐实例进行。新增 `GET /downloader/instances`(`id`、`provider` 与默认 id),供规则 / 订阅选择下载器。SSE 的 downloader 帧同样汇总,超时按实例计算。MCP `list_downloads` 汇总所有实例并带 `downloader` 字段,全部不可用时返回空列表(原先抛错)。
- **WebUI**:「下载器设置」列出所有实例(点击编辑、添加、删除、设为默认,默认实例不能删除;新 id 只接受字母、数字、`_`、`-`,这是前端限制)。规则编辑的高级选项与添加订阅新增下载器选择,留空为默认实例,只有一个实例时不显示;实例列表每次打开时请求,设置页增删实例后无需刷新。下载器页在多实例时加「下载器」列,规则 / 孤儿种子列表在已下载的种子上标出实例。批量操作仍按 hash 选择,按所在实例分组请求;同一 hash 同时在两个实例中时会作用于两个实例。
- **`refreshGroup` 只刷新给定字段**:插件卡片保存后只刷新 `plugins.enabled` / `options`,不再覆盖未保存的实例与 slots 修改(第一部分记录的问题)。「下载器设置」与「番剧管理设置」仍共用 `plugins` 未保存标记。
审阅修正(覆盖上文相应条目):
- **保存目录随实例重新生成**:`save_path_for(data, root)`(`downloader/path.py`)在已存的 `save_path` 位于该实例下载目录之下时沿用它,否则按该实例的下载目录重新生成(比较用 `PureWindowsPath`,`\` 与 `/` 都认)。`add_torrent` 每次投递都经过它并写回规则,所以默认实例切换、规则的实例被删除、订阅指向别的实例时,新种子都进目标实例的目录。代价:单实例用户修改下载目录后,存量规则的新种子也进新目录(3.3 沿用旧目录)。只在「属于另一个已配置实例」时重算做不到「实例被删除」的情况,所以按「不在本实例目录之下」判断。
- **按实例匹配与移动**:`TorrentManager` 在每个实例上按两个目录匹配种子:已存的 `save_path`(与之前相同,单实例改过下载目录时仍能找到旧种子),以及 `save_path_for(规则, 该实例目录)`。更新规则且实例不变时,对种子行记录的每个实例(加上规则当前的实例)分别计算新目录并移动。qB RSS 规则只在有匹配种子的实例与规则自己的实例上改写,因为 `rss/setRule` 在别的 qB 上会新建一条启用的自动下载规则。这样「规则未指定实例、种子经订阅进了另一实例」时也会移动。规则自身的实例仍不带订阅后备,因为种子行已记录实际位置。
- **删除逐实例隔离**:一个实例不可用或删除失败时,其它实例照常删除,最后返回 500 并列出失败的实例。删除规则时种子行已先删除,不可用实例上的种子之后没有记录(见第二部分结尾的推迟项)。
- **整季补全与自动打标跳过不可用实例**:`eps_complete` 跳过该规则(不标记 `eps_collect`,下一轮重试),其它规则照常补全并保存;`POST /downloader/torrents/tag/auto` 改用 `DownloaderPool`,不可用实例跳过。
- **版本替换事务的过滤只在多实例时生效**:只有一个下载器实例时 `list_active_replacements(None)` 返回全部事务,与 3.3 相同;否则改了主机地址(键里含主机哈希)后进行中的事务永远不会恢复。多实例下改主机地址仍有这个问题(见推迟项)。
- **迁移备份不覆盖已有备份**:`.v3.bak` 已存在时依次取 `.v3.bak.1`、`.v3.bak.2` …,失败恢复与日志都用本次写的备份。降级回 3.3 再升级时,第一份备份里的真实下载器凭据因此保留。
未实施(推迟):
- 第 10 节验收中的 qb + aria2 并存 **Docker e2e** 未加。以进程内测试替代:两个 mock 实例并列(按规则 / 订阅投递、在另一实例重命名、一个实例不可用、规则换实例后删除),以及 qB 与 aria2 实例各自得到对应后端与不同的 `downloader_type`。Docker 版需要在 `e2e/compose/downloader.yml` 加入固定 digest 的 aria2 镜像。
- aria2 的 gid ↔ 番剧映射表(`database/aria2.py`)不区分实例;两个 aria2 实例的 gid 相同的概率很低,未处理。
- `GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选;插件下载器的 options schema 与按 schema 掩码(见上文)。
- 多实例时修改某个实例的主机地址,该实例上进行中的版本替换事务找不到(`_downloader_type` 键含主机哈希)。可选做法:键改为 `<type>:<instance_id>` 并兼容旧键查询,或按「键不属于任何已配置实例」把孤儿事务交给同类型的唯一实例。
- 删除规则(删除文件)时某个实例不可用:该实例上的种子不删,种子行却已删除,之后无法重试。可选做法:保留失败实例的种子行,或先删种子再删行(与「仅删数据库不依赖下载器」冲突)。
## 1. 背景与目标
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
@@ -643,7 +707,7 @@ organize: downloader.completed → media_files.classify → file_parser
| **P0 清理** | 第 8 节:死代码、3.x 兼容层;`renamer.py` 先做纯搬移式拆分(不改行为) | 生产代码行数减少;vulture CI;测试全绿 |
| **P1 插件运行时 + SDK 骨架** | `ab_sdk` 包(含 `ab_sdk.testing`)、`module/plugin/`(清单、加载、注册表、runner、熔断、EventBus、插件 KV)、`plugins` 配置段、`GET /api/v1/plugins`;SDK 边界测试 | 本地插件可加载、配置、随配置变更重载;已完成。签名目录来源与 LLM 安装器泛化、`dev_mode` 文件监听移到 P2 |
| **P2 迁移已有注册表** | 下载器、通知、LLM、搜索站点、定时任务改为扩展点,内置实现以 `core` 登记;`/api/v1/plugins`(列表、启停、配置、Provider 列表)与 WebUI 插件卡片(JSON Schema 表单);`secret_field` 掩码;插件开发文档 | 已完成;内置行为不变(全量测试)。调整见第 0 节 |
| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 |
| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | 已完成:`plugins.instances` / `slots` 与 3.3 配置迁移器(`update/v4.py`);按实例缓存客户端与路由投递、重命名、删除;重命名逐实例运行,不可用实例跳过并通知;WebUI 多实例管理与规则 / 订阅的下载器选择;单实例行为不变(全量测试)。qb + aria2 Docker e2e 未加,以进程内并存测试替代,调整见第 0 节 |
| **P3 流水线插件化:ingest** | `torrent.filter`、`title.parsed`、`torrent.adding`、`http.request` 钩子;`metadata_provider`(mikan / tmdb 以 `core` 登记);内置插件 `ingest-filters`(包含过滤) | 已完成;无插件时行为不变(全量测试)。`feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`ranker`、`save_path`、size 过滤、按订阅覆盖推迟,见第 0 节 |
| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 已完成:`renamer.py` 拆出 `revision_saga.py`;`rename_strategy` / `media_files` / `conflict_policy` 扩展点与 `file.renamed` / `torrent.organized` 事件;内置插件 `rename`(pn / advance / template,pn / advance / none 输出与 3.3 一致)、`hardlink`(默认停用)与 `media-server-refresh`。`file_parser`、`RenameStrategyContract`、补链设置按钮(P6)推迟,调整见第 0 节 |
| **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 已完成:系统事件上总线、通知中心 SSE 改为事件推送、插件路由 / MCP 工具与资源 / 通知模板;status 等快照类 SSE 仍按节拍采样。调整见第 0 节 |

View File

@@ -28,33 +28,36 @@ describe('Downloader API contract (path + HTTP method)', () => {
it('should POST api/v1/downloader/torrents/pause with hashes when pausing', async () => {
(axios.post as any).mockResolvedValue({ data: mockApiSuccess });
await apiDownloader.pause(['abc123', 'def456']);
await apiDownloader.pause(['abc123', 'def456'], 'default');
expect(axios.post).toHaveBeenCalledWith(
'api/v1/downloader/torrents/pause',
{
hashes: ['abc123', 'def456'],
downloader_id: 'default',
}
);
});
it('should POST api/v1/downloader/torrents/resume with hashes when resuming', async () => {
(axios.post as any).mockResolvedValue({ data: mockApiSuccess });
await apiDownloader.resume(['abc123']);
await apiDownloader.resume(['abc123'], 'nas');
expect(axios.post).toHaveBeenCalledWith(
'api/v1/downloader/torrents/resume',
{
hashes: ['abc123'],
downloader_id: 'nas',
}
);
});
it('should POST api/v1/downloader/torrents/delete with hashes and delete_files when deleting', async () => {
(axios.post as any).mockResolvedValue({ data: mockApiSuccess });
await apiDownloader.deleteTorrents(['abc123'], true);
await apiDownloader.deleteTorrents(['abc123'], 'default', true);
expect(axios.post).toHaveBeenCalledWith(
'api/v1/downloader/torrents/delete',
{
hashes: ['abc123'],
downloader_id: 'default',
delete_files: true,
}
);
@@ -62,11 +65,12 @@ describe('Downloader API contract (path + HTTP method)', () => {
it('should default delete_files to false when deleting without the flag', async () => {
(axios.post as any).mockResolvedValue({ data: mockApiSuccess });
await apiDownloader.deleteTorrents(['abc123']);
await apiDownloader.deleteTorrents(['abc123'], 'default');
expect(axios.post).toHaveBeenCalledWith(
'api/v1/downloader/torrents/delete',
{
hashes: ['abc123'],
downloader_id: 'default',
delete_files: false,
}
);

View File

@@ -1,4 +1,4 @@
import type { QbTorrentInfo } from '#/downloader';
import type { DownloaderInstances, QbTorrentInfo } from '#/downloader';
import type { ApiSuccess } from '#/api';
export const apiDownloader = {
@@ -10,26 +10,37 @@ export const apiDownloader = {
return data!;
},
async pause(hashes: string[]) {
async instances() {
const { data } = await axios.get<DownloaderInstances>(
'api/v1/downloader/instances'
);
return data!;
},
async pause(hashes: string[], downloaderId: string) {
const { data } = await axios.post<ApiSuccess>(
'api/v1/downloader/torrents/pause',
{ hashes }
{ hashes, downloader_id: downloaderId }
);
return data!;
},
async resume(hashes: string[]) {
async resume(hashes: string[], downloaderId: string) {
const { data } = await axios.post<ApiSuccess>(
'api/v1/downloader/torrents/resume',
{ hashes }
{ hashes, downloader_id: downloaderId }
);
return data!;
},
async deleteTorrents(hashes: string[], deleteFiles = false) {
async deleteTorrents(
hashes: string[],
downloaderId: string,
deleteFiles = false
) {
const { data } = await axios.post<ApiSuccess>(
'api/v1/downloader/torrents/delete',
{ hashes, delete_files: deleteFiles }
{ hashes, downloader_id: downloaderId, delete_files: deleteFiles }
);
return data!;
},

View File

@@ -6,6 +6,7 @@ import type { RSS } from '#/rss';
import { rssTemplate } from '#/rss';
import { ruleTemplate } from '#/bangumi';
import { usePluginProviders } from '@/hooks/usePluginProviders';
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
/** v-model show */
const show = defineModel('show', { default: false });
@@ -19,6 +20,8 @@ const rule = defineModel<BangumiRule>('rule', {
default: () => ({ ...ruleTemplate }),
});
const pluginProviders = usePluginProviders();
// 订阅的下载器实例:由它新建的规则继承;留空跟随默认实例
const downloaders = useDownloaderInstances(show);
// 内置元数据源 + 插件登记的元数据源(metadata_provider 扩展点)
const parserTypes = computed(() => [
'tmdb',
@@ -206,6 +209,21 @@ function subscribe() {
class="parser-select"
/>
</div>
<div v-if="downloaders.multiple.value" class="option-item">
<label class="option-label">{{ $t('topbar.add.downloader') }}</label>
<NSelect
v-model:value="rss.downloader_id"
:options="downloaders.options.value"
clearable
:placeholder="
$t('homepage.rule.downloader_default', {
id: downloaders.data.value.default,
})
"
class="parser-select"
/>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { NCheckbox, NSelect, NSpin, useMessage } from 'naive-ui';
import { onKeyStroke } from '@vueuse/core';
import type { BangumiRule, DetectOffsetResponse } from '#/bangumi';
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
const emit = defineEmits<{
(e: 'apply', rule: BangumiRule): void;
@@ -91,6 +92,9 @@ const resolutionOptions = ['2160p', '1080p', '720p'].map((r) => ({
value: r,
}));
// 下载器实例:留空跟随默认实例;只有一个实例时不显示
const downloaders = useDownloaderInstances(show);
const selectMenuProps = { role: 'listbox' } as const;
function selectOptionNodeProps() {
@@ -334,6 +338,29 @@ function emitUnarchive() {
/>
</div>
<div v-if="downloaders.multiple.value" class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.downloader')
}}</label>
<NSelect
v-model:value="localRule.downloader_id"
:options="downloaders.options.value"
role="combobox"
aria-haspopup="listbox"
:menu-props="selectMenuProps"
:node-props="selectOptionNodeProps"
clearable
size="small"
:placeholder="
$t('homepage.rule.downloader_default', {
id: downloaders.data.value.default,
})
"
:aria-label="$t('homepage.rule.downloader')"
class="weekday-select"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.preferred_group')

View File

@@ -3,6 +3,7 @@ import { Close, Delete, FullSelection } from '@icon-park/vue-next';
import { useConfirm } from '@/hooks/useConfirm';
import type { Torrent } from '#/torrent';
import { useTorrentList } from '@/hooks/useTorrentList';
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
const props = defineProps<{
title: string;
@@ -22,6 +23,8 @@ const {
} = useTorrentList(props.loadFn);
const { t } = useI18n();
// 多个下载器实例时标出种子所在的实例
const downloaders = useDownloaderInstances();
const { confirm } = useConfirm();
async function handleDeleteOne(id: number) {
@@ -125,6 +128,11 @@ onActivated(load);
type="success"
:title="$t('homepage.torrents.downloaded')"
/>
<ab-tag
v-if="torrent.downloaded && downloaders.multiple.value"
type="neutral"
:title="torrent.downloader_id"
/>
<ab-tag v-if="torrent.rss_id" type="info" title="RSS" />
<ab-tag
v-else

View File

@@ -129,6 +129,7 @@ async function handleConfirm(bangumi: BangumiRule) {
connection_status: null,
last_checked_at: null,
last_error: null,
downloader_id: null,
};
await apiDownload.subscribe(bangumi, rss);
message.success(t('search.subscribe_success'));

View File

@@ -0,0 +1,154 @@
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import ConfigDownload from '../config-download.vue';
import type { Plugins } from '#/config';
vi.mock('@/hooks/usePluginProviders', () => ({
usePluginProviders: () =>
ref({
downloader: ['transmission'],
notifier: [],
search_site: [],
metadata_provider: [],
rename_strategy: [],
}),
}));
vi.mock('@/hooks/useMyI18n', () => ({
useMyI18n: () => ({ t: (key: string) => key }),
}));
vi.mock('@/hooks/useConfirm', () => ({
useConfirm: () => ({ confirm: () => Promise.resolve(true) }),
}));
function instance(id: string) {
return {
id,
point: 'downloader',
provider: 'qbittorrent',
options: {
host: `${id}:8080`,
username: '',
password: '',
path: '',
ssl: false,
},
};
}
const state = ref({} as Plugins);
vi.mock('@/store/config', () => ({
useConfigStore: () => ({ getSettingGroup: () => state }),
}));
const AbSettingStub = defineComponent({
name: 'AbSettingStub',
props: {
data: { type: [String, Boolean], default: undefined },
label: { type: [String, Function], required: true },
prop: { type: Object, default: undefined },
type: { type: String, required: true },
},
emits: ['update:data'],
template: '<div class="setting-stub"></div>',
});
function mountIt() {
return mount(ConfigDownload, {
global: {
stubs: {
'ab-fold-panel': { template: '<section><slot /></section>' },
'ab-setting': AbSettingStub,
'ab-button': {
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>',
},
'ab-input': {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
},
},
});
}
function rows(wrapper: ReturnType<typeof mountIt>) {
return wrapper
.findAll('[data-instance]')
.map((r) => r.attributes('data-instance'));
}
describe('config-download', () => {
beforeEach(() => {
state.value = {
slots: { downloader: 'default' },
instances: [instance('default'), instance('nas')],
} as unknown as Plugins;
});
it('should list every downloader instance and mark the default', () => {
const wrapper = mountIt();
expect(rows(wrapper)).toEqual(['default', 'nas']);
expect(wrapper.find('[data-instance="default"]').text()).toContain(
'config.downloader_set.default'
);
});
it('should offer plugin downloaders in the provider dropdown', () => {
const wrapper = mountIt();
const type = wrapper
.findAllComponents(AbSettingStub)
.find(
(s) =>
(s.props('label') as () => string)() === 'config.downloader_set.type'
);
expect(type?.props('prop')?.items).toEqual([
'qbittorrent',
'aria2',
'transmission',
]);
});
it('should add an instance with a new id and edit it', async () => {
const wrapper = mountIt();
await wrapper.find('[data-new-instance] input').setValue('seedbox');
await wrapper.find('[data-action="add"]').trigger('click');
expect(rows(wrapper)).toEqual(['default', 'nas', 'seedbox']);
expect(state.value.instances[2].point).toBe('downloader');
expect(wrapper.find('[data-instance="seedbox"]').classes()).toContain(
'is-editing'
);
});
it('should not add an instance whose id already exists', async () => {
const wrapper = mountIt();
await wrapper.find('[data-new-instance] input').setValue('nas');
await wrapper.find('[data-action="add"]').trigger('click');
expect(rows(wrapper)).toEqual(['default', 'nas']);
});
it('should set another instance as the default', async () => {
const wrapper = mountIt();
await wrapper
.find('[data-instance="nas"] [data-action="set-default"]')
.trigger('click');
expect(state.value.slots.downloader).toBe('nas');
});
it('should delete a non-default instance but offer no delete for the default', async () => {
const wrapper = mountIt();
expect(
wrapper.find('[data-instance="default"] [data-action="delete"]').exists()
).toBe(false);
await wrapper
.find('[data-instance="nas"] [data-action="delete"]')
.trigger('click');
await nextTick();
await nextTick();
expect(rows(wrapper)).toEqual(['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,62 @@
<script lang="ts" setup>
import { Info } from '@icon-park/vue-next';
import type { Downloader, DownloaderType } from '#/config';
import { Delete, Info, Plus } from '@icon-park/vue-next';
import type { DownloaderOptions, DownloaderType } from '#/config';
import type { SettingItem } from '#/components';
import { useConfirm } from '@/hooks/useConfirm';
import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t } = useMyI18n();
const { confirm } = useConfirm();
const { getSettingGroup } = useConfigStore();
const downloader = getSettingGroup('downloader');
const plugins = getSettingGroup('plugins');
const downloaders = computed(() =>
plugins.value.instances.filter((i) => i.point === 'downloader')
);
// 正在编辑的实例,默认为默认实例(plugins.slots.downloader)
const editingId = ref(plugins.value.slots.downloader);
const downloader = computed(
() =>
downloaders.value.find((i) => i.id === editingId.value) ??
downloaders.value.find((i) => i.id === plugins.value.slots.downloader)
);
const newId = ref('');
const canAdd = computed(
() =>
/^[\w-]+$/.test(newId.value) &&
!plugins.value.instances.some((i) => i.id === newId.value)
);
function addInstance() {
if (!canAdd.value) return;
plugins.value.instances.push({
id: newId.value,
point: 'downloader',
provider: 'qbittorrent',
options: {
host: '',
username: '',
password: '',
path: '/downloads/Bangumi',
ssl: false,
},
});
editingId.value = newId.value;
newId.value = '';
}
async function removeInstance(id: string) {
const ok = await confirm({
title: t('config.downloader_set.delete'),
body: t('config.downloader_set.delete_confirm', { id }),
confirmText: t('config.downloader_set.delete'),
danger: true,
});
if (!ok) return;
plugins.value.instances = plugins.value.instances.filter((i) => i.id !== id);
if (editingId.value === id) editingId.value = plugins.value.slots.downloader;
}
const builtinTypes: DownloaderType = ['qbittorrent', 'aria2'];
const pluginProviders = usePluginProviders();
// 插件提供的下载器 id 追加在内置选项之后
@@ -16,16 +65,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,23 +113,141 @@ 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 class="instance-list">
<div
v-for="i in downloaders"
:key="i.id"
:data-instance="i.id"
class="instance-row"
:class="{ 'is-editing': i.id === downloader?.id }"
>
<ab-button
variant="ghost"
size="sm"
class="instance-main"
:aria-pressed="i.id === downloader?.id"
@click="editingId = i.id"
>
<span class="instance-id">{{ i.id }}</span>
<span class="instance-meta"
>{{ i.provider }} · {{ i.options.host }}</span
>
</ab-button>
<span v-if="i.id === plugins.slots.downloader" class="instance-default">
{{ $t('config.downloader_set.default') }}
</span>
<template v-else>
<ab-button
size="sm"
variant="ghost"
data-action="set-default"
@click="plugins.slots.downloader = i.id"
>
{{ $t('config.downloader_set.set_default') }}
</ab-button>
<ab-button
size="sm"
variant="ghost"
data-action="delete"
:aria-label="$t('config.downloader_set.delete')"
@click="removeInstance(i.id)"
>
<Delete size="16" />
</ab-button>
</template>
</div>
<div class="instance-add" data-new-instance>
<ab-input
v-model="newId"
:placeholder="$t('config.downloader_set.new_id')"
:aria-label="$t('config.downloader_set.new_id')"
@keyup.enter="addInstance"
/>
<ab-button
size="sm"
data-action="add"
:disabled="!canAdd"
@click="addInstance"
>
<Plus size="16" />
{{ $t('config.downloader_set.add') }}
</ab-button>
</div>
</div>
<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>
</template>
<style lang="scss" scoped>
.instance-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
}
.instance-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: var(--radius-sm);
border-left: 2px solid transparent;
&.is-editing {
background: var(--color-surface-2);
border-left-color: var(--color-primary);
}
}
.instance-main {
flex: 1;
min-width: 0;
justify-content: flex-start;
gap: 12px;
color: var(--color-text);
}
.instance-id {
font-weight: 500;
}
.instance-meta,
.instance-default {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.instance-add {
display: flex;
gap: 8px;
margin-top: 4px;
}
.downloader-hint {
display: flex;
align-items: center;

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

@@ -48,7 +48,8 @@ async function run(key: string, action: () => Promise<PluginsOverview>) {
busy.value = key;
try {
apply(await action());
await refreshGroup('plugins');
// 只刷新插件卡片保存的字段,保留未保存的下载器实例与 slots 修改
await refreshGroup('plugins', ['enabled', 'options']);
// 插件启停会增减下载器/通知渠道候选,同步刷新下拉框
await refreshPluginProviders();
return true;

View File

@@ -0,0 +1,33 @@
import type { Ref } from 'vue';
import type { DownloaderInstances } from '#/downloader';
import { apiDownloader } from '@/api/downloader';
/**
* 下载器实例列表:规则 / 订阅的下载器选择与种子列表的实例标注。
* 给出 ``open`` 时每次打开(变为 true)请求一次,否则组件创建时请求一次,
* 设置页增删实例后无需刷新页面;失败时保持空列表,按单实例处理。
*/
export function useDownloaderInstances(open?: Ref<boolean>) {
const data = ref<DownloaderInstances>({ default: '', instances: [] });
function load() {
apiDownloader.instances().then(
(value) => {
data.value = value;
},
() => {}
);
}
if (open) {
watch(open, (value) => value && load(), { immediate: true });
} else {
load();
}
const multiple = computed(() => data.value.instances.length > 1);
const options = computed(() =>
data.value.instances.map((i) => ({
label: `${i.id} · ${i.provider}`,
value: i.id,
}))
);
return { data, multiple, options };
}

View File

@@ -19,7 +19,13 @@
"ssl": "SSL",
"title": "Downloader Setting",
"type": "Downloader Type",
"username": "Username"
"username": "Username",
"add": "Add",
"default": "default",
"set_default": "Set default",
"delete": "Delete downloader",
"delete_confirm": "Delete downloader \"{id}\"? Rules that use it switch to the default downloader. Torrents already in it are not touched.",
"new_id": "New downloader id"
},
"llm_set": {
"api_key": "API Key",
@@ -216,7 +222,8 @@
"size": "Size",
"dlspeed": "DL Speed",
"upspeed": "UP Speed",
"peers": "Seeds/Peers"
"peers": "Seeds/Peers",
"instance": "Downloader"
},
"state": {
"downloading": "Downloading",
@@ -289,7 +296,9 @@
"season": "Season",
"year": "Year",
"yes_btn": "Yes",
"view_torrents": "View torrents"
"view_torrents": "View torrents",
"downloader": "Downloader",
"downloader_default": "Default ({id})"
},
"others": {
"title": "Unmatched torrents"
@@ -677,7 +686,8 @@
"placeholder_name": "Optional",
"rss_link": "RSS Link",
"subscribe": "Subscribe",
"title": "Add RSS"
"title": "Add RSS",
"downloader": "Downloader"
},
"pause": "Pause",
"profile": {

View File

@@ -19,7 +19,13 @@
"ssl": "SSL",
"title": "下载设置",
"type": "下载器类型",
"username": "用户名"
"username": "用户名",
"add": "添加",
"default": "默认",
"set_default": "设为默认",
"delete": "删除下载器",
"delete_confirm": "删除下载器「{id}」?使用它的规则改用默认下载器,其中已有的种子不受影响。",
"new_id": "新下载器 id"
},
"llm_set": {
"api_key": "API 密钥",
@@ -216,7 +222,8 @@
"size": "大小",
"dlspeed": "下载速度",
"upspeed": "上传速度",
"peers": "做种/下载"
"peers": "做种/下载",
"instance": "下载器"
},
"state": {
"downloading": "下载中",
@@ -289,7 +296,9 @@
"season": "季度",
"year": "年份",
"yes_btn": "是",
"view_torrents": "查看种子"
"view_torrents": "查看种子",
"downloader": "下载器",
"downloader_default": "默认({id})"
},
"others": {
"title": "未匹配种子"
@@ -677,7 +686,8 @@
"placeholder_name": "可选",
"rss_link": "RSS 链接",
"subscribe": "订阅",
"title": "添加 RSS"
"title": "添加 RSS",
"downloader": "下载器"
},
"pause": "暂停",
"profile": {

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

@@ -3,6 +3,7 @@ import { type DataTableColumns, NDataTable } from 'naive-ui';
import AbProgress from '@/components/basic/ab-progress.vue';
import { useConfirm } from '@/hooks/useConfirm';
import type { QbTorrentInfo, TorrentGroup } from '#/downloader';
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
definePage({
name: 'Downloader',
@@ -34,7 +35,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();
@@ -120,6 +123,9 @@ function isGroupAllSelected(group: TorrentGroup): boolean {
return group.torrents.every((t) => selectedHashes.value.includes(t.hash));
}
// 多个下载器实例时标出每个种子所在的实例
const downloaders = useDownloaderInstances();
const tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [
{
type: 'selection',
@@ -130,6 +136,16 @@ const tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [
ellipsis: { tooltip: true },
minWidth: 200,
},
...(downloaders.multiple.value
? [
{
title: t('downloader.torrent.instance'),
key: 'downloader_id',
width: 110,
ellipsis: { tooltip: true },
},
]
: []),
{
title: t('downloader.torrent.progress'),
key: 'progress',

View File

@@ -33,7 +33,7 @@ describe('config store refreshGroup', () => {
store.config.program.rss_time = 1234;
server.plugins.enabled = { demo: true };
vi.mocked(apiConfig.getConfig).mockResolvedValue(clone(server));
await store.refreshGroup('plugins');
await store.refreshGroup('plugins', ['enabled', 'options']);
expect(store.config.plugins.enabled).toEqual({ demo: true });
expect(store.config.program.rss_time).toBe(1234);
@@ -41,4 +41,29 @@ describe('config store refreshGroup', () => {
expect(store.dirtyGroups).toContain('program');
expect(store.dirtyGroups).not.toContain('plugins');
});
it('should keep unsaved downloader and slot edits when only plugin fields are refreshed', async () => {
const server = clone(initConfig);
vi.mocked(apiConfig.getConfig).mockResolvedValue(clone(server));
const store = useConfigStore();
await store.getConfig();
store.config.plugins.slots.downloader = 'nas';
store.config.plugins.instances.push({
...store.config.plugins.instances[0],
id: 'nas',
});
server.plugins.enabled = { demo: true };
vi.mocked(apiConfig.getConfig).mockResolvedValue(clone(server));
await store.refreshGroup('plugins', ['enabled', 'options']);
expect(store.config.plugins.enabled).toEqual({ demo: true });
expect(store.config.plugins.slots.downloader).toBe('nas');
expect(store.config.plugins.instances.map((i) => i.id)).toEqual([
'default',
'nas',
]);
// 实例与 slots 的修改仍未保存
expect(store.dirtyGroups).toContain('plugins');
});
});

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { apiDownloader } from '@/api/downloader';
import { useDownloaderStore } from '@/store/downloader';
import type { QbTorrentInfo } from '#/downloader';
vi.mock('@/api/downloader', () => ({
apiDownloader: {
getTorrents: vi.fn().mockResolvedValue([]),
pause: vi.fn().mockResolvedValue({}),
resume: vi.fn().mockResolvedValue({}),
deleteTorrents: vi.fn().mockResolvedValue({}),
},
}));
vi.mock('@/hooks/useEventStream', () => ({
useEventStream: () => ({ downloaderData: ref(null) }),
}));
vi.mock('@/hooks/useApi', () => ({
useApi: (api: (...args: unknown[]) => Promise<unknown>) => ({
execute: (...args: unknown[]) => api(...args),
}),
}));
function torrent(hash: string, downloaderId: string) {
return { hash, downloader_id: downloaderId } as QbTorrentInfo;
}
describe('downloader store', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should send one request per downloader instance for the selected torrents', async () => {
const store = useDownloaderStore();
store.torrents = [
torrent('a1', 'default'),
torrent('b1', 'nas'),
torrent('a2', 'default'),
torrent('a3', 'default'),
];
store.selectedHashes = ['a1', 'b1', 'a2'];
await store.pauseSelected();
await store.deleteSelected(true);
expect(vi.mocked(apiDownloader.pause).mock.calls).toEqual([
[['a1', 'a2'], 'default'],
[['b1'], 'nas'],
]);
expect(vi.mocked(apiDownloader.deleteTorrents).mock.calls).toEqual([
[['a1', 'a2'], 'default', true],
[['b1'], 'nas', true],
]);
});
});

View File

@@ -43,11 +43,19 @@ export const useConfigStore = defineStore('config', () => {
/**
* 某个配置段在别处(如插件卡片走 /plugins 接口)被直接保存后,用服务端的值
* 同时刷新编辑态与快照,避免之后的全局保存用旧值把它覆盖回去。
* 只刷新 ``fields``,段内其它未保存的修改(如下载器实例)保留。
*/
async function refreshGroup<Tkey extends keyof Config>(key: Tkey) {
async function refreshGroup<Tkey extends keyof Config>(
key: Tkey,
fields: (keyof Config[Tkey])[]
) {
const res = await apiConfig.getConfig();
config.value[key] = res[key];
savedConfig.value[key] = JSON.parse(JSON.stringify(res[key]));
for (const field of fields) {
config.value[key][field] = res[key][field];
savedConfig.value[key][field] = JSON.parse(
JSON.stringify(res[key][field])
);
}
}
function getSettingGroup<Tkey extends keyof Config>(key: Tkey) {

View File

@@ -71,17 +71,37 @@ export const useDownloaderStore = defineStore('downloader', () => {
},
};
/** 选中的种子按所在下载器实例分组,每个实例发一次请求 */
async function perInstance<T>(
run: (hashes: string[], downloaderId: string) => Promise<T>
) {
const byInstance = new Map<string, string[]>();
for (const t of torrents.value) {
if (!selectedHashes.value.includes(t.hash)) continue;
const hashes = byInstance.get(t.downloader_id) ?? [];
hashes.push(t.hash);
byInstance.set(t.downloader_id, hashes);
}
let result: T | undefined;
for (const [downloaderId, hashes] of byInstance) {
result = await run(hashes, downloaderId);
}
return result;
}
const { execute: pauseSelected } = useApi(
() => apiDownloader.pause(selectedHashes.value),
() => perInstance(apiDownloader.pause),
opts
);
const { execute: resumeSelected } = useApi(
() => apiDownloader.resume(selectedHashes.value),
() => perInstance(apiDownloader.resume),
opts
);
const { execute: deleteSelected } = useApi(
(deleteFiles = false) =>
apiDownloader.deleteTorrents(selectedHashes.value, deleteFiles),
perInstance((hashes, downloaderId) =>
apiDownloader.deleteTorrents(hashes, downloaderId, deleteFiles)
),
opts
);

View File

@@ -53,6 +53,7 @@ export const mockBangumiAPI: BangumiAPI = {
preferred_group: null,
preferred_resolution: null,
episode_type: 'episode',
downloader_id: null,
};
export const mockBangumiRule: BangumiRule = {
@@ -95,6 +96,7 @@ export const mockRSSItem: RSS = {
connection_status: null,
last_checked_at: null,
last_error: null,
downloader_id: null,
};
export const mockRSSList: RSS[] = [
@@ -117,14 +119,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 +128,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 +160,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

@@ -30,6 +30,8 @@ export interface BangumiRule {
preferred_group: string | null;
preferred_resolution: string | null;
episode_type: 'episode' | 'movie' | 'special';
/** 下载器实例 id;null 为默认实例 */
downloader_id: string | null;
}
export interface BangumiAPI extends Omit<BangumiRule, 'filter' | 'rss_link'> {
@@ -73,6 +75,7 @@ export const ruleTemplate: BangumiRule = {
preferred_group: null,
preferred_resolution: null,
episode_type: 'episode',
downloader_id: null,
};
/** Legacy offset suggestion (for backward compatibility) */

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 },
},
],
},
};

View File

@@ -33,6 +33,15 @@ export interface QbTorrentInfo {
category: string;
save_path: string;
added_on: number;
/** 种子所在的下载器实例 id */
downloader_id: string;
}
/** 下载器实例列表(GET /downloader/instances) */
export interface DownloaderInstances {
/** 默认实例 id(plugins.slots.downloader) */
default: string;
instances: { id: string; provider: string }[];
}
export interface TorrentGroup {

View File

@@ -8,6 +8,8 @@ export interface RSS {
connection_status: string | null;
last_checked_at: string | null;
last_error: string | null;
/** 由此订阅新建的规则继承;null 为默认实例 */
downloader_id: string | null;
}
export const rssTemplate: RSS = {
@@ -20,4 +22,5 @@ export const rssTemplate: RSS = {
connection_status: null,
last_checked_at: null,
last_error: null,
downloader_id: null,
};

View File

@@ -7,4 +7,6 @@ export interface Torrent {
bangumi_id: number | null;
rss_id: number | null;
qb_hash: string | null;
/** 种子实际添加到的下载器实例 */
downloader_id: string;
}