mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 21:55:22 +08:00
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:
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -60,7 +60,7 @@ MESSAGE_TEMPLATE = "message_template"
|
||||
# Provider 扩展点:
|
||||
#
|
||||
# - ``RENAME_STRATEGY``:返回 :class:`ab_sdk.rename.RenameStrategy`,id 即
|
||||
# ``bangumi_manage.rename_method`` 的取值。宿主自带 ``none``(不改名),
|
||||
# ``plugins.slots.rename_strategy`` 的取值。宿主自带 ``none``(不改名),
|
||||
# 内置插件 ``rename`` 提供 ``pn``、``advance``、``template``
|
||||
# - ``MEDIA_FILES``:返回 :class:`ab_sdk.rename.MediaFiles`
|
||||
# - ``CONFLICT_POLICY``:返回 :class:`ab_sdk.rename.ConflictPolicy`
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
扩展点一览(常量见 :mod:`ab_sdk.points`):
|
||||
|
||||
- ``rename_strategy``(Provider):返回 :class:`RenameStrategy`,id 即设置项
|
||||
``bangumi_manage.rename_method`` 的取值
|
||||
- ``rename_strategy``(Provider):返回 :class:`RenameStrategy`
|
||||
- ``media_files``(Provider):返回 :class:`MediaFiles`,判断种子内文件的类别
|
||||
- ``conflict_policy``(Provider):返回 :class:`ConflictPolicy`,目标路径已被
|
||||
另一个种子占用时决定保留还是替换
|
||||
|
||||
``media_files`` 与 ``conflict_policy`` 目前只使用宿主自带的实现(id
|
||||
:data:`CORE_ID`),插件实现的选择随 P2.5 的 ``plugins.slots`` 一起提供。
|
||||
三者都按 ``plugins.slots`` 中同名的键选择 Provider id。宿主自带 ``none``
|
||||
(重命名)、:data:`CORE_ID`(文件分类)与 ``hold`` / ``replace``(冲突策略);
|
||||
选中的 id 未登记(插件停用或被熔断)时退回宿主实现。
|
||||
|
||||
整理完成后宿主发布 :class:`ab_sdk.events.FileRenamed` 与
|
||||
:class:`ab_sdk.events.TorrentOrganized`,插件用 ``@subscribe`` 接收。
|
||||
@@ -22,7 +22,7 @@ FileKind = Literal["media", "subtitle"]
|
||||
MediaKind = Literal["media", "subtitle", "ignore"]
|
||||
ConflictAction = Literal["hold", "replace"]
|
||||
|
||||
# media_files / conflict_policy 的宿主实现 id
|
||||
# media_files 的宿主实现 id
|
||||
CORE_ID = "default"
|
||||
|
||||
|
||||
@@ -114,8 +114,6 @@ class ConflictRequest:
|
||||
target_path: str
|
||||
incoming: RevisionTask
|
||||
owners: tuple[RevisionTask, ...]
|
||||
# 用户在设置中选择的版本冲突策略(bangumi_manage.revision_conflict_policy)
|
||||
configured: ConflictAction
|
||||
# 唯一占用者与新种子是同一发布、且新种子版本号更高
|
||||
strict_upgrade: bool
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -405,8 +405,13 @@ async def complete_setup(
|
||||
|
||||
# 2. Update configuration
|
||||
config_dict = settings.dict()
|
||||
config_dict["downloader"] = {
|
||||
"type": req.downloader_type,
|
||||
# 向导填写的下载器写入默认实例(slots.downloader 指向的实例)
|
||||
plugins = config_dict["plugins"]
|
||||
default = next(
|
||||
i for i in plugins["instances"] if i["id"] == plugins["slots"]["downloader"]
|
||||
)
|
||||
default["provider"] = req.downloader_type
|
||||
default["options"] = {
|
||||
"host": req.downloader_host,
|
||||
"username": req.downloader_username,
|
||||
"password": req.downloader_password,
|
||||
|
||||
@@ -21,7 +21,7 @@ from module.conf import settings
|
||||
from module.core import AppContext
|
||||
from module.notification import UpdateAppliedEvent
|
||||
from module.security.api import get_current_user
|
||||
from module.update import updater
|
||||
from module.update.updater import updater
|
||||
|
||||
from .deps import get_context
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import semver
|
||||
|
||||
from module.conf import VERSION, settings
|
||||
from module.models import Config
|
||||
from module.update import version_check
|
||||
from module.update.version_check import version_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from module.models.config import Config
|
||||
from module.update.v4 import migrate_v3_config, migrate_v3_dict
|
||||
|
||||
from .const import ENV_TO_ATTR
|
||||
|
||||
@@ -68,9 +69,9 @@ CONFIG_PATH = (
|
||||
class Settings(Config):
|
||||
"""Runtime configuration singleton.
|
||||
|
||||
On construction, loads from ``CONFIG_PATH`` if the file exists (and
|
||||
immediately re-saves to apply any migrations), otherwise bootstraps
|
||||
defaults from environment variables via ``init()``.
|
||||
On construction, migrates a 3.3 ``CONFIG_PATH`` to 4.0 (see
|
||||
``module.update.v4``), loads it and immediately re-saves it; otherwise
|
||||
bootstraps defaults from environment variables via ``init()``.
|
||||
|
||||
Use ``settings`` module-level instance rather than instantiating directly.
|
||||
"""
|
||||
@@ -78,6 +79,8 @@ class Settings(Config):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
if CONFIG_PATH.exists():
|
||||
# 必须先于 load / save:否则 3.3 的下载器等字段会被当作未知字段丢弃
|
||||
migrate_v3_config(CONFIG_PATH)
|
||||
self.load()
|
||||
self.save()
|
||||
else:
|
||||
@@ -95,12 +98,7 @@ class Settings(Config):
|
||||
|
||||
@staticmethod
|
||||
def _migrate_old_config(config: dict) -> dict:
|
||||
"""把 3.3.x 的配置改写为当前格式(更早版本的迁移已随 4.0 移除)。"""
|
||||
bangumi_manage = config.get("bangumi_manage", {})
|
||||
# "normal" 早已是无操作的废弃方法,语义等同 "none"
|
||||
if bangumi_manage.get("rename_method") == "normal":
|
||||
bangumi_manage["rename_method"] = "none"
|
||||
|
||||
"""清洗字面写入的掩码哨兵(3.3 → 4.0 的字段迁移见 ``module.update.v4``)。"""
|
||||
_scrub_corrupted_masks(config)
|
||||
|
||||
return config
|
||||
@@ -125,15 +123,17 @@ class Settings(Config):
|
||||
for key, section in ENV_TO_ATTR.items():
|
||||
for env, attr in section.items():
|
||||
if env in os.environ:
|
||||
section = config_dict.setdefault(key, {})
|
||||
if isinstance(attr, list):
|
||||
for _attr in attr:
|
||||
attr_name = _attr[0] if isinstance(_attr, tuple) else _attr
|
||||
config_dict[key][attr_name] = self.__val_from_env(
|
||||
env, _attr
|
||||
)
|
||||
section[attr_name] = self.__val_from_env(env, _attr)
|
||||
else:
|
||||
attr_name = attr[0] if isinstance(attr, tuple) else attr
|
||||
config_dict[key][attr_name] = self.__val_from_env(env, attr)
|
||||
section[attr_name] = self.__val_from_env(env, attr)
|
||||
# ENV_TO_ATTR 按 3.3 的位置写入(AB_DOWNLOADER_* → downloader、
|
||||
# AB_METHOD → bangumi_manage),再由迁移器移到默认下载器实例与 slots
|
||||
migrate_v3_dict(config_dict)
|
||||
config_obj = Config.model_validate(config_dict)
|
||||
self.__dict__.update(config_obj.__dict__)
|
||||
logger.debug("Config loaded from env")
|
||||
|
||||
@@ -23,12 +23,8 @@ from module.plugin import PluginManager
|
||||
from module.plugin.host import get_registry, set_bus, set_runner
|
||||
from module.rss import RSSAnalyser
|
||||
from module.searcher.searcher import reset_cache as reset_poster_cache
|
||||
from module.update import (
|
||||
cache_image,
|
||||
first_run,
|
||||
migrate_legacy_auth_tokens,
|
||||
run_migrations,
|
||||
)
|
||||
from module.update.auth import migrate_legacy_auth_tokens
|
||||
from module.update.startup import cache_image, first_run, run_migrations
|
||||
|
||||
from .loops import (
|
||||
calendar_tick,
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 由迁移列表派生,新增迁移时无需手动同步
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -17,6 +17,8 @@ class Torrent(SQLModel, table=True):
|
||||
homepage: Optional[str] = Field(None, alias="homepage")
|
||||
downloaded: bool = Field(False, alias="downloaded")
|
||||
qb_hash: Optional[str] = Field(None, alias="qb_hash", index=True)
|
||||
# 种子实际添加到的下载器实例
|
||||
downloader_id: str = Field("default", alias="downloader_id")
|
||||
|
||||
|
||||
class EpisodeFile(BaseModel):
|
||||
|
||||
@@ -25,7 +25,9 @@ logger = logging.getLogger(__name__)
|
||||
CORE = "core"
|
||||
|
||||
POINTS = (
|
||||
ExtensionPoint(points.DOWNLOADER, "provider", "下载器后端(downloader.type)"),
|
||||
ExtensionPoint(
|
||||
points.DOWNLOADER, "provider", "下载器后端(plugins.instances[].provider)"
|
||||
),
|
||||
ExtensionPoint(
|
||||
points.NOTIFIER, "provider", "通知渠道(notification.providers[].type)"
|
||||
),
|
||||
@@ -54,7 +56,9 @@ POINTS = (
|
||||
ExtensionPoint(points.MESSAGE_TEMPLATE, "transform", "系统事件通知文案"),
|
||||
# --- P4 organize ---
|
||||
ExtensionPoint(
|
||||
points.RENAME_STRATEGY, "provider", "重命名方式(bangumi_manage.rename_method)"
|
||||
points.RENAME_STRATEGY,
|
||||
"provider",
|
||||
"重命名方式(plugins.slots.rename_strategy)",
|
||||
),
|
||||
ExtensionPoint(points.MEDIA_FILES, "provider", "种子内文件分类(正片 / 字幕)"),
|
||||
ExtensionPoint(points.CONFLICT_POLICY, "provider", "目标路径被占用时保留或替换"),
|
||||
@@ -189,7 +193,8 @@ def _register_core(registry: ExtensionRegistry) -> None:
|
||||
|
||||
_core(registry, points.RENAME_STRATEGY, NO_RENAME, NoRename())
|
||||
_core(registry, points.MEDIA_FILES, CORE_ID, SuffixMediaFiles())
|
||||
_core(registry, points.CONFLICT_POLICY, CORE_ID, CoreConflictPolicy())
|
||||
_core(registry, points.CONFLICT_POLICY, "hold", CoreConflictPolicy("hold"))
|
||||
_core(registry, points.CONFLICT_POLICY, "replace", CoreConflictPolicy("replace"))
|
||||
|
||||
|
||||
# --- P5 events/api ---------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,会形成循环。
|
||||
|
||||
134
backend/src/module/update/v4.py
Normal file
134
backend/src/module/update/v4.py
Normal 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
|
||||
@@ -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"] == (
|
||||
|
||||
@@ -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
|
||||
|
||||
99
backend/src/test/fixtures/config_v3.3.json
vendored
Normal file
99
backend/src/test/fixtures/config_v3.3.json
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"program": {
|
||||
"rss_time": 1800,
|
||||
"rename_time": 60,
|
||||
"webui_port": 7892
|
||||
},
|
||||
"downloader": {
|
||||
"type": "qbittorrent",
|
||||
"host": "$QB_HOST",
|
||||
"username": "ab",
|
||||
"password": "qb-secret",
|
||||
"path": "/media/downloads/Bangumi",
|
||||
"ssl": true
|
||||
},
|
||||
"rss_parser": {
|
||||
"enable": true,
|
||||
"filter": [
|
||||
"720",
|
||||
"\\d+-\\d+"
|
||||
],
|
||||
"language": "zh",
|
||||
"engine": "classic"
|
||||
},
|
||||
"bangumi_manage": {
|
||||
"enable": true,
|
||||
"eps_complete": false,
|
||||
"rename_method": "advance",
|
||||
"group_tag": true,
|
||||
"remove_bad_torrent": false,
|
||||
"revision_conflict_policy": "replace",
|
||||
"track_orphans": true
|
||||
},
|
||||
"log": {
|
||||
"debug_enable": false
|
||||
},
|
||||
"network": {
|
||||
"tmdb_base_url": "https://api.themoviedb.org",
|
||||
"tmdb_api_key": "",
|
||||
"bgm_base_url": "https://api.bgm.tv"
|
||||
},
|
||||
"proxy": {
|
||||
"enable": false,
|
||||
"type": "http",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"notification": {
|
||||
"enable": true,
|
||||
"providers": [
|
||||
{
|
||||
"type": "telegram",
|
||||
"enabled": true,
|
||||
"token": "tg-token",
|
||||
"chat_id": "42"
|
||||
}
|
||||
],
|
||||
"base_url": "",
|
||||
"type": null,
|
||||
"token": null,
|
||||
"chat_id": null
|
||||
},
|
||||
"llm": {
|
||||
"enable": false,
|
||||
"provider": "openai",
|
||||
"api_key": "",
|
||||
"model": "gpt-5-mini",
|
||||
"base_url": "",
|
||||
"mode": "fallback",
|
||||
"timeout": 20.0,
|
||||
"cache_ttl": 900,
|
||||
"max_concurrency": 2,
|
||||
"failure_threshold": 3,
|
||||
"failure_backoff": 300,
|
||||
"providers": {}
|
||||
},
|
||||
"experimental_openai": {
|
||||
"enable": false,
|
||||
"api_key": "",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_type": "openai",
|
||||
"api_version": "2023-05-15",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"deployment_id": ""
|
||||
},
|
||||
"security": {
|
||||
"login_whitelist": [],
|
||||
"login_tokens": [],
|
||||
"mcp_whitelist": [],
|
||||
"mcp_tokens": [],
|
||||
"webauthn_rp_id": "",
|
||||
"webauthn_origin": ""
|
||||
},
|
||||
"update": {
|
||||
"channel": "stable",
|
||||
"auto_check": true
|
||||
}
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from module.conf.config import Settings
|
||||
from module.models.config import (
|
||||
Config,
|
||||
Downloader,
|
||||
DownloaderOptions,
|
||||
NotificationProvider,
|
||||
RSSParser,
|
||||
Security,
|
||||
@@ -61,10 +61,10 @@ class TestConfigDefaults:
|
||||
"""BangumiManage has correct default values."""
|
||||
config = Config()
|
||||
assert config.bangumi_manage.enable is True
|
||||
assert config.bangumi_manage.rename_method == "pn"
|
||||
assert config.plugins.slots.rename_strategy == "pn"
|
||||
assert config.bangumi_manage.group_tag is False
|
||||
assert config.bangumi_manage.remove_bad_torrent is False
|
||||
assert config.bangumi_manage.revision_conflict_policy == "hold"
|
||||
assert config.plugins.slots.conflict_policy == "hold"
|
||||
assert config.bangumi_manage.eps_complete is False
|
||||
|
||||
def test_proxy_defaults(self):
|
||||
@@ -85,14 +85,65 @@ class TestConfigDefaults:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginInstances:
|
||||
@pytest.mark.parametrize(
|
||||
"plugins",
|
||||
[
|
||||
{"slots": {"downloader": "missing"}},
|
||||
{
|
||||
"instances": [
|
||||
{"id": "default", "point": "downloader", "provider": "qb"},
|
||||
{"id": "default", "point": "downloader", "provider": "aria2"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"instances": [
|
||||
{
|
||||
"id": "default",
|
||||
"point": "downloader",
|
||||
"provider": "qbittorrent",
|
||||
"options": {"ssl": "sometimes"},
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_invalid_downloader_instances_rejected(self, plugins):
|
||||
with pytest.raises(ValueError):
|
||||
Config.model_validate({"plugins": plugins})
|
||||
|
||||
def test_downloader_view_follows_slot(self):
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"plugins": {
|
||||
"slots": {"downloader": "nas"},
|
||||
"instances": [
|
||||
{"id": "default", "point": "downloader", "provider": "qb"},
|
||||
{
|
||||
"id": "nas",
|
||||
"point": "downloader",
|
||||
"provider": "aria2",
|
||||
"options": {"host": "nas:6800"},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert (config.downloader.id, config.downloader.type) == ("nas", "aria2")
|
||||
assert config.downloader.host == "nas:6800"
|
||||
# 未填写的字段取默认值
|
||||
assert config.downloader.path == "/downloads/Bangumi"
|
||||
|
||||
|
||||
class TestConfigSerialization:
|
||||
def test_dict_uses_alias(self):
|
||||
"""Config.dict() uses field aliases (by_alias=True)."""
|
||||
config = Config()
|
||||
d = config.dict()
|
||||
# Downloader uses alias 'host' not 'host_'
|
||||
assert "host" in d["downloader"]
|
||||
assert "host_" not in d["downloader"]
|
||||
# 下载器实例的 options 用别名 host 而不是 host_
|
||||
options = d["plugins"]["instances"][0]["options"]
|
||||
assert "host" in options
|
||||
assert "host_" not in options
|
||||
|
||||
def test_roundtrip_json(self, tmp_path):
|
||||
"""Config can be serialized to JSON and loaded back."""
|
||||
@@ -128,18 +179,6 @@ class TestMigrateOldConfig:
|
||||
assert result["program"]["rss_time"] == 900
|
||||
assert result["program"]["rename_time"] == 60
|
||||
|
||||
def test_normal_rename_method_becomes_none(self):
|
||||
"""废弃的 normal 重命名方法迁移为语义相同的 none。"""
|
||||
config = {"bangumi_manage": {"rename_method": "normal"}}
|
||||
result = Settings._migrate_old_config(config)
|
||||
assert result["bangumi_manage"]["rename_method"] == "none"
|
||||
|
||||
@pytest.mark.parametrize("method", ["pn", "advance", "none"])
|
||||
def test_supported_rename_methods_untouched(self, method):
|
||||
config = {"bangumi_manage": {"rename_method": method}}
|
||||
result = Settings._migrate_old_config(config)
|
||||
assert result["bangumi_manage"]["rename_method"] == method
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings.load from file
|
||||
@@ -191,7 +230,8 @@ class TestSettingsLoad:
|
||||
with open(config_file) as f:
|
||||
data = json.load(f)
|
||||
assert "program" in data
|
||||
assert "downloader" in data
|
||||
assert "downloader" not in data
|
||||
assert data["plugins"]["instances"][0]["provider"] == "qbittorrent"
|
||||
# 新配置文件包含 llm 段
|
||||
assert "llm" in data
|
||||
assert data["rss_parser"]["engine"] == "classic"
|
||||
@@ -222,18 +262,34 @@ class TestSettingsLoad:
|
||||
|
||||
|
||||
class TestEnvOverrides:
|
||||
def test_downloader_host_from_env(self, tmp_path):
|
||||
"""AB_DOWNLOADER_HOST env var overrides downloader host."""
|
||||
def test_downloader_env_maps_onto_default_instance(self, tmp_path):
|
||||
"""AB_DOWNLOADER_* / AB_DOWNLOAD_PATH / AB_METHOD 写入默认下载器实例与 slots。"""
|
||||
config_file = tmp_path / "config.json"
|
||||
|
||||
env = {"AB_DOWNLOADER_HOST": "192.168.1.100:9090"}
|
||||
env = {
|
||||
"AB_DOWNLOADER_HOST": "192.168.1.100:9090",
|
||||
"AB_DOWNLOADER_USERNAME": "ab",
|
||||
"AB_DOWNLOADER_PASSWORD": "pw",
|
||||
"AB_DOWNLOAD_PATH": "/data/Bangumi",
|
||||
"AB_METHOD": "Advance",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch("module.conf.config.CONFIG_PATH", config_file):
|
||||
s = Settings.__new__(Settings)
|
||||
Config.__init__(s)
|
||||
s.init()
|
||||
|
||||
assert "192.168.1.100:9090" in s.downloader.host
|
||||
assert (s.downloader.host, s.downloader.username, s.downloader.password) == (
|
||||
"192.168.1.100:9090",
|
||||
"ab",
|
||||
"pw",
|
||||
)
|
||||
assert s.downloader.path == "/data/Bangumi"
|
||||
assert s.downloader.type == "qbittorrent"
|
||||
assert s.plugins.slots.rename_strategy == "advance"
|
||||
saved = json.loads(config_file.read_text())
|
||||
assert "downloader" not in saved
|
||||
assert len(saved["plugins"]["instances"]) == 1
|
||||
|
||||
def test_rss_parser_engine_from_env(self, tmp_path):
|
||||
"""AB_RSS_PARSER_ENGINE selects a supported parser engine."""
|
||||
@@ -260,7 +316,7 @@ class TestEnvOverrides:
|
||||
Config.__init__(s)
|
||||
s.init()
|
||||
|
||||
assert s.bangumi_manage.revision_conflict_policy == "replace"
|
||||
assert s.plugins.slots.conflict_policy == "replace"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -401,26 +457,26 @@ class TestNotificationProviders:
|
||||
|
||||
class TestDownloaderEnvExpansion:
|
||||
def test_host_expands_env_var(self, monkeypatch):
|
||||
"""Downloader.host expands $VAR references."""
|
||||
"""DownloaderOptions.host expands $VAR references."""
|
||||
monkeypatch.setenv("QB_HOST", "192.168.5.10:8080")
|
||||
d = Downloader(host="$QB_HOST")
|
||||
d = DownloaderOptions(host="$QB_HOST")
|
||||
assert d.host == "192.168.5.10:8080"
|
||||
|
||||
def test_username_expands_env_var(self, monkeypatch):
|
||||
"""Downloader.username expands $VAR references."""
|
||||
"""DownloaderOptions.username expands $VAR references."""
|
||||
monkeypatch.setenv("QB_USER", "myuser")
|
||||
d = Downloader(username="$QB_USER")
|
||||
d = DownloaderOptions(username="$QB_USER")
|
||||
assert d.username == "myuser"
|
||||
|
||||
def test_password_expands_env_var(self, monkeypatch):
|
||||
"""Downloader.password expands $VAR references."""
|
||||
"""DownloaderOptions.password expands $VAR references."""
|
||||
monkeypatch.setenv("QB_PASS", "s3cret")
|
||||
d = Downloader(password="$QB_PASS")
|
||||
d = DownloaderOptions(password="$QB_PASS")
|
||||
assert d.password == "s3cret"
|
||||
|
||||
def test_literal_host_not_expanded(self):
|
||||
"""Literal host strings without $ are returned as-is."""
|
||||
d = Downloader(host="localhost:8080")
|
||||
d = DownloaderOptions(host="localhost:8080")
|
||||
assert d.host == "localhost:8080"
|
||||
|
||||
|
||||
@@ -446,7 +502,7 @@ class TestDefaultSettings:
|
||||
assert Config().rss_parser.engine == "classic"
|
||||
|
||||
def test_revision_conflict_policy_defaults_to_hold(self):
|
||||
assert Config().bangumi_manage.revision_conflict_policy == "hold"
|
||||
assert Config().plugins.slots.conflict_policy == "hold"
|
||||
|
||||
|
||||
class TestNetworkBaseUrls:
|
||||
|
||||
@@ -9,7 +9,7 @@ from module.conf import settings as real_settings
|
||||
from module.core.context import AppContext
|
||||
from module.models import ResponseModel
|
||||
from module.notification import DownloaderUnavailableEvent
|
||||
from module.update import UnsupportedUpgradeError
|
||||
from module.update.version_check import UnsupportedUpgradeError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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)
|
||||
|
||||
532
backend/src/test/test_multi_downloader.py
Normal file
532
backend/src/test/test_multi_downloader.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 的新登记项
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
147
backend/src/test/test_update_v4.py
Normal file
147
backend/src/test/test_update_v4.py
Normal 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()
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` 的模板生成。在 **设置 → 插件 → 重命名** 中修改「文件名模板」,默认值为:
|
||||
|
||||
@@ -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 提供商凭据失效 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 へのアップグレード後の初回起動時に自動で移行されます。
|
||||
|
||||
@@ -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 节 |
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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!;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
154
webui/src/components/setting/__tests__/config-download.test.ts
Normal file
154
webui/src/components/setting/__tests__/config-download.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -22,16 +22,18 @@ vi.mock('@/store/config', async () => {
|
||||
const manageState = {
|
||||
enable: true,
|
||||
eps_complete: false,
|
||||
rename_method: 'pn',
|
||||
revision_conflict_policy: 'hold',
|
||||
group_tag: false,
|
||||
remove_bad_torrent: false,
|
||||
track_orphans: true,
|
||||
};
|
||||
const pluginsState = {
|
||||
slots: { rename_strategy: 'pn', conflict_policy: 'hold' },
|
||||
};
|
||||
return {
|
||||
__manageState: manageState,
|
||||
__pluginsState: pluginsState,
|
||||
useConfigStore: () => ({
|
||||
getSettingGroup: () => computed(() => manageState),
|
||||
getSettingGroup: (key: string) =>
|
||||
computed(() => (key === 'plugins' ? pluginsState : manageState)),
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -87,9 +89,9 @@ describe('config-manage', () => {
|
||||
await policy.vm.$emit('update:data', 'replace');
|
||||
await nextTick();
|
||||
const store = (await import('@/store/config')) as unknown as {
|
||||
__manageState: { revision_conflict_policy: string };
|
||||
__pluginsState: { slots: { conflict_policy: string } };
|
||||
};
|
||||
expect(store.__manageState.revision_conflict_policy).toBe('replace');
|
||||
expect(store.__pluginsState.slots.conflict_policy).toBe('replace');
|
||||
});
|
||||
|
||||
it('should append plugin rename strategies when the rename plugin provides them', () => {
|
||||
|
||||
@@ -1,13 +1,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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
webui/src/hooks/useDownloaderInstances.ts
Normal file
33
webui/src/hooks/useDownloaderInstances.ts
Normal 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 };
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
57
webui/src/store/__tests__/downloader.test.ts
Normal file
57
webui/src/store/__tests__/downloader.test.ts
Normal 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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -7,4 +7,6 @@ export interface Torrent {
|
||||
bangumi_id: number | null;
|
||||
rss_id: number | null;
|
||||
qb_hash: string | null;
|
||||
/** 种子实际添加到的下载器实例 */
|
||||
downloader_id: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user