mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
Merge remote-tracking branch 'origin/v3' into codex/manual-source-classification
This commit is contained in:
@@ -4,13 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, cast
|
||||
|
||||
from app.adapters.network.http import RequestUtils
|
||||
@@ -18,6 +21,7 @@ from app.adapters.system.resource import ResourceHelper, get_resource_versions
|
||||
from app.foundation.environment import is_docker
|
||||
from app.foundation.singleton import SingletonClass
|
||||
from app.foundation.version import compare_version
|
||||
from app.runtime.dependencies.profile import runtime_sync_arguments
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.runtime.thread import ThreadHelper
|
||||
@@ -79,7 +83,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
|
||||
@property
|
||||
def _install_file(self) -> Path:
|
||||
"""返回启动器消费的安装意图文件路径。"""
|
||||
"""返回 Docker root worker 或本地 CLI 消费的安装意图文件路径。"""
|
||||
return self._root / "install.json"
|
||||
|
||||
@property
|
||||
@@ -97,6 +101,33 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
"""返回站点资源包暂存目录。"""
|
||||
return self._root / "resources"
|
||||
|
||||
@property
|
||||
def _docker_app_dir(self) -> Path:
|
||||
"""返回 Docker 当前后端源码目录。"""
|
||||
return Path(get_runtime_setting("ROOT_PATH"))
|
||||
|
||||
@property
|
||||
def _docker_public_dir(self) -> Path:
|
||||
"""返回 Docker 当前前端静态文件目录。"""
|
||||
return Path(get_runtime_setting("FRONTEND_PATH"))
|
||||
|
||||
@property
|
||||
def _docker_pending_file(self) -> Path:
|
||||
"""返回 Docker 载荷切换事务标记路径。"""
|
||||
return Path(get_runtime_setting("TEMP_PATH")) / "__update_pending__"
|
||||
|
||||
@property
|
||||
def _docker_previous_app_dir(self) -> Path:
|
||||
"""返回 Docker 更新前后端源码备份目录。"""
|
||||
app_dir = self._docker_app_dir
|
||||
return app_dir.with_name(f"{app_dir.name}.__update_previous__")
|
||||
|
||||
@property
|
||||
def _docker_previous_public_dir(self) -> Path:
|
||||
"""返回 Docker 更新前前端静态文件备份目录。"""
|
||||
public_dir = self._docker_public_dir
|
||||
return public_dir.with_name(f"{public_dir.name}.__update_previous__")
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
"""返回 UTC ISO 时间戳。"""
|
||||
@@ -261,7 +292,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
return min(100, int(downloaded_value * 100 / total_value))
|
||||
|
||||
def get_status(self) -> SystemUpdateStatus:
|
||||
"""返回状态快照,并在新进程中收敛已完成的安装状态。"""
|
||||
"""收敛安装状态并返回快照;提醒开关实时读取,避免缓存绕过关闭设置。"""
|
||||
with self._lock:
|
||||
state = self._read_state()
|
||||
changed = False
|
||||
@@ -303,6 +334,8 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
state = self._persist_state(self._sync_aggregate(state))
|
||||
else:
|
||||
state = self._sync_aggregate(state)
|
||||
state["auto_update"] = get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") is True
|
||||
state["auto_update_resource"] = get_runtime_setting("AUTO_UPDATE_RESOURCE") is True
|
||||
return cast(SystemUpdateStatus, SystemUpdateStatus.model_validate(state))
|
||||
|
||||
def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool:
|
||||
@@ -343,6 +376,16 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
}
|
||||
)
|
||||
|
||||
def check_scheduled(self) -> SystemUpdateStatus:
|
||||
"""按实时开关分别检查主程序和资源,避免热重载前的排队任务越过关闭设置。"""
|
||||
for target, setting in (
|
||||
(_APPLICATION, "MOVIEPILOT_AUTO_UPDATE"),
|
||||
(_RESOURCES, "AUTO_UPDATE_RESOURCE"),
|
||||
):
|
||||
if get_runtime_setting(setting) is True:
|
||||
self.check(target)
|
||||
return self.get_status()
|
||||
|
||||
def check(self, target: SystemUpdateType | None = None) -> SystemUpdateStatus:
|
||||
"""检查主程序和站点资源更新,定时检查失败只记录在对应明细中。"""
|
||||
targets = (target,) if target else _TARGETS
|
||||
@@ -504,10 +547,11 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
return self.get_status()
|
||||
|
||||
def request_install(self, target: SystemUpdateType = _APPLICATION) -> tuple[bool, str]:
|
||||
"""校验指定待安装制品,并写入启动阶段消费的安装意图。"""
|
||||
"""校验指定待安装制品,并写入 Docker worker 消费的安装意图。"""
|
||||
if target not in _TARGETS:
|
||||
return False, f"未知升级类型:{target}"
|
||||
with self._lock:
|
||||
temporary: Path | None = None
|
||||
state = self.get_status()
|
||||
item = self._get_item(state.model_dump(), target)
|
||||
if item["state"] != "ready":
|
||||
@@ -529,15 +573,477 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
if target not in targets:
|
||||
targets.append(target)
|
||||
prepared["targets"] = targets
|
||||
self._install_file.write_text(
|
||||
self._install_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self._install_file.with_suffix(f".tmp.{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
temporary.replace(self._install_file)
|
||||
self._write_item(target, state="installing", can_install=False, error=None)
|
||||
return True, message
|
||||
except (OSError, RuntimeError, json.JSONDecodeError) as error:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self._write_item(target, state="failed", error=str(error), can_install=False)
|
||||
return False, str(error)
|
||||
|
||||
def apply_prepared_update(self) -> tuple[bool, str]:
|
||||
"""由 Docker root 更新 worker 将已确认制品替换到当前运行目录。"""
|
||||
if not is_docker():
|
||||
return False, "当前运行环境不是 Docker"
|
||||
|
||||
targets: set[SystemUpdateType] = set()
|
||||
with self._lock:
|
||||
try:
|
||||
prepared = self._read_install_manifest()
|
||||
targets = self._prepared_targets(prepared)
|
||||
if not targets:
|
||||
raise RuntimeError("更新清单缺少可安装目标")
|
||||
if _APPLICATION in targets:
|
||||
self._validate_application_manifest(prepared)
|
||||
version = str(prepared.get("version") or "")
|
||||
frontend_version = str(prepared.get("frontend_version") or "")
|
||||
if self._validate_backend_archive(version) != frontend_version:
|
||||
raise RuntimeError("后端更新包声明的前端版本不匹配")
|
||||
self._validate_frontend_archive(frontend_version)
|
||||
if _RESOURCES in targets:
|
||||
self._validate_resource_manifest(prepared)
|
||||
|
||||
if _APPLICATION in targets:
|
||||
self._apply_docker_application(
|
||||
prepared,
|
||||
include_resources=_RESOURCES in targets,
|
||||
)
|
||||
elif _RESOURCES in targets:
|
||||
self._apply_docker_resources(prepared)
|
||||
|
||||
try:
|
||||
for target in (_APPLICATION, _RESOURCES):
|
||||
if target in targets:
|
||||
self._consume_prepared_target(target)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
# 载荷已经替换成功,清单清理失败不能阻止 worker 通知入口重启。
|
||||
logger.warning(f"更新载荷已替换,但清理下载清单失败:{error}")
|
||||
try:
|
||||
self._install_file.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning(f"清理 Docker 更新安装清单失败:{error}")
|
||||
return True, "已下载的更新已替换到 Docker 程序目录"
|
||||
except (OSError, RuntimeError, ValueError, zipfile.BadZipFile) as error:
|
||||
message = f"Docker 更新包替换失败:{error}"
|
||||
self._mark_install_failed(targets, message)
|
||||
logger.error(message)
|
||||
return False, message
|
||||
|
||||
def _read_install_manifest(self) -> dict[str, Any]:
|
||||
"""读取必须存在的 Docker 更新安装清单。"""
|
||||
payload = json.loads(self._install_file.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("更新安装清单格式无效")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _prepared_targets(prepared: dict[str, Any]) -> set[SystemUpdateType]:
|
||||
"""解析更新清单中的主程序和站点资源安装目标。"""
|
||||
raw_targets = prepared.get("targets")
|
||||
if raw_targets is not None and not isinstance(raw_targets, list):
|
||||
raise RuntimeError("更新清单目标格式无效")
|
||||
if isinstance(raw_targets, list) and any(
|
||||
not isinstance(target, str) or target not in _TARGETS
|
||||
for target in raw_targets
|
||||
):
|
||||
raise RuntimeError("更新清单包含未知安装目标")
|
||||
targets = {
|
||||
cast(SystemUpdateType, target)
|
||||
for target in raw_targets or []
|
||||
}
|
||||
if not targets and prepared.get("backend_archive"):
|
||||
targets.add(_APPLICATION)
|
||||
return targets
|
||||
|
||||
def _mark_install_failed(
|
||||
self, targets: set[SystemUpdateType], message: str
|
||||
) -> None:
|
||||
"""记录 Docker 更新失败并撤销本次安装意图,保留下载包供重试。"""
|
||||
try:
|
||||
self._install_file.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning(f"清理 Docker 更新安装清单失败:{error}")
|
||||
if not targets:
|
||||
try:
|
||||
state = self._read_state()
|
||||
targets = {
|
||||
cast(SystemUpdateType, item["type"])
|
||||
for item in state.get("updates", [])
|
||||
if item.get("state") == "installing" and item.get("type") in _TARGETS
|
||||
}
|
||||
except Exception as error: # noqa: BLE001 失败路径只记录,不能遮蔽原始错误
|
||||
logger.warning(f"读取待安装更新状态失败:{error}")
|
||||
for target in targets:
|
||||
try:
|
||||
self._write_item(
|
||||
target,
|
||||
state="failed",
|
||||
error=message,
|
||||
can_update=True,
|
||||
can_install=False,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 失败路径不得遮蔽原始安装错误
|
||||
logger.error(f"记录 {target} 更新失败状态失败:{error}")
|
||||
|
||||
def _consume_prepared_target(self, target: SystemUpdateType) -> None:
|
||||
"""从持久化下载清单移除已替换目标,保留另一类下载制品。"""
|
||||
prepared = self._read_prepared_manifest_optional()
|
||||
if target == _APPLICATION:
|
||||
for key in (
|
||||
"version",
|
||||
"frontend_version",
|
||||
"backend_archive",
|
||||
"frontend_archive",
|
||||
"backend_sha256",
|
||||
"frontend_sha256",
|
||||
):
|
||||
prepared.pop(key, None)
|
||||
elif target == _RESOURCES:
|
||||
for key in ("resource_package_version", "resource_files"):
|
||||
prepared.pop(key, None)
|
||||
else:
|
||||
raise ValueError(f"未知升级类型:{target}")
|
||||
|
||||
targets = [
|
||||
value
|
||||
for value in prepared.get("targets", [])
|
||||
if value in _TARGETS and value != target
|
||||
]
|
||||
if targets:
|
||||
prepared["targets"] = targets
|
||||
else:
|
||||
prepared.pop("targets", None)
|
||||
|
||||
prepared_file = self._root / "prepared.json"
|
||||
if prepared.get("backend_archive") or prepared.get("resource_files"):
|
||||
temporary = prepared_file.with_suffix(f".tmp.{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
temporary.replace(prepared_file)
|
||||
else:
|
||||
prepared_file.unlink(missing_ok=True)
|
||||
|
||||
def _set_docker_pending(self, state: str) -> None:
|
||||
"""原子写入 Docker 载荷切换状态,供入口脚本在异常重启时恢复。"""
|
||||
self._docker_pending_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self._docker_pending_file.with_suffix(f".tmp.{os.getpid()}")
|
||||
temporary.write_text(f"{state}\n", encoding="utf-8")
|
||||
temporary.replace(self._docker_pending_file)
|
||||
|
||||
def _clear_docker_pending(self) -> None:
|
||||
"""清除已经提交完成的 Docker 载荷切换状态。"""
|
||||
self._docker_pending_file.unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _setting_text(key: str, default: str = "") -> str:
|
||||
"""读取更新 worker 所需的运行配置,并兼容独立 root 子进程环境。"""
|
||||
try:
|
||||
value = get_runtime_setting(key)
|
||||
except AttributeError:
|
||||
value = None
|
||||
return str(value or os.getenv(key, default) or default).strip()
|
||||
|
||||
def _sync_docker_dependencies(self, project_dir: Path, *, force: bool = False) -> bool:
|
||||
"""按新后端清单同步 Docker 共享虚拟环境依赖。"""
|
||||
current_dir = self._docker_app_dir
|
||||
if not force and all(
|
||||
(current_dir / name).read_bytes() == (project_dir / name).read_bytes()
|
||||
for name in ("pyproject.toml", "uv.lock")
|
||||
):
|
||||
return False
|
||||
|
||||
venv_path = self._setting_text("VENV_PATH", "/opt/venv")
|
||||
uv_bin = self._setting_text("UV_BIN", "/usr/local/bin/uv")
|
||||
command = [
|
||||
uv_bin,
|
||||
"sync",
|
||||
"--project",
|
||||
str(project_dir),
|
||||
"--locked",
|
||||
"--inexact",
|
||||
"--no-dev",
|
||||
"--no-install-project",
|
||||
"--python",
|
||||
f"{venv_path}/bin/python3",
|
||||
*runtime_sync_arguments(),
|
||||
]
|
||||
package_index = self._setting_text("PIP_PROXY")
|
||||
if package_index:
|
||||
command.extend(("--default-index", package_index))
|
||||
environment = os.environ.copy()
|
||||
proxy = self._setting_text("PROXY_HOST")
|
||||
if proxy:
|
||||
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
environment[key] = proxy
|
||||
environment.update(
|
||||
{
|
||||
"UV_PROJECT_ENVIRONMENT": venv_path,
|
||||
"UV_LINK_MODE": "copy",
|
||||
}
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(project_dir),
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as error:
|
||||
raise RuntimeError(f"依赖同步执行失败:{error}") from error
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"依赖同步失败,退出码:{result.returncode}")
|
||||
return True
|
||||
|
||||
def _extract_backend_archive(self, archive_path: Path, destination: Path) -> Path:
|
||||
"""安全解压后端 Release,并返回唯一的源码根目录。"""
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive)
|
||||
roots = {
|
||||
PurePosixPath(name).parts[0]
|
||||
for name in archive.namelist()
|
||||
if PurePosixPath(name).parts
|
||||
}
|
||||
if len(roots) != 1:
|
||||
raise RuntimeError("后端更新包源码根目录无效")
|
||||
archive.extractall(destination)
|
||||
source_root = destination / next(iter(roots))
|
||||
if not source_root.is_dir():
|
||||
raise RuntimeError("后端更新包源码目录不存在")
|
||||
return source_root
|
||||
|
||||
def _extract_frontend_archive(self, archive_path: Path, destination: Path) -> Path:
|
||||
"""安全解压前端 dist.zip,并返回静态文件目录。"""
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive)
|
||||
archive.extractall(destination)
|
||||
frontend_dir = destination / "dist"
|
||||
if not frontend_dir.is_dir():
|
||||
raise RuntimeError("前端更新包缺少 dist 目录")
|
||||
return frontend_dir
|
||||
|
||||
@staticmethod
|
||||
def _remove_path(path: Path) -> None:
|
||||
"""删除 Docker 更新事务中的文件或目录。"""
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
shutil.rmtree(path)
|
||||
elif path.exists() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
@staticmethod
|
||||
def _preserve_tree_ownership(source: Path, destination: Path) -> None:
|
||||
"""复制运行时目录后恢复原目录的所有者,避免插件变成 root 不可写。"""
|
||||
source_paths = (source, *source.rglob("*"))
|
||||
for source_path in source_paths:
|
||||
destination_path = destination / source_path.relative_to(source)
|
||||
source_stat = source_path.lstat()
|
||||
os.chown(
|
||||
destination_path,
|
||||
source_stat.st_uid,
|
||||
source_stat.st_gid,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clear_staged_native_resources(resource_dir: Path) -> None:
|
||||
"""清除暂存目录中的旧平台原生站点资源。"""
|
||||
if not resource_dir.is_dir():
|
||||
return
|
||||
for path in resource_dir.iterdir():
|
||||
if path.is_file() and path.name.startswith("sites.") and path.suffix in {
|
||||
".so",
|
||||
".pyd",
|
||||
".dylib",
|
||||
}:
|
||||
path.unlink()
|
||||
|
||||
def _resource_source_dir(self, app_dir: Path) -> Path:
|
||||
"""定位当前后端携带的站点资源目录,并兼容历史目录。"""
|
||||
resource_dir = app_dir / "app" / "application" / "site"
|
||||
for legacy_dir in (
|
||||
app_dir / "app" / "infrastructure",
|
||||
app_dir / "app" / "adapters" / "network",
|
||||
app_dir / "app" / "helper",
|
||||
):
|
||||
if not resource_dir.is_dir() and legacy_dir.is_dir():
|
||||
resource_dir = legacy_dir
|
||||
return resource_dir
|
||||
|
||||
def _copy_prepared_resources(
|
||||
self, prepared: dict[str, Any], resource_dir: Path
|
||||
) -> None:
|
||||
"""把已校验的完整站点资源包复制到指定源码目录。"""
|
||||
self._clear_staged_native_resources(resource_dir)
|
||||
for item in prepared.get("resource_files", []):
|
||||
name = Path(str(item.get("name") or ""))
|
||||
if name.name != str(name):
|
||||
raise RuntimeError("站点资源文件名不安全")
|
||||
shutil.copy2(str(item["path"]), resource_dir / name)
|
||||
|
||||
def _prepare_docker_application(
|
||||
self,
|
||||
prepared: dict[str, Any],
|
||||
temporary_root: Path,
|
||||
*,
|
||||
include_resources: bool,
|
||||
) -> tuple[Path, Path]:
|
||||
"""解压并组装待切换的 Docker 后端、前端和插件资源载荷。"""
|
||||
backend_extract = temporary_root / "backend"
|
||||
frontend_extract = temporary_root / "frontend"
|
||||
backend_extract.mkdir()
|
||||
frontend_extract.mkdir()
|
||||
source_app = self._extract_backend_archive(
|
||||
Path(str(prepared["backend_archive"])), backend_extract
|
||||
)
|
||||
source_public = self._extract_frontend_archive(
|
||||
Path(str(prepared["frontend_archive"])), frontend_extract
|
||||
)
|
||||
stage_app = temporary_root / "App"
|
||||
stage_public = temporary_root / "public"
|
||||
source_app.replace(stage_app)
|
||||
source_public.replace(stage_public)
|
||||
|
||||
current_app = self._docker_app_dir
|
||||
current_plugins = current_app / "app" / "plugins"
|
||||
stage_plugins = stage_app / "app" / "plugins"
|
||||
if stage_plugins.exists() or stage_plugins.is_symlink():
|
||||
self._remove_path(stage_plugins)
|
||||
if current_plugins.is_dir():
|
||||
shutil.copytree(current_plugins, stage_plugins, symlinks=True)
|
||||
self._preserve_tree_ownership(current_plugins, stage_plugins)
|
||||
else:
|
||||
stage_plugins.mkdir(parents=True, exist_ok=True)
|
||||
if not (stage_plugins / "__init__.py").is_file():
|
||||
raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口")
|
||||
|
||||
stage_resources = stage_app / "app" / "application" / "site"
|
||||
if stage_resources.exists() or stage_resources.is_symlink():
|
||||
self._remove_path(stage_resources)
|
||||
current_resources = self._resource_source_dir(current_app)
|
||||
if current_resources.is_dir():
|
||||
shutil.copytree(current_resources, stage_resources, symlinks=True)
|
||||
else:
|
||||
stage_resources.mkdir(parents=True, exist_ok=True)
|
||||
if include_resources:
|
||||
self._copy_prepared_resources(prepared, stage_resources)
|
||||
return stage_app, stage_public
|
||||
|
||||
def _restore_docker_payload(self) -> None:
|
||||
"""在 Docker 载荷切换失败时恢复更新前的源码和前端目录。"""
|
||||
for current, previous in (
|
||||
(self._docker_app_dir, self._docker_previous_app_dir),
|
||||
(self._docker_public_dir, self._docker_previous_public_dir),
|
||||
):
|
||||
if not previous.exists():
|
||||
continue
|
||||
if current.exists() or current.is_symlink():
|
||||
self._remove_path(current)
|
||||
previous.replace(current)
|
||||
|
||||
def _apply_docker_application(
|
||||
self, prepared: dict[str, Any], *, include_resources: bool
|
||||
) -> None:
|
||||
"""原子替换 Docker 后端源码和前端静态目录,并同步依赖。"""
|
||||
app_dir = self._docker_app_dir
|
||||
public_dir = self._docker_public_dir
|
||||
previous_app = self._docker_previous_app_dir
|
||||
previous_public = self._docker_previous_public_dir
|
||||
if not app_dir.is_dir() or not public_dir.is_dir():
|
||||
raise RuntimeError("Docker 当前程序目录不完整")
|
||||
if previous_app.exists() or previous_public.exists():
|
||||
raise RuntimeError("存在未完成的 Docker 更新事务")
|
||||
|
||||
with TemporaryDirectory(prefix=".moviepilot-update-", dir=str(app_dir.parent)) as temp:
|
||||
temporary_root = Path(temp)
|
||||
stage_app, stage_public = self._prepare_docker_application(
|
||||
prepared,
|
||||
temporary_root,
|
||||
include_resources=include_resources,
|
||||
)
|
||||
dependencies_changed = any(
|
||||
(app_dir / name).read_bytes() != (stage_app / name).read_bytes()
|
||||
for name in ("pyproject.toml", "uv.lock")
|
||||
)
|
||||
dependency_sync_started = False
|
||||
self._set_docker_pending("prepared")
|
||||
try:
|
||||
if dependencies_changed:
|
||||
self._set_docker_pending("dependencies")
|
||||
dependency_sync_started = True
|
||||
self._sync_docker_dependencies(stage_app)
|
||||
self._set_docker_pending("prepared")
|
||||
app_dir.replace(previous_app)
|
||||
try:
|
||||
public_dir.replace(previous_public)
|
||||
stage_app.replace(app_dir)
|
||||
stage_public.replace(public_dir)
|
||||
except OSError:
|
||||
self._restore_docker_payload()
|
||||
raise
|
||||
self._set_docker_pending("committed")
|
||||
except Exception:
|
||||
rollback_failed = False
|
||||
try:
|
||||
self._restore_docker_payload()
|
||||
except OSError as error:
|
||||
logger.error(f"Docker 更新回滚失败:{error}")
|
||||
rollback_failed = True
|
||||
if dependency_sync_started:
|
||||
try:
|
||||
self._set_docker_pending("dependencies")
|
||||
self._sync_docker_dependencies(app_dir, force=True)
|
||||
except (OSError, RuntimeError) as error:
|
||||
logger.error(f"Docker 更新依赖回滚失败:{error}")
|
||||
rollback_failed = True
|
||||
if rollback_failed:
|
||||
raise
|
||||
try:
|
||||
self._clear_docker_pending()
|
||||
except OSError as error:
|
||||
logger.error(f"清理 Docker 更新事务标记失败:{error}")
|
||||
raise
|
||||
raise
|
||||
|
||||
try:
|
||||
self._remove_path(previous_app)
|
||||
self._remove_path(previous_public)
|
||||
self._clear_docker_pending()
|
||||
except OSError as error:
|
||||
logger.warning(f"Docker 更新已完成但旧载荷清理失败:{error}")
|
||||
|
||||
def _apply_docker_resources(self, prepared: dict[str, Any]) -> None:
|
||||
"""原子替换 Docker 当前源码携带的站点资源目录。"""
|
||||
resource_dir = self._resource_source_dir(self._docker_app_dir)
|
||||
resource_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with TemporaryDirectory(
|
||||
prefix=".moviepilot-resource-update-", dir=str(resource_dir.parent)
|
||||
) as temp:
|
||||
stage_dir = Path(temp) / "site"
|
||||
if resource_dir.is_dir():
|
||||
shutil.copytree(resource_dir, stage_dir, symlinks=True)
|
||||
else:
|
||||
stage_dir.mkdir()
|
||||
self._copy_prepared_resources(prepared, stage_dir)
|
||||
backup_dir = resource_dir.with_name(f"{resource_dir.name}.__prepared_previous__")
|
||||
self._remove_path(backup_dir)
|
||||
if resource_dir.exists() or resource_dir.is_symlink():
|
||||
resource_dir.replace(backup_dir)
|
||||
try:
|
||||
stage_dir.replace(resource_dir)
|
||||
except OSError:
|
||||
if backup_dir.exists():
|
||||
backup_dir.replace(resource_dir)
|
||||
raise
|
||||
self._remove_path(backup_dir)
|
||||
|
||||
def cancel_install(self, reason: str) -> None:
|
||||
"""重启请求失败时撤销全部已选安装意图,避免下次普通启动意外安装。"""
|
||||
with self._lock:
|
||||
@@ -771,6 +1277,12 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
frontend_archive = Path(str(prepared.get("frontend_archive") or ""))
|
||||
if not prepared.get("version") or not prepared.get("frontend_version"):
|
||||
raise RuntimeError("主程序更新清单缺少版本信息")
|
||||
for archive, expected in (
|
||||
(backend_archive, self._backend_archive),
|
||||
(frontend_archive, self._frontend_archive),
|
||||
):
|
||||
if archive.resolve() != expected.resolve():
|
||||
raise RuntimeError("主程序更新包路径不安全")
|
||||
if not backend_archive.is_file() or self._sha256(backend_archive) != prepared.get("backend_sha256"):
|
||||
raise RuntimeError("后端更新包校验失败")
|
||||
if not frontend_archive.is_file() or self._sha256(frontend_archive) != prepared.get("frontend_sha256"):
|
||||
@@ -785,7 +1297,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
actual_names = {
|
||||
str(item.get("name") or "") for item in files if isinstance(item, dict)
|
||||
}
|
||||
if actual_names != expected_names:
|
||||
if len(files) != len(expected_names) or actual_names != expected_names:
|
||||
raise RuntimeError("站点资源更新清单不是当前平台的完整资源包")
|
||||
root = self._root.resolve()
|
||||
for item in files:
|
||||
|
||||
@@ -413,7 +413,7 @@ class MediaServerOperationTool(_ServiceOperationTool):
|
||||
name: str = "mediaserver_operation"
|
||||
description: str = (
|
||||
"Operate a configured Emby, Jellyfin, Plex, ZSpace, UGREEN, TrimeMedia, "
|
||||
"or Navidrome server. The input schema contains one exact branch per action, "
|
||||
"Navidrome, or MediaVault server. The input schema contains one exact branch per action, "
|
||||
"including providers, effects, required fields, types, defaults, enums, nested "
|
||||
"item fields, and cross-field constraints."
|
||||
)
|
||||
|
||||
@@ -561,8 +561,8 @@ def _execute_manual_transfer(
|
||||
return _SchemaResponse(
|
||||
success=False, message=f"整理记录不存在,ID:{transer_item.logid}"
|
||||
)
|
||||
# 强制转移
|
||||
force = True
|
||||
# 失败历史必须经过整理链的重试/重整判定,不能绕过旧任务直接重新准入。
|
||||
force = bool(history.status)
|
||||
# 下载器与 Hash 是同一组下载上下文,重新识别时由当前文件路径重新匹配。
|
||||
downloader = history.downloader if transer_item.from_history else None
|
||||
download_hash = history.download_hash if transer_item.from_history else None
|
||||
|
||||
@@ -179,6 +179,12 @@ class SchedulerRuntimeConfig:
|
||||
usage_statistic_share: bool
|
||||
site_link: str | None
|
||||
auto_update: bool = False
|
||||
auto_update_resource: bool = True
|
||||
|
||||
@property
|
||||
def update_check_enabled(self) -> bool:
|
||||
"""主程序或资源任一检查开启时保留共享的定时检测服务。"""
|
||||
return self.auto_update or self.auto_update_resource
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -165,7 +165,7 @@ class TemplateContextBuilder:
|
||||
|
||||
会读取 ``context`` 中由 ``_add_episode_details`` 先填好的 ``season`` /
|
||||
``year`` / ``title_year`` 占位,保证电视剧场景下季/年优先沿用 meta 解析值;
|
||||
音乐场景保留文件标签解析出的曲目级字段,仅用识别结果补齐专辑级字段;
|
||||
音乐场景保留曲目级标签;已识别专辑实体时以所选数据源覆盖专辑级字段;
|
||||
通知场景(``aggregate_music_album=True``)下整专批量以专辑为标题主体。
|
||||
"""
|
||||
if not mediainfo:
|
||||
@@ -173,15 +173,14 @@ class TemplateContextBuilder:
|
||||
if isinstance(mediainfo, MusicInfo):
|
||||
# 专辑实体批量下载/入库只发一条通知:标题取专辑名、不展示单曲
|
||||
# 序号;重命名等逐文件场景保持 False,继续使用文件自己的曲名和曲序。
|
||||
is_album_context = (
|
||||
aggregate_music_album
|
||||
and mediainfo.music_type == MUSIC_ENTITY_ALBUM
|
||||
)
|
||||
is_album_entity = mediainfo.music_type == MUSIC_ENTITY_ALBUM
|
||||
is_album_context = aggregate_music_album and is_album_entity
|
||||
has_album_identity = bool(is_album_entity and mediainfo.media_source and mediainfo.media_id)
|
||||
# 专辑场景以识别结果的专辑名为标题;整专年份以识别结果为准,
|
||||
# 逐文件场景沿用 meta 解析年份,保证文件级年份优先。
|
||||
year = (
|
||||
mediainfo.year
|
||||
if (is_album_context and mediainfo.year)
|
||||
if (has_album_identity and mediainfo.year)
|
||||
else (context.get("year") or mediainfo.year)
|
||||
)
|
||||
if is_album_context and mediainfo.album:
|
||||
@@ -196,13 +195,14 @@ class TemplateContextBuilder:
|
||||
artist = context.get("artist") or cls.__convert_invalid_characters(mediainfo.artist)
|
||||
# 标签/目录名自带的尾部年份会被重命名模板的 `({{year}})` 再次追加,
|
||||
# 统一剥离避免生成 "专辑 (2018) (2018)" 这类重复年份目录(issue #6355)
|
||||
album_value = mediainfo.album or mediainfo.title if has_album_identity else context.get("album") or mediainfo.album
|
||||
album = cls.__strip_album_trailing_year(
|
||||
context.get("album") or cls.__convert_invalid_characters(mediainfo.album),
|
||||
cls.__convert_invalid_characters(album_value),
|
||||
year,
|
||||
)
|
||||
album_artist = context.get("album_artist") or cls.__convert_invalid_characters(
|
||||
mediainfo.album_artist
|
||||
)
|
||||
album_artist_value = mediainfo.album_artist or mediainfo.artist \
|
||||
if has_album_identity else context.get("album_artist") or mediainfo.album_artist
|
||||
album_artist = cls.__convert_invalid_characters(album_artist_value)
|
||||
disc_number = context.get("disc_number") or mediainfo.disc_number
|
||||
track_number = (
|
||||
None
|
||||
|
||||
@@ -389,7 +389,7 @@ class SystemService:
|
||||
return SystemOperationResult(status.state != "failed", status.error, status)
|
||||
|
||||
def install_update(self, target: SystemUpdateType = "application") -> SystemOperationResult:
|
||||
"""确认指定制品并在重启失败时回滚安装请求。"""
|
||||
"""确认指定制品并安排 Docker worker 在受管重启前完成替换。"""
|
||||
if not self._control.can_restart():
|
||||
return SystemOperationResult(False, "当前运行环境不支持升级操作!")
|
||||
prepared, message = self._updates.prepare_install(target)
|
||||
|
||||
+18
-2
@@ -243,9 +243,10 @@ def _git_current_branch() -> Optional[str]:
|
||||
|
||||
|
||||
def _auto_update_mode() -> str:
|
||||
"""启动时仅由独立 Dev 开关或一次性更新请求选择开发分支。"""
|
||||
if SystemHelper.consume_one_shot_dev_update():
|
||||
return "dev"
|
||||
return str(get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") or "").strip().lower()
|
||||
return "dev" if get_runtime_setting("MOVIEPILOT_UPDATE_DEV") is True else "false"
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
@@ -490,6 +491,7 @@ def _resolve_auto_update_targets(mode: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _best_effort_auto_update() -> None:
|
||||
"""优先应用已确认的安装包,再按 Dev 跟踪偏好更新;失败不阻断启动。"""
|
||||
if _apply_prepared_release_update():
|
||||
return
|
||||
|
||||
@@ -521,7 +523,7 @@ def _best_effort_auto_update() -> None:
|
||||
str(get_runtime_setting("CONFIG_PATH")),
|
||||
]
|
||||
|
||||
click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新")
|
||||
click.echo("检测到 Dev 跟踪开关或一次性更新请求,启动前执行本地开发版更新")
|
||||
result = subprocess.run(
|
||||
update_command,
|
||||
cwd=str(_repo_root()),
|
||||
@@ -1213,6 +1215,20 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None:
|
||||
click.echo(f"Frontend URL: {_frontend_base_url(frontend_result['runtime'])}")
|
||||
|
||||
|
||||
@cli.command("apply-prepared-update", hidden=True, context_settings=CONTEXT_SETTINGS) # type: ignore[misc]
|
||||
def apply_prepared_update() -> None:
|
||||
"""由 Docker root 更新 worker 应用已确认的下载制品。"""
|
||||
from app.adapters.system.update import system_update_manager
|
||||
from app.foundation.environment import is_docker
|
||||
|
||||
if not is_docker():
|
||||
raise click.ClickException("仅 Docker 更新 worker 可以执行该操作")
|
||||
success, message = system_update_manager.apply_prepared_update()
|
||||
if not success:
|
||||
raise click.ClickException(message)
|
||||
click.echo(message)
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
def status() -> None:
|
||||
"""查看本地 MoviePilot 前后端服务状态"""
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"MediaVaultModule": {
|
||||
"name": "MediaVault"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""
|
||||
模块开关设置,返回开关名和开关值,开关值为True时代表有值即打开,不实现该方法或返回None代表不使用开关
|
||||
部分模块支持同时开启多个,此时设置项以,分隔,开关值使用in判断
|
||||
|
||||
@@ -794,20 +794,25 @@ class TransHandler:
|
||||
cls,
|
||||
*,
|
||||
step_runner: Optional[TransferStepRunner],
|
||||
fileitem: FileItem,
|
||||
source_fileitem: dict[str, Any],
|
||||
target_storage: str,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
target_file: Path,
|
||||
transfer_type: str,
|
||||
) -> tuple[Optional[FileItem], str]:
|
||||
"""执行稳定传输步骤,并把跨存储 move 拆为落地与源删除。"""
|
||||
"""按冻结叶节点执行传输,并把跨存储 move 拆为落地与源删除。
|
||||
|
||||
意图直接消费原始快照,避免旧快照补默认字段或适配器更新运行期文件信息
|
||||
导致步骤身份漂移;每个外部操作单独恢复文件对象。
|
||||
"""
|
||||
fileitem = FileItem(**source_fileitem)
|
||||
cross_storage_move = (
|
||||
transfer_type == "move" and fileitem.storage != target_storage
|
||||
)
|
||||
materialize_type = "copy" if cross_storage_move else transfer_type
|
||||
intent_payload = {
|
||||
"source": fileitem.model_dump(mode="json"),
|
||||
"source": source_fileitem,
|
||||
"target_storage": target_storage,
|
||||
"target_path": target_file.as_posix(),
|
||||
"transfer_type": materialize_type,
|
||||
@@ -853,7 +858,7 @@ class TransHandler:
|
||||
|
||||
def execute_source_delete() -> TransferStepResult:
|
||||
"""在目标已落地后单独删除跨存储 move 的源文件。"""
|
||||
if not source_oper.delete(fileitem):
|
||||
if not source_oper.delete(FileItem(**source_fileitem)):
|
||||
raise RuntimeError(f"{fileitem.path} 源文件删除失败")
|
||||
return TransferStepResult(payload={
|
||||
"source_path": fileitem.path,
|
||||
@@ -865,7 +870,7 @@ class TransHandler:
|
||||
phase="transfer",
|
||||
kind="delete_move_source",
|
||||
payload={
|
||||
"source": fileitem.model_dump(mode="json"),
|
||||
"source": source_fileitem,
|
||||
"target_storage": target_storage,
|
||||
"target_path": target_file.as_posix(),
|
||||
},
|
||||
@@ -998,6 +1003,7 @@ class TransHandler:
|
||||
*,
|
||||
step_runner: Optional[TransferStepRunner],
|
||||
fileitem: FileItem,
|
||||
source_fileitem: dict[str, Any],
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo | MusicInfo,
|
||||
target_oper: StorageBase,
|
||||
@@ -1007,7 +1013,7 @@ class TransHandler:
|
||||
overwrite_mode: Optional[str],
|
||||
need_notify: bool,
|
||||
) -> tuple[bool, bool, Optional[TransferInfo]]:
|
||||
"""冻结覆盖策略判定,避免目标变化后重启得到不同步骤序列。"""
|
||||
"""以原始源快照冻结覆盖判定,避免模型补字段改变旧任务的步骤身份。"""
|
||||
def execute() -> TransferStepResult:
|
||||
"""执行一次覆盖策略判定并冻结完整裁决。"""
|
||||
over_flag, delete_versions, failure = self.__resolve_overwrite(
|
||||
@@ -1032,7 +1038,7 @@ class TransHandler:
|
||||
phase="decision",
|
||||
kind="resolve_overwrite",
|
||||
payload={
|
||||
"source": fileitem.model_dump(mode="json"),
|
||||
"source": source_fileitem,
|
||||
"target_storage": target_storage,
|
||||
"target_path": target_file.as_posix(),
|
||||
"transfer_type": transfer_type,
|
||||
@@ -1224,7 +1230,7 @@ class TransHandler:
|
||||
source_item = FileItem(**planned_item.source_fileitem)
|
||||
new_item, error = self.__execute_transfer_with_steps(
|
||||
step_runner=step_runner,
|
||||
fileitem=source_item,
|
||||
source_fileitem=planned_item.source_fileitem,
|
||||
target_storage=planned_item.target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
@@ -1283,6 +1289,7 @@ class TransHandler:
|
||||
over_flag, delete_versions, overwrite_failure = self.__resolve_overwrite_with_step(
|
||||
step_runner=step_runner,
|
||||
fileitem=fileitem,
|
||||
source_fileitem=frozen_source_payload,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
target_oper=target_oper,
|
||||
@@ -1375,7 +1382,7 @@ class TransHandler:
|
||||
)
|
||||
new_item, error = self.__execute_transfer_with_steps(
|
||||
step_runner=step_runner,
|
||||
fileitem=fileitem,
|
||||
source_fileitem=planned_item.source_fileitem,
|
||||
target_storage=target_storage,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""MediaVault 宿主模块的惰性兼容入口。"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
_EXPORTS = {
|
||||
"MediaVaultModule": ("app.modules.mediavault.module", "MediaVaultModule"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""按需解析历史包级导出,并保持模块类的原始反射路径。"""
|
||||
contract = _EXPORTS.get(name)
|
||||
if contract is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, symbol_name = contract
|
||||
value = getattr(import_module(module_name), symbol_name)
|
||||
if name == "MediaVaultModule":
|
||||
value.__module__ = __name__
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""向交互式工具公开兼容符号而不提前加载实现。"""
|
||||
return sorted({*globals(), *_EXPORTS})
|
||||
|
||||
|
||||
__all__ = ["MediaVaultModule"]
|
||||
@@ -0,0 +1,124 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.foundation.url import UrlUtils
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""MediaVault 统一响应包装。"""
|
||||
|
||||
success: bool
|
||||
data: Optional[Union[Dict[str, Any], List[Any], str, int, bool]] = None
|
||||
message: Optional[str] = None
|
||||
status_code: Optional[int] = None
|
||||
|
||||
|
||||
class Api:
|
||||
"""MediaVault 自建媒体库的原生 HTTP 接口。
|
||||
|
||||
统一走管理端口的 `/api/v1`,凭据为管理面板的长效 API Key;
|
||||
自建媒体库的接口都挂在 `/api/v1/media-library` 之下。
|
||||
"""
|
||||
|
||||
LIBRARY_PATH = "/api/v1/media-library"
|
||||
|
||||
def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None):
|
||||
self._host = UrlUtils.standardize_base_url(host).rstrip("/") if host else None
|
||||
self._apikey = apikey
|
||||
self._request_utils = RequestUtils(use_session=True, timeout=15)
|
||||
|
||||
@property
|
||||
def host(self) -> Optional[str]:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self._host and self._apikey)
|
||||
|
||||
def close(self) -> None:
|
||||
"""释放底层会话。"""
|
||||
self._request_utils.close()
|
||||
|
||||
def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str:
|
||||
"""拼装带鉴权的图片直链;item_id 传媒体库 ID 时得到媒体库封面。"""
|
||||
if not self.configured or not item_id:
|
||||
return ""
|
||||
base = (UrlUtils.standardize_base_url(host).rstrip("/") if host else self._host)
|
||||
query = urlencode({"api_key": self._apikey})
|
||||
return f"{base}{self.LIBRARY_PATH}/items/{item_id}/image/{image_type}?{query}"
|
||||
|
||||
def request(
|
||||
self,
|
||||
api: str,
|
||||
method: Optional[str] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
base_path: Optional[str] = None,
|
||||
suppress_log: bool = False,
|
||||
) -> Optional[Result]:
|
||||
"""请求 MediaVault 接口。
|
||||
|
||||
:param api: 接口路径,默认相对自建媒体库前缀
|
||||
:param base_path: 覆盖接口前缀(如站点级 `/api/v1`)
|
||||
:param suppress_log: 探测类调用可关闭错误日志
|
||||
:return: 网络层失败返回 None,业务失败返回 success=False 的 Result
|
||||
"""
|
||||
if not self.configured or not api:
|
||||
return None
|
||||
host = self._host or ""
|
||||
prefix = base_path if base_path is not None else self.LIBRARY_PATH
|
||||
url = host + prefix + (api if api.startswith("/") else f"/{api}")
|
||||
if method is None:
|
||||
method = "get" if data is None else "post"
|
||||
headers = {
|
||||
"User-Agent": get_runtime_setting("USER_AGENT"),
|
||||
"Accept": "application/json",
|
||||
"X-API-Key": self._apikey,
|
||||
}
|
||||
try:
|
||||
res = self._request_utils.request(
|
||||
method=method, url=url, headers=headers, params=params, json=data
|
||||
)
|
||||
except Exception as err:
|
||||
if not suppress_log:
|
||||
logger.error(f"请求 MediaVault 接口 {url} 异常:{err}")
|
||||
return None
|
||||
if res is None:
|
||||
if not suppress_log:
|
||||
logger.error(f"请求 MediaVault 接口 {url} 无响应")
|
||||
return None
|
||||
if res.status_code >= 400:
|
||||
message = self.__error_message(res)
|
||||
if not suppress_log:
|
||||
logger.error(f"请求 MediaVault 接口 {url} 失败:{res.status_code} {message}")
|
||||
return Result(False, None, message, res.status_code)
|
||||
try:
|
||||
body = res.json()
|
||||
except Exception as err:
|
||||
if not suppress_log:
|
||||
logger.error(f"解析 MediaVault 接口 {url} 响应失败:{err}")
|
||||
return None
|
||||
if isinstance(body, dict) and "success" in body:
|
||||
if not body.get("success"):
|
||||
message = str(body.get("message") or body.get("detail") or "")
|
||||
if not suppress_log:
|
||||
logger.error(f"请求 MediaVault 接口 {url} 未成功:{message}")
|
||||
return Result(False, None, message, res.status_code)
|
||||
return Result(True, body.get("data"), None, res.status_code)
|
||||
return Result(True, body, None, res.status_code)
|
||||
|
||||
@staticmethod
|
||||
def __error_message(res: Any) -> str:
|
||||
"""从错误响应中取出可读信息,非 JSON 时退回状态文本。"""
|
||||
try:
|
||||
body = res.json()
|
||||
except Exception:
|
||||
return (res.text or "")[:200]
|
||||
if isinstance(body, dict):
|
||||
return str(body.get("detail") or body.get("message") or "")[:200]
|
||||
return str(body)[:200]
|
||||
@@ -0,0 +1,22 @@
|
||||
schema_version = 1
|
||||
id = "MediaVaultModule"
|
||||
kind = "host_module"
|
||||
entrypoint = "app.modules.mediavault:MediaVaultModule"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "MediaVault"
|
||||
type = "mediaserver"
|
||||
subtype = "MediaVault"
|
||||
priority = 7
|
||||
|
||||
[activation]
|
||||
policy = "when_configured"
|
||||
watch = ["MediaServers"]
|
||||
|
||||
[activation.selector]
|
||||
kind = "system_config_item"
|
||||
key = "MediaServers"
|
||||
match_field = "type"
|
||||
match_value = "mediavault"
|
||||
enabled_field = "enabled"
|
||||
@@ -0,0 +1,589 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app.application.mediaserver import MediaServerIdentityHelper
|
||||
from app.foundation.url import UrlUtils
|
||||
from app.modules.mediavault.api import Api
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.dashboard import Statistic as _SchemaStatistic
|
||||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||||
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
|
||||
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
|
||||
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
|
||||
from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
class MediaVault:
|
||||
"""MediaVault 自建媒体库客户端。
|
||||
|
||||
只使用 MediaVault 管理端口的原生接口,凭据是管理面板的长效 API Key;
|
||||
自建媒体库没有音乐库,也不主动外发 Webhook,相关能力在模块层显式留空。
|
||||
"""
|
||||
|
||||
# MediaVault 单次列表请求的最大条数,与服务端 page_size 上限一致
|
||||
PAGE_LIMIT = 100
|
||||
# 媒体库类型到 MoviePilot 媒体类型的映射
|
||||
LIBRARY_TYPES = {"movies": MediaType.MOVIE.value, "tvshows": MediaType.TV.value}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: Optional[str] = None,
|
||||
apikey: Optional[str] = None,
|
||||
play_host: Optional[str] = None,
|
||||
sync_libraries: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._host = UrlUtils.standardize_base_url(host).rstrip("/") if host else None
|
||||
self._playhost = (
|
||||
UrlUtils.standardize_base_url(play_host).rstrip("/") if play_host else None
|
||||
)
|
||||
self._apikey = apikey
|
||||
self._sync_libraries = sync_libraries or []
|
||||
self._api = Api(host=host, apikey=apikey)
|
||||
self._active = False
|
||||
if not self.is_configured():
|
||||
logger.error("MediaVault 配置不完整!")
|
||||
return
|
||||
self.reconnect()
|
||||
|
||||
# ── 连接状态 ────────────────────────────────────────────────
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""配置是否完整到可以发起请求。"""
|
||||
return bool(self._host and self._apikey)
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""当前 API Key 是否已探测通过。"""
|
||||
return self._active
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
"""是否需要重连。"""
|
||||
if not self.is_configured():
|
||||
return False
|
||||
return not self._active
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
"""用媒体库列表接口探测连通性与凭据有效性。"""
|
||||
if not self.is_configured():
|
||||
return False
|
||||
result = self._api.request("/libraries", suppress_log=True)
|
||||
self._active = bool(result and result.success)
|
||||
return self._active
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""释放底层会话。"""
|
||||
self._active = False
|
||||
self._api.close()
|
||||
|
||||
def authenticate(self, username: str, password: str) -> Optional[str]:
|
||||
"""用 MediaVault 账号完成用户认证,返回访问令牌。"""
|
||||
if not self.is_configured() or not username or not password:
|
||||
return None
|
||||
result = self._api.request(
|
||||
"/login",
|
||||
method="post",
|
||||
data={"username": username, "password": password},
|
||||
base_path="/api/v1/user-auth",
|
||||
suppress_log=True,
|
||||
)
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
token = result.data.get("access_token") or result.data.get("token")
|
||||
return str(token) if token else None
|
||||
|
||||
# ── 媒体库 ──────────────────────────────────────────────────
|
||||
|
||||
def get_librarys(
|
||||
self, hidden: Optional[bool] = False
|
||||
) -> Optional[List[_SchemaMediaServerLibrary]]:
|
||||
"""获取媒体库列表。"""
|
||||
rows = self.__library_rows()
|
||||
if rows is None:
|
||||
return None
|
||||
libraries = []
|
||||
for row in rows:
|
||||
library_id = str(row.get("id") or "")
|
||||
if not library_id:
|
||||
continue
|
||||
if (
|
||||
hidden
|
||||
and self._sync_libraries
|
||||
and "all" not in self._sync_libraries
|
||||
and library_id not in self._sync_libraries
|
||||
):
|
||||
continue
|
||||
library_type = self.LIBRARY_TYPES.get(
|
||||
str(row.get("library_type") or ""), MediaType.UNKNOWN.value
|
||||
)
|
||||
libraries.append(
|
||||
_SchemaMediaServerLibrary(
|
||||
server="mediavault",
|
||||
id=library_id,
|
||||
item_id=library_id,
|
||||
name=row.get("name"),
|
||||
path=row.get("root_paths") or row.get("root_path"),
|
||||
type=library_type,
|
||||
item_count=self.get_items_count(library_id),
|
||||
image=self._api.image_url(library_id, "primary"),
|
||||
link=f"{self._playhost or self._host}/library",
|
||||
server_type="mediavault",
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
|
||||
def __library_rows(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""媒体库原始记录,连接失败返回 None。"""
|
||||
result = self._api.request("/libraries")
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
items = result.data.get("items")
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
def get_items_count(self, parent: Union[str, int]) -> Optional[int]:
|
||||
"""获取指定媒体库可同步的媒体条目总数。"""
|
||||
if not parent:
|
||||
return None
|
||||
result = self.__query_items(
|
||||
library_id=str(parent), kinds="Movie,Series", page=1, page_size=1
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
total = result.get("total")
|
||||
return int(total) if total is not None else None
|
||||
|
||||
def get_items(
|
||||
self,
|
||||
parent: Union[str, int],
|
||||
start_index: Optional[int] = 0,
|
||||
limit: Optional[int] = -1,
|
||||
) -> Generator[Optional[_SchemaMediaServerItem], Any, None]:
|
||||
"""遍历媒体库中的电影与剧集条目,limit 为 None 或 -1 时取全部。"""
|
||||
if not parent or not self.is_configured():
|
||||
return
|
||||
# 页大小固定,页码才能稳定映射到偏移量;条数上限在产出侧裁剪
|
||||
skip = max(0, start_index or 0)
|
||||
remaining = None if limit is None or limit == -1 else max(0, limit)
|
||||
page = skip // self.PAGE_LIMIT + 1
|
||||
drop = skip % self.PAGE_LIMIT
|
||||
while remaining is None or remaining > 0:
|
||||
result = self.__query_items(
|
||||
library_id=str(parent),
|
||||
kinds="Movie,Series",
|
||||
page=page,
|
||||
page_size=self.PAGE_LIMIT,
|
||||
)
|
||||
if result is None:
|
||||
return
|
||||
rows = result.get("items") or []
|
||||
for row in rows[drop:]:
|
||||
if remaining is not None and remaining <= 0:
|
||||
return
|
||||
item = self.__format_item_info(row)
|
||||
if item:
|
||||
yield item
|
||||
if remaining is not None:
|
||||
remaining -= 1
|
||||
if len(rows) < self.PAGE_LIMIT:
|
||||
return
|
||||
drop = 0
|
||||
page += 1
|
||||
|
||||
def __query_items(
|
||||
self,
|
||||
library_id: str = "",
|
||||
parent_id: Optional[str] = None,
|
||||
keyword: str = "",
|
||||
kinds: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 40,
|
||||
filter_by: str = "",
|
||||
sort_by: str = "",
|
||||
sort_order: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""调用条目列表接口;MediaVault 只接受页码,偏移量由调用方换算。"""
|
||||
params: Dict[str, Any] = {"page": max(1, page), "page_size": page_size}
|
||||
if library_id:
|
||||
params["library_id"] = library_id
|
||||
if parent_id is not None:
|
||||
params["parent_id"] = parent_id
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
if kinds:
|
||||
params["kinds"] = kinds
|
||||
if filter_by:
|
||||
params["filter_by"] = filter_by
|
||||
if sort_by:
|
||||
params["sort_by"] = sort_by
|
||||
if sort_order:
|
||||
params["sort_order"] = sort_order
|
||||
result = self._api.request("/items", params=params)
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
return result.data
|
||||
|
||||
# ── 条目 ────────────────────────────────────────────────────
|
||||
|
||||
def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]:
|
||||
"""获取单个条目详情。"""
|
||||
if not itemid or not self.is_configured():
|
||||
return None
|
||||
result = self._api.request(f"/items/{itemid}", suppress_log=True)
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
return self.__format_item_info(result.data)
|
||||
|
||||
def get_movies(
|
||||
self,
|
||||
title: str,
|
||||
year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
) -> Optional[List[_SchemaMediaServerItem]]:
|
||||
"""按标题和年份检查电影是否存在。"""
|
||||
if not title or not self.is_configured():
|
||||
return None
|
||||
result = self.__query_items(keyword=title, kinds="Movie", page_size=self.PAGE_LIMIT)
|
||||
if result is None:
|
||||
return None
|
||||
movies = []
|
||||
for row in result.get("items") or []:
|
||||
item = self.__format_item_info(row)
|
||||
if not item or item.title != title:
|
||||
continue
|
||||
if year and str(item.year) != str(year):
|
||||
continue
|
||||
if not MediaServerIdentityHelper.is_compatible(item, media_source, media_id):
|
||||
continue
|
||||
movies.append(item)
|
||||
return movies
|
||||
|
||||
def get_tv_episodes(
|
||||
self,
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""返回剧集在媒体库中每季已有的集号。"""
|
||||
if not self.is_configured():
|
||||
return None, None
|
||||
series_id = item_id
|
||||
if series_id:
|
||||
info = self.get_iteminfo(series_id)
|
||||
if not info or not MediaServerIdentityHelper.is_compatible(
|
||||
info, media_source, media_id
|
||||
):
|
||||
# 缓存的条目 ID 失效或指向了别的剧,退回按标题重新定位
|
||||
series_id = None
|
||||
if not series_id:
|
||||
if not title:
|
||||
return None, {}
|
||||
series_id = self.__find_series_id(title, year, media_source, media_id)
|
||||
if series_id is None:
|
||||
return None, None
|
||||
if not series_id:
|
||||
return None, {}
|
||||
result = self._api.request(f"/items/{series_id}/episodes")
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None, None
|
||||
seasons: Dict[int, List[int]] = {}
|
||||
for raw_season, episodes in (result.data.get("seasons") or {}).items():
|
||||
try:
|
||||
season_index = int(raw_season)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if season is not None and season_index != season:
|
||||
continue
|
||||
seasons[season_index] = sorted(
|
||||
{int(episode) for episode in episodes if episode is not None}
|
||||
)
|
||||
return series_id, seasons
|
||||
|
||||
def __find_series_id(
|
||||
self,
|
||||
title: str,
|
||||
year: Optional[str],
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
) -> Optional[str]:
|
||||
"""按标题定位剧集条目 ID;连接失败返回 None,未找到返回空串。"""
|
||||
result = self.__query_items(keyword=title, kinds="Series", page_size=self.PAGE_LIMIT)
|
||||
if result is None:
|
||||
return None
|
||||
for row in result.get("items") or []:
|
||||
item = self.__format_item_info(row)
|
||||
if not item or item.title != title:
|
||||
continue
|
||||
if year and str(item.year) != str(year):
|
||||
continue
|
||||
if not MediaServerIdentityHelper.is_compatible(item, media_source, media_id):
|
||||
continue
|
||||
return str(item.item_id)
|
||||
return ""
|
||||
|
||||
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
|
||||
"""获取指定季的集号到条目 ID 映射。"""
|
||||
if not item_id or not self.is_configured():
|
||||
return {}
|
||||
season_id = self.__find_season_id(item_id, season)
|
||||
if not season_id:
|
||||
return {}
|
||||
episode_ids: Dict[int, str] = {}
|
||||
page = 1
|
||||
while True:
|
||||
result = self.__query_items(
|
||||
parent_id=season_id, kinds="Episode", page=page, page_size=self.PAGE_LIMIT
|
||||
)
|
||||
if result is None:
|
||||
return episode_ids
|
||||
rows = result.get("items") or []
|
||||
for row in rows:
|
||||
episode = row.get("episode")
|
||||
row_id = row.get("id")
|
||||
if episode is None or not row_id:
|
||||
continue
|
||||
episode_ids[int(episode)] = str(row_id)
|
||||
if len(rows) < self.PAGE_LIMIT:
|
||||
return episode_ids
|
||||
page += 1
|
||||
|
||||
def __find_season_id(self, series_id: str, season: int) -> str:
|
||||
"""在剧集下定位指定季的条目 ID。"""
|
||||
page = 1
|
||||
while True:
|
||||
result = self.__query_items(
|
||||
parent_id=series_id, kinds="Season", page=page, page_size=self.PAGE_LIMIT
|
||||
)
|
||||
if result is None:
|
||||
return ""
|
||||
rows = result.get("items") or []
|
||||
for row in rows:
|
||||
if row.get("season") == season and row.get("id"):
|
||||
return str(row["id"])
|
||||
if len(rows) < self.PAGE_LIMIT:
|
||||
return ""
|
||||
page += 1
|
||||
|
||||
def __format_item_info(self, row: Dict[str, Any]) -> Optional[_SchemaMediaServerItem]:
|
||||
"""把 MediaVault 条目转换为统一媒体服务器模型。"""
|
||||
try:
|
||||
metadata = row.get("metadata_info") or {}
|
||||
provider_ids: Dict[str, Any] = {}
|
||||
if row.get("tmdb_id"):
|
||||
provider_ids["Tmdb"] = str(row["tmdb_id"])
|
||||
for source, target in (("imdb_id", "Imdb"), ("tvdb_id", "Tvdb")):
|
||||
value = (metadata.get("external_ids") or {}).get(source)
|
||||
if value:
|
||||
provider_ids[target] = str(value)
|
||||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(provider_ids)
|
||||
user_data = row.get("user_data") or {}
|
||||
position = user_data.get("position_ticks") or 0
|
||||
return _SchemaMediaServerItem(
|
||||
server="mediavault",
|
||||
library=row.get("library_id"),
|
||||
item_id=str(row.get("id") or ""),
|
||||
item_type=row.get("kind"),
|
||||
title=row.get("title"),
|
||||
original_title=metadata.get("original_title"),
|
||||
year=row.get("year") or None,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
path=self.__item_path(row),
|
||||
user_state=_SchemaMediaServerItemUserState(
|
||||
played=user_data.get("played"),
|
||||
resume=position > 0,
|
||||
last_played_date=self.__local_time(user_data.get("last_played_at")),
|
||||
play_count=int(bool(user_data.get("played"))),
|
||||
) if user_data else None,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"解析 MediaVault 条目失败:{err}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __item_path(row: Dict[str, Any]) -> Optional[str]:
|
||||
"""条目详情带媒体源时取第一个文件路径,列表接口没有路径字段。"""
|
||||
sources = row.get("sources")
|
||||
if isinstance(sources, list):
|
||||
for source in sources:
|
||||
if isinstance(source, dict) and source.get("path"):
|
||||
return str(source["path"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __local_time(value: Optional[str]) -> Optional[str]:
|
||||
"""把 ISO 时间截断为统一模型使用的秒级本地时间文本。"""
|
||||
if not value:
|
||||
return None
|
||||
return str(value).split(".")[0].replace("T", " ")
|
||||
|
||||
# ── 统计与展示 ──────────────────────────────────────────────
|
||||
|
||||
def get_medias_count(self) -> Optional[_SchemaStatistic]:
|
||||
"""媒体数量统计。"""
|
||||
result = self._api.request("/statistics")
|
||||
if not result or not result.success or not isinstance(result.data, dict):
|
||||
return None
|
||||
return _SchemaStatistic(
|
||||
movie_count=result.data.get("movie_count") or 0,
|
||||
tv_count=result.data.get("series_count") or 0,
|
||||
episode_count=result.data.get("episode_count") or 0,
|
||||
)
|
||||
|
||||
def get_user_count(self) -> int:
|
||||
"""媒体库可见用户数。"""
|
||||
result = self._api.request("/users", suppress_log=True)
|
||||
if not result or not result.success or not isinstance(result.data, list):
|
||||
return 0
|
||||
return len(result.data)
|
||||
|
||||
def get_resume(self, num: Optional[int] = 12) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""继续观看列表。"""
|
||||
return self.__play_items(filter_by="resume", num=num, resume=True)
|
||||
|
||||
def get_latest(self, num: Optional[int] = 20) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""最新入库列表。"""
|
||||
return self.__play_items(sort_by="added", sort_order="desc", num=num, resume=False)
|
||||
|
||||
def __play_items(
|
||||
self,
|
||||
num: Optional[int],
|
||||
resume: bool,
|
||||
filter_by: str = "",
|
||||
sort_by: str = "",
|
||||
sort_order: str = "",
|
||||
) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""把条目列表转换为可播放展示项。"""
|
||||
if not self.is_configured():
|
||||
return None
|
||||
count = max(1, min(self.PAGE_LIMIT, num or 20))
|
||||
result = self.__query_items(
|
||||
kinds="Movie,Episode" if resume else "Movie,Series",
|
||||
filter_by=filter_by,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
page_size=count,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
items = []
|
||||
for row in (result.get("items") or [])[:count]:
|
||||
row_id = str(row.get("id") or "")
|
||||
if not row_id:
|
||||
continue
|
||||
is_episode = row.get("kind") == "Episode"
|
||||
title: Optional[str] = row.get("title")
|
||||
subtitle: Optional[str] = None
|
||||
if is_episode:
|
||||
title = row.get("series_name") or row.get("title")
|
||||
subtitle = f'S{row.get("season")}:{row.get("episode")} - {row.get("title")}'
|
||||
elif row.get("year"):
|
||||
subtitle = str(row["year"])
|
||||
image_id = row.get("series_id") if is_episode else row_id
|
||||
percent = None
|
||||
duration = row.get("duration_ticks") or 0
|
||||
position = (row.get("user_data") or {}).get("position_ticks") or 0
|
||||
if duration > 0 and position > 0:
|
||||
percent = round(position / duration * 100, 2)
|
||||
items.append(
|
||||
_SchemaMediaServerPlayItem(
|
||||
id=row_id,
|
||||
item_id=row_id,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
type=MediaType.TV.value if is_episode else MediaType.MOVIE.value,
|
||||
image=self._api.image_url(str(image_id or row_id), "primary"),
|
||||
link=self.get_play_url(row_id),
|
||||
percent=percent,
|
||||
server_type="mediavault",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def get_latest_backdrops(
|
||||
self, num: Optional[int] = 20, remote: Optional[bool] = False
|
||||
) -> Optional[List[str]]:
|
||||
"""最新入库条目的背景图地址。"""
|
||||
if not self.is_configured():
|
||||
return None
|
||||
count = max(1, min(self.PAGE_LIMIT, num or 20))
|
||||
# 没有背景图的条目会白占名额,多取一批再按实际有图的截断
|
||||
result = self.__query_items(
|
||||
kinds="Movie,Series", sort_by="added", sort_order="desc", page_size=self.PAGE_LIMIT
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
host = (self._playhost or self._host) if remote else self._host
|
||||
images = []
|
||||
for row in result.get("items") or []:
|
||||
if not row.get("has_backdrop") or not row.get("id"):
|
||||
continue
|
||||
images.append(self._api.image_url(str(row["id"]), "backdrop", host=host))
|
||||
if len(images) == count:
|
||||
break
|
||||
return images
|
||||
|
||||
def get_play_url(self, item_id: str) -> Optional[str]:
|
||||
"""媒体库网页播放地址。"""
|
||||
if not item_id or not self.is_configured():
|
||||
return None
|
||||
return f"{self._playhost or self._host}/library/item/{item_id}"
|
||||
|
||||
# ── 入库刷新 ────────────────────────────────────────────────
|
||||
|
||||
def refresh_root_library(self) -> Optional[bool]:
|
||||
"""触发全部媒体库扫描。"""
|
||||
rows = self.__library_rows()
|
||||
if rows is None:
|
||||
return None
|
||||
results = [self.__queue_scan(str(row.get("id"))) for row in rows if row.get("id")]
|
||||
return all(results) if results else False
|
||||
|
||||
def refresh_library_by_items(
|
||||
self, items: List[_SchemaRefreshMediaItem]
|
||||
) -> Optional[bool]:
|
||||
"""按入库路径定位媒体库并触发扫描;定位不到时退回全库扫描。"""
|
||||
if not items:
|
||||
return False
|
||||
rows = self.__library_rows()
|
||||
if rows is None:
|
||||
return None
|
||||
matched = set()
|
||||
unmatched = False
|
||||
for item in items:
|
||||
library_id = self.__match_library_by_path(rows, item.target_path)
|
||||
if library_id:
|
||||
matched.add(library_id)
|
||||
else:
|
||||
unmatched = True
|
||||
logger.info(f"MediaVault 中未找到 {item.title} 对应的媒体库,将扫描全部媒体库")
|
||||
if unmatched:
|
||||
return self.refresh_root_library()
|
||||
return all(self.__queue_scan(library_id) for library_id in matched)
|
||||
|
||||
@staticmethod
|
||||
def __match_library_by_path(
|
||||
rows: List[Dict[str, Any]], target_path: Optional[Path]
|
||||
) -> str:
|
||||
"""按目录归属把入库路径映射到媒体库。"""
|
||||
if not target_path:
|
||||
return ""
|
||||
for row in rows:
|
||||
roots = row.get("root_paths") or ([row["root_path"]] if row.get("root_path") else [])
|
||||
for root in roots:
|
||||
try:
|
||||
if target_path == Path(root) or Path(root) in target_path.parents:
|
||||
return str(row.get("id") or "")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ""
|
||||
|
||||
def __queue_scan(self, library_id: str) -> bool:
|
||||
"""把媒体库扫描排进 MediaVault 的后台队列,不阻塞入库流程。"""
|
||||
if not library_id:
|
||||
return False
|
||||
result = self._api.request(f"/libraries/{library_id}/scan-task", method="post")
|
||||
return bool(result and result.success)
|
||||
@@ -0,0 +1,231 @@
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app.modules._base.mediaserver import _MediaServerModuleBase
|
||||
from app.modules.mediavault.mediavault import MediaVault
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.dashboard import Statistic as _SchemaStatistic
|
||||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||||
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
|
||||
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
|
||||
from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerSeasonInfo
|
||||
from app.schemas.types import MediaServerType, ModuleType
|
||||
|
||||
|
||||
class MediaVaultModule(_MediaServerModuleBase[MediaVault]):
|
||||
"""MediaVault 自建媒体库模块。"""
|
||||
|
||||
# 媒体库标识(ExistMediaInfo.server_type)
|
||||
_server_type_value = "mediavault"
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
初始化模块
|
||||
"""
|
||||
super().init_service(
|
||||
service_name=MediaVault.__name__.lower(),
|
||||
service_type=lambda conf: MediaVault(
|
||||
**conf.config, sync_libraries=conf.sync_libraries
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "MediaVault"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""
|
||||
获取模块类型
|
||||
"""
|
||||
return ModuleType.MediaServer
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaServerType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaServerType.MediaVault
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
|
||||
"""
|
||||
return 7
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""本模块不使用开关设置。"""
|
||||
return None
|
||||
|
||||
def _is_inactive(self, server: MediaVault) -> bool:
|
||||
"""未配置的实例不参与定时重连。"""
|
||||
return server.is_configured() and server.is_inactive()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for server in self.get_instances().values():
|
||||
try:
|
||||
server.disconnect()
|
||||
except Exception as err:
|
||||
logger.error(f"停止 MediaVault 模块实例失败:{err}")
|
||||
|
||||
def _test_server(self, server: MediaVault, name: str) -> Optional[str]:
|
||||
"""用配置完整性与 API Key 探测结果判断连接状态。"""
|
||||
if not server.is_configured():
|
||||
return f"{self.get_name()}配置不完整:{name}"
|
||||
if server.is_inactive() and not server.reconnect():
|
||||
return f"无法连接{self.get_name()}:{name}"
|
||||
return None
|
||||
|
||||
def media_statistic(
|
||||
self, server: Optional[str] = None
|
||||
) -> Optional[List[_SchemaStatistic]]:
|
||||
"""
|
||||
媒体数量统计
|
||||
"""
|
||||
if server:
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
servers = [server_obj] if server_obj else []
|
||||
else:
|
||||
servers = list(self.get_instances().values())
|
||||
statistics = []
|
||||
for s in servers:
|
||||
statistic = s.get_medias_count()
|
||||
if not statistic:
|
||||
continue
|
||||
statistic.user_count = s.get_user_count()
|
||||
statistics.append(statistic)
|
||||
return statistics
|
||||
|
||||
def mediaserver_librarys(
|
||||
self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs: Any
|
||||
) -> Optional[List[_SchemaMediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_librarys(hidden=hidden)
|
||||
return None
|
||||
|
||||
def mediaserver_items(
|
||||
self,
|
||||
server: str,
|
||||
library_id: Union[str, int],
|
||||
start_index: Optional[int] = 0,
|
||||
limit: Optional[int] = -1,
|
||||
) -> Optional[Generator[Optional[_SchemaMediaServerItem], Any, None]]:
|
||||
"""
|
||||
获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param library_id: 媒体库ID
|
||||
:param start_index: 起始索引
|
||||
:param limit: 每次请求的最大项目数,None 或 -1 表示一次性获取所有数据
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items(library_id, start_index, limit)
|
||||
return None
|
||||
|
||||
def mediaserver_items_count(
|
||||
self, server: str, library_id: Union[str, int]
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
获取指定媒体库可同步的媒体条目总数
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_items_count(library_id)
|
||||
return None
|
||||
|
||||
def mediaserver_iteminfo(
|
||||
self, server: str, item_id: str
|
||||
) -> Optional[_SchemaMediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if server_obj:
|
||||
return server_obj.get_iteminfo(str(item_id))
|
||||
return None
|
||||
|
||||
def mediaserver_tv_episodes(
|
||||
self, server: str, item_id: Union[str, int]
|
||||
) -> Optional[List[_SchemaMediaServerSeasonInfo]]:
|
||||
"""
|
||||
获取剧集信息
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
_, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id))
|
||||
if not seasoninfo:
|
||||
return []
|
||||
return [
|
||||
_SchemaMediaServerSeasonInfo(season=season, episodes=episodes)
|
||||
for season, episodes in seasoninfo.items()
|
||||
]
|
||||
|
||||
def mediaserver_season_episode_ids(
|
||||
self, server: str, item_id: Union[str, int], season: int
|
||||
) -> Optional[Dict[int, str]]:
|
||||
"""
|
||||
获取指定季的集号到条目 ID 映射
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_season_episode_ids(str(item_id), season)
|
||||
|
||||
def mediaserver_playing(
|
||||
self, server: str, count: Optional[int] = 20, **kwargs: Any
|
||||
) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_resume(num=count)
|
||||
|
||||
def mediaserver_play_url(
|
||||
self, server: str, item_id: Union[str, int]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
获取媒体库播放地址
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_play_url(str(item_id))
|
||||
|
||||
def mediaserver_latest(
|
||||
self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs: Any
|
||||
) -> Optional[List[_SchemaMediaServerPlayItem]]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_latest(num=count)
|
||||
|
||||
def mediaserver_latest_images(
|
||||
self,
|
||||
server: Optional[str] = None,
|
||||
count: Optional[int] = 20,
|
||||
remote: Optional[bool] = False,
|
||||
**kwargs: Any,
|
||||
) -> List[str]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目的图片
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param count: 获取数量
|
||||
:param remote: True为外网链接,False为内网链接
|
||||
"""
|
||||
server_obj: Optional[MediaVault] = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return []
|
||||
return server_obj.get_latest_backdrops(num=count, remote=remote) or []
|
||||
+156
-68
@@ -8,8 +8,21 @@ import shutil
|
||||
import sys
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
)
|
||||
from urllib.parse import quote, urlencode, urlparse
|
||||
|
||||
from dotenv import set_key, unset_key
|
||||
@@ -36,6 +49,86 @@ from app.runtime.version import get_app_version
|
||||
from app.runtime.webpush import WebPushRegistry, webpush_registry
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
SettingConverter = Callable[[Any, Any], Tuple[Any, bool]]
|
||||
SettingMigration = Callable[[Dict[str, Any]], Dict[str, Tuple[Any, Any]]]
|
||||
SettingValidator = Callable[[Any], Optional[str]]
|
||||
SettingSerializer = Callable[[Any], str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SettingPolicy:
|
||||
"""
|
||||
部署配置字段的专属处理策略,供通用更新流程按字段声明执行
|
||||
"""
|
||||
|
||||
before_convert: Optional[Callable[[Any], Any]] = None
|
||||
converter: Optional[SettingConverter] = None
|
||||
migrate: Optional[SettingMigration] = None
|
||||
validate: Optional[SettingValidator] = None
|
||||
serialize: Optional[SettingSerializer] = None
|
||||
sensitive: bool = False
|
||||
|
||||
|
||||
def _get_setting_policy(field_info: Any) -> Optional[SettingPolicy]:
|
||||
"""读取 Pydantic 字段声明上的部署配置策略"""
|
||||
return next(
|
||||
(
|
||||
metadata
|
||||
for metadata in getattr(field_info, "metadata", ())
|
||||
if isinstance(metadata, SettingPolicy)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legacy_update_mode(value: Any) -> Any:
|
||||
"""将旧自动更新模式转换为布尔开关"""
|
||||
if isinstance(value, str) and value.strip().lower() in {"dev", "release"}:
|
||||
return True
|
||||
return value
|
||||
|
||||
|
||||
def _migrate_legacy_update_mode(
|
||||
data: Dict[str, Any],
|
||||
) -> Dict[str, Tuple[Any, Any]]:
|
||||
"""迁移旧自动更新配置中的开发分支跟踪偏好"""
|
||||
if (
|
||||
str(data.get("MOVIEPILOT_AUTO_UPDATE", "")).strip().lower() != "dev"
|
||||
or "MOVIEPILOT_UPDATE_DEV" in data
|
||||
):
|
||||
return {}
|
||||
|
||||
data["MOVIEPILOT_UPDATE_DEV"] = True
|
||||
return {"MOVIEPILOT_UPDATE_DEV": (None, True)}
|
||||
|
||||
|
||||
def _normalize_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]:
|
||||
"""校验并规范化 API_TOKEN,避免把令牌原文写入日志"""
|
||||
if isinstance(value, (list, dict, set)):
|
||||
value = copy.deepcopy(value)
|
||||
value = value.strip() if isinstance(value, str) else None
|
||||
if not value:
|
||||
return None, str(original_value) not in {"", "None"}
|
||||
if len(value) < 16:
|
||||
new_token = secrets.token_urlsafe(16)
|
||||
logger.warning(
|
||||
"'API_TOKEN' 长度不足 16 个字符,存在安全隐患,已随机生成新的安全令牌"
|
||||
)
|
||||
return new_token, True
|
||||
return value, str(value) != str(original_value)
|
||||
|
||||
|
||||
def _validate_rust_accel(value: Any) -> Optional[str]:
|
||||
"""校验 free-threaded 运行时的 Rust 加速约束"""
|
||||
if is_free_threaded_runtime() and value is not True:
|
||||
return "free-threaded 运行时必须启用 Rust 加速"
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_bool(value: Any) -> str:
|
||||
"""将布尔配置按启动脚本兼容的形式持久化"""
|
||||
return str(value).lower()
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
"""
|
||||
@@ -124,7 +217,10 @@ class ConfigModel(BaseModel):
|
||||
# 辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户
|
||||
AUXILIARY_AUTH_ENABLE: bool = False
|
||||
# API密钥,需要更换
|
||||
API_TOKEN: Optional[str] = None
|
||||
API_TOKEN: Annotated[
|
||||
Optional[str],
|
||||
SettingPolicy(converter=_normalize_api_token, sensitive=True),
|
||||
] = None
|
||||
# 用户认证站点
|
||||
AUTH_SITE: str = ""
|
||||
|
||||
@@ -335,8 +431,19 @@ class ConfigModel(BaseModel):
|
||||
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
||||
|
||||
# ==================== 系统升级配置 ====================
|
||||
# 开发版仍可在启动时跟踪 v3 分支;Release 更新由后台更新服务管理。
|
||||
MOVIEPILOT_AUTO_UPDATE: str = "false"
|
||||
# 自动检查稳定版本并提示升级,不自动下载或安装。
|
||||
MOVIEPILOT_AUTO_UPDATE: Annotated[
|
||||
bool,
|
||||
SettingPolicy(
|
||||
before_convert=_normalize_legacy_update_mode,
|
||||
migrate=_migrate_legacy_update_mode,
|
||||
serialize=_serialize_bool,
|
||||
),
|
||||
] = False
|
||||
# 独立控制启动时跟踪 v3 开发分支。
|
||||
MOVIEPILOT_UPDATE_DEV: Annotated[
|
||||
bool, SettingPolicy(serialize=_serialize_bool)
|
||||
] = False
|
||||
# 后台检查站点资源包,确认后由启动器在进程拉起前应用
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
|
||||
@@ -621,7 +728,7 @@ class ConfigModel(BaseModel):
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# Rust 加速总开关,free-threaded 运行时固定启用
|
||||
RUST_ACCEL: bool = True
|
||||
RUST_ACCEL: Annotated[bool, SettingPolicy(validate=_validate_rust_accel)] = True
|
||||
# 是否启用编码探测的性能模式
|
||||
ENCODING_DETECTION_PERFORMANCE_MODE: bool = True
|
||||
# 编码探测的最低置信度阈值
|
||||
@@ -796,18 +903,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
校验 API_TOKEN
|
||||
"""
|
||||
if isinstance(value, (list, dict, set)):
|
||||
value = copy.deepcopy(value)
|
||||
value = value.strip() if isinstance(value, str) else None
|
||||
if not value:
|
||||
return None, str(original_value) not in {"", "None"}
|
||||
if len(value) < 16:
|
||||
new_token = secrets.token_urlsafe(16)
|
||||
logger.warning(
|
||||
f"'API_TOKEN' 长度不足 16 个字符,存在安全隐患,已随机生成新的【API_TOKEN】{new_token}"
|
||||
)
|
||||
return new_token, True
|
||||
return value, str(value) != str(original_value)
|
||||
return _normalize_api_token(value, original_value)
|
||||
|
||||
@staticmethod
|
||||
def generic_type_converter(
|
||||
@@ -819,11 +915,20 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
raise_exception: bool = False,
|
||||
) -> Tuple[Any, bool]:
|
||||
"""
|
||||
通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值
|
||||
先执行字段声明的转换策略,再根据预期类型转换值。如果转换失败,返回默认值
|
||||
:return: 元组 (转换后的值, 是否需要更新)
|
||||
"""
|
||||
if isinstance(value, (list, dict, set)):
|
||||
value = copy.deepcopy(value)
|
||||
|
||||
field = Settings.model_fields.get(field_name)
|
||||
policy = _get_setting_policy(field)
|
||||
if policy:
|
||||
if policy.before_convert:
|
||||
value = policy.before_convert(value)
|
||||
if policy.converter:
|
||||
return policy.converter(value, original_value)
|
||||
|
||||
# 如果 value 是 None,仍需要检查与 original_value 是否不一致
|
||||
if value is None:
|
||||
return default, str(value) != str(original_value)
|
||||
@@ -911,51 +1016,34 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
@classmethod
|
||||
def generic_type_validator(cls, data: Any): # noqa
|
||||
"""
|
||||
通用校验器,尝试将配置值转换为期望的类型
|
||||
通用校验器,迁移旧 Dev 跟踪偏好后将配置值转换为期望的类型。
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# 仅 true 表示启用后台 Release 检查,其他模式不注册该定时服务。
|
||||
if "MOVIEPILOT_AUTO_UPDATE" in data:
|
||||
original_update_mode = data["MOVIEPILOT_AUTO_UPDATE"]
|
||||
mode = str(original_update_mode or "").strip().lower()
|
||||
normalized_update_mode = (
|
||||
mode if mode in {"true", "dev", "false"} else "false"
|
||||
)
|
||||
if normalized_update_mode != str(original_update_mode):
|
||||
cls.update_env_config(
|
||||
"MOVIEPILOT_AUTO_UPDATE",
|
||||
original_update_mode,
|
||||
normalized_update_mode,
|
||||
)
|
||||
data["MOVIEPILOT_AUTO_UPDATE"] = normalized_update_mode
|
||||
|
||||
# 处理 API_TOKEN 特殊验证
|
||||
if "API_TOKEN" in data:
|
||||
converted_value, needs_update = cls.validate_api_token(
|
||||
data["API_TOKEN"], data["API_TOKEN"]
|
||||
)
|
||||
if needs_update:
|
||||
cls.update_env_config("API_TOKEN", data["API_TOKEN"], converted_value)
|
||||
data["API_TOKEN"] = converted_value
|
||||
# 字段策略负责兼容迁移,公共校验器只负责执行并持久化迁移结果。
|
||||
for field_info in cls.model_fields.values():
|
||||
policy = _get_setting_policy(field_info)
|
||||
if not policy or not policy.migrate:
|
||||
continue
|
||||
updates = policy.migrate(data)
|
||||
for field_name, (original_value, converted_value) in updates.items():
|
||||
cls.update_env_config(field_name, original_value, converted_value)
|
||||
|
||||
# 对其他字段进行类型转换
|
||||
for field_name, field_info in cls.model_fields.items():
|
||||
for field_name, field in cls.model_fields.items():
|
||||
if field_name not in data:
|
||||
continue
|
||||
value = data[field_name]
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
field = cls.model_fields.get(field_name)
|
||||
if field:
|
||||
converted_value, needs_update = cls.generic_type_converter(
|
||||
value, value, field.annotation, field.default, field_name
|
||||
)
|
||||
if needs_update:
|
||||
cls.update_env_config(field_name, value, converted_value)
|
||||
data[field_name] = converted_value
|
||||
converted_value, needs_update = cls.generic_type_converter(
|
||||
value, value, field.annotation, field.default, field_name
|
||||
)
|
||||
if needs_update:
|
||||
cls.update_env_config(field_name, value, converted_value)
|
||||
data[field_name] = converted_value
|
||||
|
||||
return data
|
||||
|
||||
@@ -964,15 +1052,19 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
field_name: str, original_value: Any, converted_value: Any
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
更新 env 配置
|
||||
按字段策略序列化并更新 env 配置。
|
||||
"""
|
||||
policy = _get_setting_policy(Settings.model_fields.get(field_name))
|
||||
# 成功且无提示时使用空字符串,保证与 Tuple[bool, str] 返回类型一致
|
||||
message = ""
|
||||
is_converted = original_value is not None and str(original_value) != str(
|
||||
converted_value
|
||||
)
|
||||
if is_converted:
|
||||
message = f"配置项 '{field_name}' 的值 '{original_value}' 无效,已替换为 '{converted_value}'"
|
||||
if policy and policy.sensitive:
|
||||
message = f"配置项 '{field_name}' 的值无效,已替换为安全值"
|
||||
else:
|
||||
message = f"配置项 '{field_name}' 的值 '{original_value}' 无效,已替换为 '{converted_value}'"
|
||||
logger.warning(message)
|
||||
|
||||
if field_name in os.environ:
|
||||
@@ -990,8 +1082,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
)
|
||||
logger.info(f"配置项 '{field_name}' 已清空,从 'app.env' 中移除")
|
||||
return True, message
|
||||
if policy and policy.serialize:
|
||||
value_to_write = policy.serialize(converted_value)
|
||||
# 如果是列表、字典或集合类型,将其转换为JSON字符串
|
||||
if isinstance(converted_value, (list, dict, set)):
|
||||
elif isinstance(converted_value, (list, dict, set)):
|
||||
value_to_write = json.dumps(converted_value)
|
||||
else:
|
||||
value_to_write = str(converted_value)
|
||||
@@ -1021,20 +1115,14 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if not field:
|
||||
return False, f"配置项 '{key}' 不存在"
|
||||
original_value = getattr(self, key)
|
||||
if key == "API_TOKEN":
|
||||
converted_value, needs_update = self.validate_api_token(
|
||||
value, original_value
|
||||
)
|
||||
else:
|
||||
converted_value, needs_update = self.generic_type_converter(
|
||||
value, original_value, field.annotation, field.default, key
|
||||
)
|
||||
if (
|
||||
key == "RUST_ACCEL"
|
||||
and is_free_threaded_runtime()
|
||||
and converted_value is not True
|
||||
):
|
||||
return False, "free-threaded 运行时必须启用 Rust 加速"
|
||||
converted_value, needs_update = self.generic_type_converter(
|
||||
value, original_value, field.annotation, field.default, key
|
||||
)
|
||||
policy = _get_setting_policy(field)
|
||||
if policy and policy.validate:
|
||||
validation_message = policy.validate(converted_value)
|
||||
if validation_message:
|
||||
return False, validation_message
|
||||
# 如果没有抛出异常,则统一使用 converted_value 进行更新
|
||||
if needs_update or str(value) != str(converted_value):
|
||||
success, message = self.update_env_config(key, value, converted_value)
|
||||
|
||||
@@ -67,6 +67,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
|
||||
"jellyfin",
|
||||
"listenbrainz",
|
||||
"lrclib",
|
||||
"mediavault",
|
||||
"musicbrainz",
|
||||
"musixmatch",
|
||||
"navidrome",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.events import eventmanager, Event
|
||||
from app.runtime.log import logger
|
||||
@@ -17,7 +18,7 @@ class ConfigReloadMixin:
|
||||
# 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。
|
||||
CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""为声明了 CONFIG_WATCH 的子类生成配置变更处理器。"""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
|
||||
+37
-17
@@ -9,10 +9,10 @@ from typing import Optional, Tuple
|
||||
|
||||
import psutil
|
||||
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.foundation.environment import is_docker, is_frozen, is_windows
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.reload import ConfigReloadMixin
|
||||
from app.foundation.environment import is_windows,is_frozen,is_docker
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
|
||||
|
||||
class SystemHelper(ConfigReloadMixin):
|
||||
@@ -38,9 +38,13 @@ class SystemHelper(ConfigReloadMixin):
|
||||
__one_shot_dev_update_flag_file = (
|
||||
get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update"
|
||||
)
|
||||
__prepared_update_manifest = (
|
||||
get_runtime_setting('TEMP_PATH') / "moviepilot-update/install.json"
|
||||
)
|
||||
__supervisor_config = Path("/etc/supervisor/supervisord.conf")
|
||||
__supervisorctl = Path("/usr/bin/supervisorctl")
|
||||
__supervisor_socket = Path("/run/moviepilot/supervisor.sock")
|
||||
__supervisor_update_worker = "moviepilot-update-worker"
|
||||
|
||||
def on_config_changed(self):
|
||||
"""配置变化后重新应用日志设置。"""
|
||||
@@ -175,14 +179,25 @@ class SystemHelper(ConfigReloadMixin):
|
||||
@staticmethod
|
||||
def _schedule_supervisor_restart() -> None:
|
||||
"""延迟调用本地 supervisor,确保重启接口有机会完成响应。"""
|
||||
def restart_backend() -> None:
|
||||
SystemHelper._schedule_supervisor_command("restart", "all")
|
||||
|
||||
@staticmethod
|
||||
def _schedule_supervisor_shutdown() -> None:
|
||||
"""延迟关闭 supervisor,让容器入口重新执行更新和启动准备流程。"""
|
||||
SystemHelper._schedule_supervisor_command("shutdown")
|
||||
|
||||
@staticmethod
|
||||
def _schedule_supervisor_command(action: str, target: Optional[str] = None) -> None:
|
||||
"""延迟调用本地 supervisor 控制命令,确保重启接口有机会完成响应。"""
|
||||
def run_command() -> None:
|
||||
command = [
|
||||
str(SystemHelper.__supervisorctl),
|
||||
"-c",
|
||||
str(SystemHelper.__supervisor_config),
|
||||
"restart",
|
||||
"all",
|
||||
action,
|
||||
]
|
||||
if target is not None:
|
||||
command.append(target)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
command,
|
||||
@@ -193,9 +208,9 @@ class SystemHelper(ConfigReloadMixin):
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as err:
|
||||
logger.error(f"调用 supervisor 重启后端失败: {err}")
|
||||
logger.error(f"调用 supervisor {action} 失败: {err}")
|
||||
|
||||
restart_timer = threading.Timer(0.5, restart_backend)
|
||||
restart_timer = threading.Timer(0.5, run_command)
|
||||
restart_timer.daemon = True
|
||||
restart_timer.start()
|
||||
|
||||
@@ -222,7 +237,7 @@ class SystemHelper(ConfigReloadMixin):
|
||||
|
||||
@staticmethod
|
||||
def restart() -> Tuple[bool, str]:
|
||||
"""请求容器内 supervisor 重启受管的前后端进程。"""
|
||||
"""执行当前部署支持的受管重启流程。"""
|
||||
if not is_frozen() and is_windows():
|
||||
success, message = SystemHelper._windows_restart()
|
||||
return success, message
|
||||
@@ -244,20 +259,25 @@ class SystemHelper(ConfigReloadMixin):
|
||||
and SystemHelper.__supervisor_socket.exists()
|
||||
):
|
||||
return False, "容器内 supervisor 未安装"
|
||||
logger.info("请求容器内 supervisor 重启后端服务")
|
||||
SystemHelper._schedule_supervisor_restart()
|
||||
if SystemHelper.__prepared_update_manifest.is_file():
|
||||
logger.info("检测到已确认的更新包,请求 root 更新 worker 替换程序目录")
|
||||
SystemHelper._schedule_supervisor_command(
|
||||
"start", SystemHelper.__supervisor_update_worker
|
||||
)
|
||||
elif SystemHelper.__one_shot_dev_update_flag_file.is_file():
|
||||
logger.info("检测到一次性 Dev 更新,请求 supervisor 关闭并重新执行容器启动流程")
|
||||
SystemHelper._schedule_supervisor_shutdown()
|
||||
else:
|
||||
logger.info("请求容器内 supervisor 重启前后端服务")
|
||||
SystemHelper._schedule_supervisor_restart()
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def upgrade_dev() -> Tuple[bool, str]:
|
||||
"""保留原 Dev 模式:重启后跟踪当前 v3 开发分支。"""
|
||||
configured_mode = str(
|
||||
get_runtime_setting('MOVIEPILOT_AUTO_UPDATE') or ""
|
||||
).strip().lower()
|
||||
if configured_mode != "dev":
|
||||
queued, message = SystemHelper.queue_one_shot_dev_update()
|
||||
if not queued:
|
||||
return False, message
|
||||
queued, message = SystemHelper.queue_one_shot_dev_update()
|
||||
if not queued:
|
||||
return False, message
|
||||
ret, message = SystemHelper.restart()
|
||||
if not ret:
|
||||
SystemHelper.clear_one_shot_dev_update()
|
||||
|
||||
@@ -204,8 +204,8 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase):
|
||||
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
|
||||
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
|
||||
*(
|
||||
[JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system")]
|
||||
if config.auto_update
|
||||
[JobSpec("system_update_check", "检查系统更新", system_update_manager.check_scheduled, "system")]
|
||||
if config.update_check_enabled
|
||||
else []
|
||||
),
|
||||
]
|
||||
@@ -428,8 +428,8 @@ class SchedulerCatalogOwner(_SchedulerOwnerBase):
|
||||
kwargs={"job_id": "plugin_market_refresh"},
|
||||
)
|
||||
|
||||
if config.auto_update:
|
||||
# 更新检查只缓存 Release 元数据,不会在未授权时下载或重启。
|
||||
if config.update_check_enabled:
|
||||
# 任一更新开关开启即注册,执行时分别检查已启用的主程序或资源。
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
|
||||
@@ -73,6 +73,7 @@ class Scheduler(
|
||||
"DB_BACKUP_CRON",
|
||||
"USAGE_STATISTIC_SHARE",
|
||||
"MOVIEPILOT_AUTO_UPDATE",
|
||||
"AUTO_UPDATE_RESOURCE",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -253,6 +253,9 @@ class SystemUpdateRequest(BaseModel): # type: ignore[misc]
|
||||
class SystemUpdateStatus(BaseModel):
|
||||
"""主程序与站点资源后台更新的聚合状态快照。"""
|
||||
|
||||
auto_update: bool = Field(default=False, description="是否启用主程序自动检查及升级提醒")
|
||||
auto_update_resource: bool = Field(default=True, description="是否启用站点资源自动检查及升级提醒")
|
||||
|
||||
state: Literal[
|
||||
"idle",
|
||||
"available",
|
||||
|
||||
@@ -594,6 +594,8 @@ class MediaServerType(Enum):
|
||||
Ugreen = "Ugreen"
|
||||
# Navidrome 音乐服务器
|
||||
Navidrome = "Navidrome"
|
||||
# MediaVault 自建媒体库
|
||||
MediaVault = "MediaVault"
|
||||
|
||||
|
||||
# 识别器类型
|
||||
|
||||
@@ -189,7 +189,8 @@ def build_scheduler_runtime_config(settings: Settings) -> SchedulerRuntimeConfig
|
||||
ai_agent_job_interval=settings.AI_AGENT_JOB_INTERVAL,
|
||||
usage_statistic_share=settings.USAGE_STATISTIC_SHARE,
|
||||
site_link=settings.MP_DOMAIN("#/site"),
|
||||
auto_update=str(settings.MOVIEPILOT_AUTO_UPDATE).strip().lower() == "true",
|
||||
auto_update=settings.MOVIEPILOT_AUTO_UPDATE,
|
||||
auto_update_resource=settings.AUTO_UPDATE_RESOURCE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+107
-7
@@ -32,11 +32,15 @@ function is_truthy_value() {
|
||||
|
||||
# 设置虚拟环境路径(兼容群晖等系统必须这样配置)
|
||||
VENV_PATH="${VENV_PATH:-/opt/venv}"
|
||||
export VENV_PATH
|
||||
export PATH="${VENV_PATH}/bin:$PATH"
|
||||
UV_BIN="${UV_BIN:-/usr/local/bin/uv}"
|
||||
|
||||
# 校正设置目录
|
||||
CONFIG_DIR="${CONFIG_DIR:-/config}"
|
||||
export CONFIG_DIR
|
||||
MP_CONTROL_DIR="${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}"
|
||||
export MP_CONTROL_DIR
|
||||
|
||||
function apply_package_cache_env() {
|
||||
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
|
||||
@@ -49,6 +53,7 @@ function apply_package_cache_env() {
|
||||
# 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值
|
||||
# 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义)
|
||||
function load_config_from_app_env() {
|
||||
# 保留未配置的新 Dev 开关为空,交由更新器兼容旧模式、Python 持久化迁移。
|
||||
|
||||
local env_file="${CONFIG_DIR}/app.env"
|
||||
|
||||
@@ -62,6 +67,7 @@ function load_config_from_app_env() {
|
||||
["PROXY_HOST"]=""
|
||||
["GITHUB_TOKEN"]=""
|
||||
["MOVIEPILOT_AUTO_UPDATE"]="false"
|
||||
["MOVIEPILOT_UPDATE_DEV"]=""
|
||||
["MOVIEPILOT_FORCE_CHOWN"]="false"
|
||||
["MOVIEPILOT_SAFE_MODE"]="false"
|
||||
["BROWSER_EMULATION"]="cloakbrowser"
|
||||
@@ -310,6 +316,36 @@ function maybe_reexec_control_bundle() {
|
||||
fi
|
||||
}
|
||||
|
||||
function run_pending_dev_update_after_supervisor_shutdown() {
|
||||
# 消费由受管重启请求留下的一次性 Dev 更新标记。
|
||||
[ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ] || return 1
|
||||
if ! rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}"; then
|
||||
ERROR "→ 无法消费一次性 Dev 更新标记,停止启动。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local update_exit_code=0
|
||||
MOVIEPILOT_UPDATE_DEV="true"
|
||||
INFO "检测到受管重启的 Dev 更新请求"
|
||||
run_moviepilot_update || update_exit_code=$?
|
||||
MOVIEPILOT_UPDATE_DEV="${MOVIEPILOT_UPDATE_DEV_ORIGINAL}"
|
||||
|
||||
[ "${update_exit_code}" -eq 0 ] \
|
||||
&& [ "${MOVIEPILOT_UPDATE_RESULT:-noop}" = "updated" ]
|
||||
}
|
||||
|
||||
function apply_pending_release_update_at_startup() {
|
||||
# worker 尚未启动就发生容器重启时,由 root 入口兜底消费安装清单。
|
||||
local install_manifest="${CONFIG_DIR}/temp/moviepilot-update/install.json"
|
||||
[ -f "${install_manifest}" ] || return 1
|
||||
INFO "检测到未完成的 Release 安装请求,启动前由 root 安装器恢复"
|
||||
if ! "${VENV_PATH}/bin/python3" -m app.cli apply-prepared-update; then
|
||||
WARN "→ 启动前 Release 更新恢复失败,继续使用当前程序启动。"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
function correct_home_permissions() {
|
||||
local child
|
||||
|
||||
@@ -422,13 +458,14 @@ function correct_file_permissions() {
|
||||
load_config_from_app_env
|
||||
apply_package_cache_env
|
||||
|
||||
# Dev 手动更新仍沿用一次性标记;Release 安装只消费已下载并校验的清单。
|
||||
# Dev 手动更新仍沿用一次性标记;Release 安装由 root 更新 worker 在重启前完成。
|
||||
ONE_SHOT_DEV_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_dev_update"
|
||||
SUPERVISOR_RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart"
|
||||
ONE_SHOT_DEV_UPDATE="false"
|
||||
MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}"
|
||||
MOVIEPILOT_UPDATE_DEV_ORIGINAL="${MOVIEPILOT_UPDATE_DEV}"
|
||||
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
|
||||
rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}"
|
||||
MOVIEPILOT_AUTO_UPDATE="dev"
|
||||
MOVIEPILOT_UPDATE_DEV="true"
|
||||
ONE_SHOT_DEV_UPDATE="true"
|
||||
INFO "检测到一次性 Dev 更新标记,本次启动将更新开发分支"
|
||||
fi
|
||||
@@ -454,7 +491,7 @@ else
|
||||
MOVIEPILOT_UPDATE_RESULT="noop"
|
||||
fi
|
||||
if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then
|
||||
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
|
||||
MOVIEPILOT_UPDATE_DEV="${MOVIEPILOT_UPDATE_DEV_ORIGINAL}"
|
||||
fi
|
||||
if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then
|
||||
ERROR "→ 容器更新回滚未完成,停止启动。"
|
||||
@@ -464,6 +501,14 @@ fi
|
||||
maybe_reexec_control_bundle
|
||||
cd /app || exit
|
||||
|
||||
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ] \
|
||||
&& [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then
|
||||
if apply_pending_release_update_at_startup; then
|
||||
INFO "→ 未完成的 Release 更新已安装,重新执行入口加载新代码。"
|
||||
exec /entrypoint.sh --post-update-reexec
|
||||
fi
|
||||
fi
|
||||
|
||||
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"
|
||||
|
||||
# 更改 moviepilot userid 和 groupid
|
||||
@@ -498,7 +543,62 @@ ensure_browser_kernel
|
||||
# 证书管理
|
||||
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/cert.sh"
|
||||
|
||||
# supervisord 常驻前台并统一托管 Nginx 与后端;容器停止信号由它转发给两个进程组。
|
||||
# supervisord 常驻前台并统一托管 Nginx 与后端;带更新标记的 shutdown 会回到本入口消费更新包。
|
||||
install -d -m 0755 /run/moviepilot
|
||||
INFO "→ 启动容器进程 supervisor..."
|
||||
exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf
|
||||
# Supervisor 的控制面只在容器内使用;未显式传入时生成本次容器启动专用的随机凭据,避免固定密码进入镜像。
|
||||
if [ -z "${MOVIEPILOT_SUPERVISOR_PASSWORD:-}" ]; then
|
||||
MOVIEPILOT_SUPERVISOR_PASSWORD="$(openssl rand -hex 32)" || {
|
||||
ERROR "→ 无法生成 supervisor 控制面认证凭据,停止启动。"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [ -z "${MOVIEPILOT_SUPERVISOR_PASSWORD}" ]; then
|
||||
ERROR "→ supervisor 控制面认证凭据为空,停止启动。"
|
||||
exit 1
|
||||
fi
|
||||
export MOVIEPILOT_SUPERVISOR_PASSWORD
|
||||
SUPERVISOR_SIGNAL_RECEIVED="false"
|
||||
SUPERVISOR_PID=""
|
||||
function forward_supervisor_signal() {
|
||||
SUPERVISOR_SIGNAL_RECEIVED="true"
|
||||
if [ -n "${SUPERVISOR_PID}" ]; then
|
||||
kill -TERM "${SUPERVISOR_PID}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap 'forward_supervisor_signal' SIGINT SIGTERM
|
||||
while true; do
|
||||
INFO "→ 启动容器进程 supervisor..."
|
||||
/usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf &
|
||||
SUPERVISOR_PID=$!
|
||||
wait "${SUPERVISOR_PID}"
|
||||
supervisor_exit_code=$?
|
||||
SUPERVISOR_PID=""
|
||||
|
||||
if [ "${SUPERVISOR_SIGNAL_RECEIVED}" = "true" ] || [ "${supervisor_exit_code}" -ne 0 ]; then
|
||||
exit "${supervisor_exit_code}"
|
||||
fi
|
||||
|
||||
if [ -f "${SUPERVISOR_RESTART_REQUEST_FILE}" ]; then
|
||||
if ! rm -f "${SUPERVISOR_RESTART_REQUEST_FILE}"; then
|
||||
ERROR "→ 无法消费更新后的重启请求,停止启动。"
|
||||
exit 1
|
||||
fi
|
||||
INFO "→ 更新代码已落盘,重新执行容器入口以加载新版本。"
|
||||
exec /entrypoint.sh --post-update-reexec
|
||||
fi
|
||||
|
||||
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
|
||||
if run_pending_dev_update_after_supervisor_shutdown; then
|
||||
INFO "→ 更新包已安装,重新执行容器入口以加载新版本。"
|
||||
exec /entrypoint.sh --post-update-reexec
|
||||
fi
|
||||
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
|
||||
ERROR "→ 更新请求未能完成且标记仍存在,停止启动。"
|
||||
exit 1
|
||||
fi
|
||||
WARN "→ Dev 更新失败,继续启动当前版本。"
|
||||
continue
|
||||
fi
|
||||
|
||||
exit 0
|
||||
done
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
file=/run/moviepilot/supervisor.sock
|
||||
chmod=0770
|
||||
chown=root:moviepilot
|
||||
username=moviepilot
|
||||
password=%(ENV_MOVIEPILOT_SUPERVISOR_PASSWORD)s
|
||||
|
||||
[supervisord]
|
||||
user=root
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
pidfile=/run/moviepilot/supervisord.pid
|
||||
@@ -14,6 +17,8 @@ supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///run/moviepilot/supervisor.sock
|
||||
username=moviepilot
|
||||
password=%(ENV_MOVIEPILOT_SUPERVISOR_PASSWORD)s
|
||||
|
||||
[program:moviepilot-nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
|
||||
@@ -45,3 +50,20 @@ stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:moviepilot-update-worker]
|
||||
command=/bin/bash /usr/local/lib/moviepilot/control/update-worker.sh
|
||||
directory=/app
|
||||
priority=30
|
||||
user=root
|
||||
autostart=false
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=300
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
set -u
|
||||
|
||||
VENV_PATH="${VENV_PATH:-/opt/venv}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/config}"
|
||||
export VENV_PATH CONFIG_DIR
|
||||
SUPERVISOR_CONFIG="/etc/supervisor/supervisord.conf"
|
||||
RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart"
|
||||
|
||||
function INFO() {
|
||||
echo "[INFO] ${1}"
|
||||
}
|
||||
|
||||
function ERROR() {
|
||||
echo "[ERROR] ${1}" >&2
|
||||
}
|
||||
|
||||
cd /app || exit 1
|
||||
|
||||
INFO "→ 开始将已下载的更新包替换到 Docker 程序目录..."
|
||||
if ! "${VENV_PATH}/bin/python3" -m app.cli apply-prepared-update; then
|
||||
ERROR "→ Docker 更新包替换失败,保留当前运行进程。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! mkdir -p "$(dirname "${RESTART_REQUEST_FILE}")"; then
|
||||
ERROR "→ 无法记录更新后的重启请求。"
|
||||
exit 1
|
||||
fi
|
||||
restart_request_tmp="${RESTART_REQUEST_FILE}.tmp.$$"
|
||||
if ! printf '%s\n' update > "${restart_request_tmp}" \
|
||||
|| ! mv -f "${restart_request_tmp}" "${RESTART_REQUEST_FILE}"; then
|
||||
rm -f "${restart_request_tmp}"
|
||||
ERROR "→ 无法记录更新后的重启请求。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INFO "→ 更新包已替换,通知 supervisor 关闭并由容器入口重新启动新代码..."
|
||||
if ! /usr/bin/supervisorctl -c "${SUPERVISOR_CONFIG}" shutdown; then
|
||||
ERROR "→ supervisor 关闭请求失败,更新代码已落盘,可手动重启后生效。"
|
||||
exit 1
|
||||
fi
|
||||
+31
-236
@@ -31,27 +31,6 @@ PUBLIC_DIR=/public
|
||||
UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__"
|
||||
UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__"
|
||||
UPDATE_PREVIOUS_PUBLIC="${PUBLIC_DIR}.__update_previous__"
|
||||
PREPARED_UPDATE_ROOT="${CONFIG_DIR}/temp/moviepilot-update"
|
||||
PREPARED_UPDATE_MANIFEST="${PREPARED_UPDATE_ROOT}/install.json"
|
||||
PREPARED_DOWNLOAD_MANIFEST="${PREPARED_UPDATE_ROOT}/prepared.json"
|
||||
PREPARED_UPDATE_STATE="${PREPARED_UPDATE_ROOT}/state.json"
|
||||
|
||||
function mark_prepared_update_failed() {
|
||||
local message="$1"
|
||||
local temporary_state="${PREPARED_UPDATE_STATE}.tmp.$$"
|
||||
mkdir -p "${PREPARED_UPDATE_ROOT}"
|
||||
if [ -f "${PREPARED_UPDATE_STATE}" ]; then
|
||||
jq --arg error "${message}" \
|
||||
'.state = "failed" | .error = $error | .can_update = true | .can_install = false | .updates = ((.updates // []) | map(if .state == "installing" then .state = "failed" | .error = $error | .can_update = true | .can_install = false else . end))' \
|
||||
"${PREPARED_UPDATE_STATE}" > "${temporary_state}"
|
||||
else
|
||||
jq -n --arg error "${message}" \
|
||||
'{state: "failed", error: $error, can_update: true, can_install: false, updates: []}' \
|
||||
> "${temporary_state}"
|
||||
fi
|
||||
mv -f "${temporary_state}" "${PREPARED_UPDATE_STATE}"
|
||||
rm -f "${PREPARED_UPDATE_MANIFEST}"
|
||||
}
|
||||
|
||||
function apply_package_cache_env() {
|
||||
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
|
||||
@@ -309,91 +288,6 @@ function existing_resource_dir() {
|
||||
printf '%s\n' "${resource_source_dir}"
|
||||
}
|
||||
|
||||
function prepared_update_has_target() {
|
||||
local target="$1"
|
||||
jq -e --arg target "${target}" \
|
||||
'if (.targets | type) == "array" then (.targets | index($target)) != null else $target == "application" and (.backend_archive // "") != "" end' \
|
||||
"${PREPARED_UPDATE_MANIFEST}" >/dev/null
|
||||
}
|
||||
|
||||
function validate_prepared_resources() {
|
||||
local resource_path
|
||||
local resource_name
|
||||
local resource_sha256
|
||||
local resource_count
|
||||
resource_count=$(jq -r '.resource_files // [] | length' "${PREPARED_UPDATE_MANIFEST}") || return 1
|
||||
[ "${resource_count}" -gt 0 ] || return 1
|
||||
jq -e '([.resource_files[]?.name] | index("user.sites.v3.bin")) != null and any(.resource_files[]?.name; startswith("sites."))' "${PREPARED_UPDATE_MANIFEST}" >/dev/null || return 1
|
||||
while IFS=$'\t' read -r resource_path resource_name resource_sha256; do
|
||||
[ -n "${resource_path}" ] && [ -n "${resource_name}" ] || return 1
|
||||
[ "$(basename "${resource_name}")" = "${resource_name}" ] || return 1
|
||||
[[ "${resource_name}" != *..* ]] || return 1
|
||||
[ -f "${resource_path}" ] || return 1
|
||||
[ "$(sha256sum "${resource_path}" | awk '{print $1}')" = "${resource_sha256}" ] || return 1
|
||||
done < <(jq -r '.resource_files[]? | [.path, .name, .sha256] | @tsv' "${PREPARED_UPDATE_MANIFEST}")
|
||||
}
|
||||
|
||||
function consume_prepared_target() {
|
||||
local target="$1"
|
||||
local temporary="${PREPARED_DOWNLOAD_MANIFEST}.tmp.$$"
|
||||
[ -f "${PREPARED_DOWNLOAD_MANIFEST}" ] || return 0
|
||||
jq --arg target "${target}" '
|
||||
if $target == "application" then
|
||||
del(.version, .frontend_version, .backend_archive, .frontend_archive, .backend_sha256, .frontend_sha256)
|
||||
elif $target == "resources" then
|
||||
del(.resource_package_version, .resource_files)
|
||||
else . end
|
||||
| if (.targets | type) == "array" then
|
||||
.targets = [.targets[] | select(. != $target)]
|
||||
| if (.targets | length) == 0 then del(.targets) else . end
|
||||
else . end
|
||||
' "${PREPARED_DOWNLOAD_MANIFEST}" > "${temporary}" || {
|
||||
rm -f "${temporary}"
|
||||
return 1
|
||||
}
|
||||
if jq -e '((.backend_archive // "") == "") and (((.resource_files // []) | length) == 0)' "${temporary}" >/dev/null; then
|
||||
rm -f "${temporary}" "${PREPARED_DOWNLOAD_MANIFEST}"
|
||||
else
|
||||
mv -f "${temporary}" "${PREPARED_DOWNLOAD_MANIFEST}"
|
||||
fi
|
||||
}
|
||||
|
||||
function clear_staged_native_resources() {
|
||||
local resource_dir="$1"
|
||||
rm -f "${resource_dir}"/sites.*.so "${resource_dir}"/sites.*.pyd "${resource_dir}"/sites.*.dylib
|
||||
}
|
||||
|
||||
function apply_prepared_resources() {
|
||||
local target_dir="${APP_DIR}/app/application/site"
|
||||
local stage_dir="${TMP_PATH}/PreparedResources"
|
||||
local backup_dir="${target_dir}.__prepared_previous__"
|
||||
local resource_path
|
||||
local resource_name
|
||||
|
||||
validate_prepared_resources || return 1
|
||||
rm -rf "${stage_dir}" "${backup_dir}"
|
||||
mkdir -p "${stage_dir}" "${target_dir}" || return 1
|
||||
if [ -d "${target_dir}" ] && ! cp -a "${target_dir}/." "${stage_dir}/"; then
|
||||
return 1
|
||||
fi
|
||||
clear_staged_native_resources "${stage_dir}"
|
||||
while IFS=$'\t' read -r resource_path resource_name; do
|
||||
[ -n "${resource_path}" ] && [ -n "${resource_name}" ] || return 1
|
||||
cp -f "${resource_path}" "${stage_dir}/${resource_name}" || return 1
|
||||
done < <(jq -r '.resource_files[]? | [.path, .name] | @tsv' "${PREPARED_UPDATE_MANIFEST}")
|
||||
|
||||
if [ -d "${target_dir}" ]; then
|
||||
mv "${target_dir}" "${backup_dir}" || return 1
|
||||
fi
|
||||
if ! mkdir -p "${target_dir}" || ! cp -a "${stage_dir}/." "${target_dir}/"; then
|
||||
rm -rf "${target_dir}"
|
||||
[ -d "${backup_dir}" ] && mv "${backup_dir}" "${target_dir}"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "${backup_dir}" "${stage_dir}"
|
||||
return 0
|
||||
}
|
||||
|
||||
function download_staged_resource() {
|
||||
local url="$1"
|
||||
local destination="$2"
|
||||
@@ -456,17 +350,6 @@ function stage_runtime_payload() {
|
||||
cp -a "${resource_file}" "${stage_resource_dir}/" || return 1
|
||||
done
|
||||
|
||||
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
|
||||
if prepared_update_has_target resources; then
|
||||
clear_staged_native_resources "${stage_resource_dir}"
|
||||
while IFS=$'\t' read -r resource_path resource_name; do
|
||||
[ -n "${resource_path}" ] && [ -n "${resource_name}" ] || return 1
|
||||
cp -f "${resource_path}" "${stage_resource_dir}/${resource_name}" || return 1
|
||||
done < <(jq -r '.resource_files[]? | [.path, .name] | @tsv' "${PREPARED_UPDATE_MANIFEST}")
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
python_version="$("${VENV_PATH}/bin/python3" -c 'import sys, sysconfig; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}{"t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""}")')" || return 1
|
||||
arch="$(uname -m)"
|
||||
if [ "${arch}" = "aarch64" ]; then
|
||||
@@ -503,15 +386,7 @@ function swap_staged_payload() {
|
||||
# 下载程序资源,$1: 后端版本路径
|
||||
function install_backend_and_download_resources() {
|
||||
# 更新后端程序
|
||||
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
|
||||
if ! busybox unzip -q "${PREPARED_BACKEND_ARCHIVE}" -d "${TMP_PATH}"; then
|
||||
ERROR "已准备的后端更新包解压失败"
|
||||
return 1
|
||||
fi
|
||||
if [ -e "${TMP_PATH}"/MoviePilot-* ]; then
|
||||
mv "${TMP_PATH}"/MoviePilot-* "${TMP_PATH}/App" || return 1
|
||||
fi
|
||||
elif ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
|
||||
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
|
||||
WARN "后端程序下载失败,继续使用旧的程序来启动..."
|
||||
return 1
|
||||
fi
|
||||
@@ -532,10 +407,7 @@ function install_backend_and_download_resources() {
|
||||
fi
|
||||
|
||||
# 如果是"heads/v3.zip",则查找v3开头的最新版本号
|
||||
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
|
||||
frontend_version="${PREPARED_FRONTEND_VERSION}"
|
||||
INFO "已准备的前端版本号:${frontend_version}"
|
||||
elif [[ "${1}" == "heads/v3.zip" ]]; then
|
||||
if [[ "${1}" == "heads/v3.zip" ]]; then
|
||||
INFO "→ 正在获取前端最新版本号..."
|
||||
# 获取所有发布的版本列表,并筛选出以v3开头的版本号
|
||||
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v3\.")
|
||||
@@ -558,12 +430,7 @@ function install_backend_and_download_resources() {
|
||||
INFO "前端版本号:${frontend_version}"
|
||||
fi
|
||||
# 更新前端程序
|
||||
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
|
||||
if ! busybox unzip -q "${PREPARED_FRONTEND_ARCHIVE}" -d "${TMP_PATH}"; then
|
||||
ERROR "已准备的前端更新包解压失败"
|
||||
return 1
|
||||
fi
|
||||
elif ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
|
||||
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
|
||||
WARN "前端程序下载失败,继续使用旧的程序来启动..."
|
||||
return 1
|
||||
fi
|
||||
@@ -728,110 +595,38 @@ function configure_package_route() {
|
||||
}
|
||||
|
||||
function run_moviepilot_update() {
|
||||
MOVIEPILOT_UPDATE_RESULT="noop"
|
||||
if [ -f "${PREPARED_UPDATE_MANIFEST}" ]; then
|
||||
PREPARED_HAS_APPLICATION="false"
|
||||
PREPARED_HAS_RESOURCES="false"
|
||||
if prepared_update_has_target application; then PREPARED_HAS_APPLICATION="true"; fi
|
||||
if prepared_update_has_target resources; then PREPARED_HAS_RESOURCES="true"; fi
|
||||
PREPARED_BACKEND_ARCHIVE=$(jq -r '.backend_archive // empty' "${PREPARED_UPDATE_MANIFEST}")
|
||||
PREPARED_FRONTEND_ARCHIVE=$(jq -r '.frontend_archive // empty' "${PREPARED_UPDATE_MANIFEST}")
|
||||
PREPARED_BACKEND_SHA256=$(jq -r '.backend_sha256 // empty' "${PREPARED_UPDATE_MANIFEST}")
|
||||
PREPARED_FRONTEND_SHA256=$(jq -r '.frontend_sha256 // empty' "${PREPARED_UPDATE_MANIFEST}")
|
||||
PREPARED_VERSION=$(jq -r '.version // empty' "${PREPARED_UPDATE_MANIFEST}")
|
||||
PREPARED_FRONTEND_VERSION=$(jq -r '.frontend_version // empty' "${PREPARED_UPDATE_MANIFEST}")
|
||||
if [ "${PREPARED_HAS_APPLICATION}" = "true" ] && { [ ! -f "${PREPARED_BACKEND_ARCHIVE}" ] || [ ! -f "${PREPARED_FRONTEND_ARCHIVE}" ] \
|
||||
|| [ "$(sha256sum "${PREPARED_BACKEND_ARCHIVE}" | awk '{print $1}')" != "${PREPARED_BACKEND_SHA256}" ] \
|
||||
|| [ "$(sha256sum "${PREPARED_FRONTEND_ARCHIVE}" | awk '{print $1}')" != "${PREPARED_FRONTEND_SHA256}" ]; }; then
|
||||
ERROR "已准备的更新包校验失败,拒绝安装"
|
||||
mark_prepared_update_failed "已准备的更新包校验失败"
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
return 1
|
||||
# 新 Dev 开关独立于自动检查;仅在未配置新开关时兼容首次启动的旧 dev 值。
|
||||
MOVIEPILOT_UPDATE_RESULT="noop"
|
||||
local dev_update="${MOVIEPILOT_UPDATE_DEV:-}"
|
||||
if [ -z "${dev_update}" ] && [[ "${MOVIEPILOT_AUTO_UPDATE:-}" == [Dd][Ee][Vv] ]]; then
|
||||
dev_update="true"
|
||||
fi
|
||||
if [ "${PREPARED_HAS_RESOURCES}" = "true" ] && ! validate_prepared_resources; then
|
||||
ERROR "已准备的站点资源包校验失败,拒绝安装"
|
||||
mark_prepared_update_failed "已准备的站点资源包校验失败"
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
return 1
|
||||
fi
|
||||
if [ "${PREPARED_HAS_APPLICATION}" != "true" ] && [ "${PREPARED_HAS_RESOURCES}" != "true" ]; then
|
||||
ERROR "已准备的更新清单没有可安装内容,拒绝安装"
|
||||
mark_prepared_update_failed "已准备的更新清单没有可安装内容"
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
return 1
|
||||
fi
|
||||
MOVIEPILOT_PREPARED_UPDATE="true"
|
||||
TMP_PATH=$(mktemp -d)
|
||||
if [ ! -d "${TMP_PATH}" ]; then
|
||||
# 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况
|
||||
TMP_PATH=/tmp/mp_update_path
|
||||
if [ -d /tmp/mp_update_path ]; then
|
||||
rm -rf /tmp/mp_update_path
|
||||
if [[ "${dev_update}" == [Tt][Rr][Uu][Ee] ]]; then
|
||||
TMP_PATH=$(mktemp -d)
|
||||
if [ ! -d "${TMP_PATH}" ]; then
|
||||
TMP_PATH=/tmp/mp_update_path
|
||||
rm -rf "${TMP_PATH}"
|
||||
mkdir -p "${TMP_PATH}"
|
||||
fi
|
||||
mkdir -p /tmp/mp_update_path
|
||||
fi
|
||||
CURL_OPTIONS="-sL"
|
||||
if [ -n "${PROXY_HOST}" ]; then
|
||||
CURL_OPTIONS="-sL -x ${PROXY_HOST}"
|
||||
fi
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
|
||||
else
|
||||
CURL_HEADERS=""
|
||||
fi
|
||||
INFO "安装已下载并校验的 MoviePilot 更新包"
|
||||
prepared_install_success="true"
|
||||
if [ "${PREPARED_HAS_APPLICATION}" = "true" ]; then
|
||||
if ! install_backend_and_download_resources "tags/${PREPARED_VERSION}.zip"; then
|
||||
prepared_install_success="false"
|
||||
elif [ "${PREPARED_HAS_RESOURCES}" = "true" ] && ! consume_prepared_target resources; then
|
||||
prepared_install_success="false"
|
||||
elif ! consume_prepared_target application; then
|
||||
prepared_install_success="false"
|
||||
retries=0
|
||||
while true; do
|
||||
if test_connectivity_github "${retries}"; then
|
||||
break
|
||||
fi
|
||||
retries=$((retries + 1))
|
||||
done
|
||||
INFO "Github:${GITHUB_LOG}"
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
|
||||
else
|
||||
CURL_HEADERS=""
|
||||
fi
|
||||
INFO "Dev 更新模式"
|
||||
if ! install_backend_and_download_resources "heads/v3.zip"; then
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
fi
|
||||
fi
|
||||
if [ "${PREPARED_HAS_APPLICATION}" != "true" ] && [ "${PREPARED_HAS_RESOURCES}" = "true" ] \
|
||||
&& ! apply_prepared_resources; then
|
||||
prepared_install_success="false"
|
||||
elif [ "${PREPARED_HAS_APPLICATION}" != "true" ] && [ "${PREPARED_HAS_RESOURCES}" = "true" ] \
|
||||
&& ! consume_prepared_target resources; then
|
||||
prepared_install_success="false"
|
||||
fi
|
||||
if [ "${prepared_install_success}" = "true" ]; then
|
||||
rm -f "${PREPARED_UPDATE_MANIFEST}"
|
||||
else
|
||||
mark_prepared_update_failed "已下载的 Release 更新安装失败"
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
fi
|
||||
if [ -d "${TMP_PATH}" ]; then
|
||||
rm -rf "${TMP_PATH}"
|
||||
fi
|
||||
elif [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
|
||||
TMP_PATH=$(mktemp -d)
|
||||
if [ ! -d "${TMP_PATH}" ]; then
|
||||
TMP_PATH=/tmp/mp_update_path
|
||||
rm -rf "${TMP_PATH}"
|
||||
mkdir -p "${TMP_PATH}"
|
||||
fi
|
||||
retries=0
|
||||
while true; do
|
||||
if test_connectivity_github ${retries}; then
|
||||
break
|
||||
fi
|
||||
retries=$((retries + 1))
|
||||
done
|
||||
INFO "Github:${GITHUB_LOG}"
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
|
||||
else
|
||||
CURL_HEADERS=""
|
||||
INFO "没有待安装 Dev 更新,按当前版本启动"
|
||||
fi
|
||||
INFO "Dev 更新模式"
|
||||
if ! install_backend_and_download_resources "heads/v3.zip"; then
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
fi
|
||||
rm -rf "${TMP_PATH}"
|
||||
else
|
||||
INFO "没有待安装更新,按当前版本启动"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -754,8 +754,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 973 |
|
||||
| 内部导入边 | 8,263 |
|
||||
| Python 模块 | 980 |
|
||||
| 内部导入边 | 8,302 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 976 / 8,263 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 980 / 8,302 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,494 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 541 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 全量 mypy 历史债务 | 9,441 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 538 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
+1
-1
@@ -393,7 +393,7 @@ moviepilot version
|
||||
|
||||
- `start` 会先启动后端,再启动前端
|
||||
- `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置
|
||||
- `MOVIEPILOT_AUTO_UPDATE` 默认关闭;设置为 `true` 时启用后台 Release 检查,设置为 `dev` 时保留启动前跟踪当前 v3 开发分支的行为,更新失败只告警,不阻断当前启动
|
||||
- `MOVIEPILOT_AUTO_UPDATE` 为布尔开关,默认 `false`;只有 `true` 启用后台 Release 检查和版本提醒,保存后定时服务热更新。`AUTO_UPDATE_RESOURCE` 独立控制站点资源检查和提醒;任一开关开启即启用检测服务,且只检查对应目标,两者均关闭才移除服务。`MOVIEPILOT_UPDATE_DEV` 为独立布尔开关,默认 `false`;设为 `true` 时在每次启动/重启前跟踪当前 v3 开发分支,更新失败只告警,不阻断当前启动。旧 `dev/release` 值统一转换为 `MOVIEPILOT_AUTO_UPDATE=true`;旧 `dev` 在未显式配置新开关时迁移为 `MOVIEPILOT_UPDATE_DEV=true`
|
||||
- Release 更新由后台每 6 小时检查 GitHub Release;管理员确认后先静默下载安装包并显示进度,下载完成后再次确认重启,启动阶段只安装已下载且通过 SHA-256 校验的包
|
||||
- 页面中的“稍后”会在当前浏览器暂停提醒 24 小时,“忽略此版本”只屏蔽当前版本;出现更高版本时会重新提示
|
||||
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
||||
|
||||
+46
-23
@@ -4,7 +4,7 @@
|
||||
`docker/` 下的控制脚本、更新事务、依赖自愈、浏览器和证书准备、Nginx、Python lifespan、
|
||||
异常保活与退出清理。
|
||||
|
||||
本文基于 `v3` 分支 2026-09-02 的实现整理。实际行为以当前源码为准。
|
||||
本文基于 `v3` 分支 2026-09-07 的实现整理。实际行为以当前源码为准。
|
||||
|
||||
## 1. 文件职责
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器和证书准备,最后启动 supervisor。 |
|
||||
| `docker/backend.sh` | supervisor 托管的后端进程命令,负责工作目录、权限和 Python 进程。 |
|
||||
| `docker/supervisord.conf` | 容器内 supervisor 配置,同时托管 Nginx 和后端。 |
|
||||
| `docker/update.sh` | 被 `entrypoint.sh` source;处理未完成更新恢复、已准备 Release 安装、Dev 更新、依赖同步和载荷事务。 |
|
||||
| `docker/update.sh` | 被 `entrypoint.sh` source;处理未完成更新恢复、Dev 更新、依赖同步和载荷事务,不再安装 Release 程序包。 |
|
||||
| `docker/update-worker.sh` | 由 Supervisor 以 root 按需运行;将已确认的 Release/资源制品替换到 Docker 程序目录,然后请求入口重新加载新代码。 |
|
||||
| `docker/browser.sh` | 被 `entrypoint.sh` source;选择持久化 CloakBrowser 缓存、校正权限并按需安装浏览器内核。 |
|
||||
| `docker/cert.sh` | 被 `entrypoint.sh` source;校验证书、按需安装 acme.sh、签发证书并配置续期任务。 |
|
||||
| `docker/nginx.template.conf` | 由环境变量渲染为 `/etc/nginx/nginx.conf`,提供前端静态文件、API 和 SSE 反向代理。 |
|
||||
@@ -33,7 +34,7 @@ Docker
|
||||
-> 渲染 Nginx 配置
|
||||
-> source update.sh
|
||||
-> 恢复未完成更新
|
||||
-> 安装已准备的 Release/资源包,或执行 Dev 更新
|
||||
-> 仅执行 Dev 更新
|
||||
-> 必要时用更新后的控制脚本重新 exec 一次
|
||||
-> source browser.sh
|
||||
-> 映射 PUID/PGID
|
||||
@@ -45,6 +46,7 @@ Docker
|
||||
-> supervisord
|
||||
-> Nginx
|
||||
-> gosu moviepilot python3 app/main.py
|
||||
-> 按需启动 root update-worker.sh 安装已确认 Release/资源制品
|
||||
-> Uvicorn/FastAPI lifespan
|
||||
-> 数据库迁移和全部生命周期组件
|
||||
-> /health/ready 返回 200
|
||||
@@ -63,7 +65,7 @@ Docker
|
||||
|
||||
### 3.1 为什么不直接执行 `/app/docker/entrypoint.sh`
|
||||
|
||||
Docker 的 Dev 更新可以在启动过程中整体替换 `/app`。如果当前 Shell 正在从 `/app/docker` 继续
|
||||
Docker 的 Dev 更新和 root 更新 worker 都可能整体替换 `/app`。如果当前 Shell 正在从 `/app/docker` 继续
|
||||
source 其他脚本,可能出现同一次启动混用新旧脚本的情况。因此 launcher 会先选择完整的一代控制脚本,
|
||||
再复制到只属于本轮启动的运行时快照目录。
|
||||
|
||||
@@ -157,7 +159,7 @@ source 其他脚本,可能出现同一次启动混用新旧脚本的情况。
|
||||
/config/temp/moviepilot.pending_dev_update
|
||||
```
|
||||
|
||||
entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_AUTO_UPDATE` 临时设为 `dev`。更新阶段结束后
|
||||
entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_UPDATE_DEV` 临时设为 `true`。更新阶段结束后
|
||||
恢复原值,避免把一次性操作变成永久自动更新。
|
||||
|
||||
### 5.2 未完成更新恢复
|
||||
@@ -184,8 +186,8 @@ Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“
|
||||
|
||||
### 5.3 已准备的 Release/资源更新
|
||||
|
||||
稳定版更新不在容器启动时联网检查 GitHub Release,也不在 Shell 中比较版本号。后台更新服务会提前
|
||||
下载并校验制品,用户确认重启后生成:
|
||||
稳定版更新不在容器启动时联网检查 GitHub Release,也不在 `update.sh` 中比较版本号或替换程序。
|
||||
后台更新服务负责下载和校验制品;用户确认安装后生成:
|
||||
|
||||
```text
|
||||
/config/temp/moviepilot-update/prepared.json
|
||||
@@ -193,18 +195,25 @@ Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“
|
||||
/config/temp/moviepilot-update/state.json
|
||||
```
|
||||
|
||||
启动时 `install.json` 优先于 `MOVIEPILOT_AUTO_UPDATE`。处理顺序:
|
||||
随后 `SystemHelper` 请求 Supervisor 启动 root 更新 worker,处理顺序:
|
||||
|
||||
1. 识别 `application`、`resources` 目标。
|
||||
1. root worker 识别 `application`、`resources` 目标。
|
||||
2. 校验后端、前端和资源文件存在且 SHA-256 一致。
|
||||
3. 应用更新时解压后端和前端到临时目录,保留当前插件运行目录和 V3 站点资源。
|
||||
4. 同时包含资源目标时,把已准备资源写入新后端的 `app/application/site/`。
|
||||
5. 只有资源目标时,使用临时目录和备份目录原子替换当前资源文件。
|
||||
6. 安装成功后逐项消费 `prepared.json`,删除 `install.json`;失败时写入 `state.json` 并保留可重试状态。
|
||||
5. 只有资源目标时,使用临时目录和备份目录原子替换当前源码携带的资源文件。
|
||||
6. 后端依赖清单变化时,由 root worker 使用共享虚拟环境同步锁定依赖。
|
||||
7. 源码和资源替换完成后消费下载清单,写入重启标记并关闭 Supervisor;外层入口重新执行 launcher,加载新代码。
|
||||
8. 校验、依赖或替换失败时不关闭当前服务,写入 `state.json` 的可重试失败状态。
|
||||
|
||||
如果 worker 尚未启动就发生外部容器重启,入口会在启动 Supervisor 前调用同一个 root 安装器兜底消费
|
||||
`install.json`,成功后重新执行 launcher;这条恢复路径也不经过 `update.sh`。
|
||||
|
||||
因此,制品下载完成后仍保留用户确认安装这一安全边界;确认后先替换 Docker 中的程序目录,重启只负责加载已经落盘的新代码。
|
||||
|
||||
### 5.4 Dev 自动更新
|
||||
|
||||
仅当 `MOVIEPILOT_AUTO_UPDATE=dev` 时,启动脚本会联网获取 `v3` 分支源码和最新 V3 前端 Release。
|
||||
仅当 `MOVIEPILOT_UPDATE_DEV=true` 时(首次升级兼容尚未迁移且未配置新开关的旧 `MOVIEPILOT_AUTO_UPDATE=dev`),启动脚本会联网获取 `v3` 分支源码和最新 V3 前端 Release。
|
||||
GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引按 `PIP_PROXY`、`PROXY_HOST`、
|
||||
直连顺序选择。
|
||||
|
||||
@@ -212,9 +221,9 @@ GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引
|
||||
|
||||
### 5.5 载荷切换事务
|
||||
|
||||
应用更新的提交顺序为:
|
||||
Release root worker 的应用更新提交顺序为:
|
||||
|
||||
1. 下载、解压并验证后端和前端。
|
||||
1. 后台下载后,在 worker 中解压并验证后端和前端。
|
||||
2. 暂存插件和站点资源。
|
||||
3. 写入 `prepared`。
|
||||
4. 依赖清单变化时写入 `dependencies`,再同步临时后端声明的依赖。
|
||||
@@ -223,6 +232,9 @@ GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引
|
||||
7. 写入 `committed`。
|
||||
8. 删除旧代备份和事务标记。
|
||||
|
||||
Dev 更新仍由 `update.sh` 使用同一组事务标记处理;非 Dev 的制品替换只在
|
||||
`app.adapters.system.update.SystemUpdateManager` 和 `docker/update-worker.sh` 中执行。
|
||||
|
||||
依赖同步固定使用当前虚拟环境解释器,并执行等价于:
|
||||
|
||||
```text
|
||||
@@ -235,7 +247,7 @@ uv sync --project <project> --locked --inexact --no-dev --no-install-project \
|
||||
|
||||
### 5.6 控制脚本更新后重入
|
||||
|
||||
应用更新成功后,entrypoint 通过根目录 launcher 的 `--source-generation` 重新计算 `/app/docker` 代际。
|
||||
Release 或 Dev 应用更新成功后,entrypoint 通过根目录 launcher 的 `--source-generation` 重新计算 `/app/docker` 代际。
|
||||
如果新代际与当前 `MP_CONTROL_GENERATION` 不同,会:
|
||||
|
||||
```text
|
||||
@@ -250,7 +262,10 @@ launcher 设置“更新已完成”和“已经重入”标志,新 entrypoint
|
||||
### 6.1 运行用户映射
|
||||
|
||||
entrypoint 使用 `PUID`、`PGID` 修改镜像内 `moviepilot` 用户和组。后端、浏览器安装及 doctor 默认通过
|
||||
`gosu moviepilot:moviepilot` 执行;`START_NOGOSU=true` 仅用于不降权的特殊运行场景。
|
||||
`gosu moviepilot:moviepilot` 执行;Release 更新 worker 明确以 root 运行来替换 root 所有的 `/app` 和
|
||||
`/public`,不会把运行权限提升给后端;`START_NOGOSU=true` 仅用于不降权的特殊运行场景。即使 `PUID/PGID`
|
||||
设置为非 0,内置重启仍然可用,因为 Supervisor socket 会使用映射后的 `moviepilot` 组权限;但容器入口
|
||||
本身必须以 root 启动,不能额外使用 Docker 的 `--user` 覆盖入口用户。
|
||||
|
||||
### 6.2 后端依赖自愈
|
||||
|
||||
@@ -310,8 +325,10 @@ entrypoint 使用 `PUID`、`PGID` 修改镜像内 `moviepilot` 用户和组。
|
||||
### 7.2 Nginx 和 supervisor
|
||||
|
||||
证书检查完成后,entrypoint 以前台模式启动 supervisor。supervisor 同时托管 `moviepilot-nginx` 与
|
||||
`moviepilot-backend`,两者异常退出时自动拉起。控制 socket 位于 `/run/moviepilot/supervisor.sock`,权限为
|
||||
`root:moviepilot`、`0770`,后端运行用户可访问;镜像不再挂载或代理 Docker Socket。
|
||||
`moviepilot-backend`,两者异常退出时自动拉起;显式安装更新时还会按需启动 root worker。控制 socket 位于 `/run/moviepilot/supervisor.sock`,权限为
|
||||
`root:moviepilot`、`0770`,后端运行用户可访问;控制面同时启用认证,密码默认在每次容器启动时随机生成,
|
||||
不写入配置卷。supervisor 本身显式以 root 运行以管理 Nginx,后端仍由 `backend.sh` 降权为
|
||||
`moviepilot`;镜像不再挂载或代理 Docker Socket。
|
||||
|
||||
## 8. Python 后端启动
|
||||
|
||||
@@ -400,8 +417,12 @@ Python lifespan 会先撤销 readiness,再按组件声明的 `stop_order` 停
|
||||
|
||||
### 9.2 应用内重启
|
||||
|
||||
应用请求重启时,通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。supervisor 先向旧进程组发送
|
||||
SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不访问 Docker API,也不依赖 Docker restart policy。
|
||||
普通应用重启通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。确认安装 Release 时,
|
||||
`SystemHelper` 只启动 root `moviepilot-update-worker`;worker 先替换 `/app`、`/public` 或站点资源,
|
||||
再写入 `moviepilot.pending_supervisor_restart` 并执行 `supervisorctl shutdown`。外层 entrypoint 看到标记后
|
||||
重新执行 launcher,加载新代码。Dev 更新仍通过一次性 Dev 标记关闭 Supervisor,再由 entrypoint 调用
|
||||
`update.sh`。这样更新包不会因只重启受管进程而停留在暂存目录;整个过程不访问 Docker API,也不依赖
|
||||
Docker restart policy。
|
||||
|
||||
### 9.3 异常诊断
|
||||
|
||||
@@ -423,8 +444,9 @@ SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不
|
||||
| `/app.__update_previous__` | 更新前后端备份。 |
|
||||
| `/public.__update_previous__` | 更新前前端备份。 |
|
||||
| `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 |
|
||||
| `/config/temp/moviepilot.pending_supervisor_restart` | Release worker 已替换程序、等待入口重新加载的标记。 |
|
||||
| `/config/temp/moviepilot.pending_dev_update` | 单次 Dev 更新请求。 |
|
||||
| `/run/moviepilot/supervisor.sock` | 容器内 supervisor 控制 socket,权限为 `root:moviepilot`、`0770`。 |
|
||||
| `/run/moviepilot/supervisor.sock` | 容器内 supervisor 控制 socket,权限为 `root:moviepilot`、`0770`,并启用本次容器启动的认证凭据。 |
|
||||
| `/config/certs/latest/` | Nginx 使用的稳定证书路径。 |
|
||||
|
||||
## 11. 关键环境变量
|
||||
@@ -436,7 +458,8 @@ SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不
|
||||
| `UMASK` | `000` | 后端进程文件权限掩码。 |
|
||||
| `PORT` | `3001` | 后端监听和 readiness 端口。 |
|
||||
| `NGINX_PORT` | `3000` | HTTP 前端入口。 |
|
||||
| `MOVIEPILOT_AUTO_UPDATE` | `false` | 只有 `dev` 会触发启动时分支更新;稳定版使用准备清单。 |
|
||||
| `MOVIEPILOT_AUTO_UPDATE` | `false` | 布尔开关,仅 `true` 开启后台版本检查和升级提醒;关闭后不检查主程序;`AUTO_UPDATE_RESOURCE=true` 时仍启用服务且只检查站点资源。稳定版下载及安装需手动确认。旧 `dev/release` 统一迁移为 `true`。 |
|
||||
| `MOVIEPILOT_UPDATE_DEV` | `false` | 独立布尔开关,`true` 触发 `update.sh` 的启动时 Dev 分支更新;旧 `dev` 在未显式配置此开关时保留跟踪偏好。 |
|
||||
| `MOVIEPILOT_SAFE_MODE` | `false` | 跳过普通模式专属的插件及后台控制面。 |
|
||||
| `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 |
|
||||
| `PACKAGE_CACHE_ROOT` | `/config/.cache` | 包管理缓存根目录。 |
|
||||
@@ -455,7 +478,7 @@ SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不
|
||||
后续修改 Docker 启动流程时应保持以下边界:
|
||||
|
||||
1. 控制脚本必须按完整代际执行,不能在同一次启动中直接混用更新前后的 `/app/docker/*.sh`。
|
||||
2. Release 更新由后台下载和用户确认驱动;启动脚本只消费已校验清单,不恢复启动时 GitHub Release 查询和 Shell 版本比较。
|
||||
2. Release 更新由后台下载、用户确认和 root worker 驱动;`update.sh` 不得恢复 Release 程序/资源替换、启动时 GitHub Release 查询或 Shell 版本比较。
|
||||
3. 更新载荷与共享虚拟环境必须作为一个可恢复事务处理,不能留下新源码配旧依赖或旧源码配新数据库的混合状态。
|
||||
4. 标准 V3 与 V3t 依赖恢复必须复用 `app.runtime.dependencies.profile`,不能使用默认组覆盖当前 ABI profile。
|
||||
5. 站点资源只安装到 `app/application/site/`;历史目录仅用于更新旧载荷时读取兼容资源。
|
||||
|
||||
+4
-4
@@ -45,7 +45,7 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
|
||||
| :--- | :--- | :--- |
|
||||
| `moviepilot_api` | MoviePilot 产品业务 API:媒体、搜索、订阅、下载、整理、站点、存储、调度、工作流、插件、过滤规则和系统配置 | `skills/moviepilot-api/SKILL.md`;运行时 schema 为 `app/agent/policy/resources/api_mcp_schema.json` |
|
||||
| `downloader_operation` | qBittorrent、Transmission、rTorrent 原生任务、队列、文件、限速、标签和会话操作 | `skills/downloader-operation/SKILL.md` 与 `skills/downloader-operation/scripts/mp-downloader.py` 的 `ACTIONS` |
|
||||
| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` |
|
||||
| `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome、MediaVault 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` |
|
||||
| `database_operation` | MoviePilot 配置数据库表清单、实时 schema、只读 SQL 和明确授权写入 | `skills/database-operation/SKILL.md` 与 `skills/database-operation/scripts/mp-db.py` 的 `ACTIONS` |
|
||||
|
||||
这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。
|
||||
@@ -253,11 +253,11 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
||||
|
||||
#### 系统更新
|
||||
|
||||
系统 Release 更新采用“检查、后台下载、确认安装”三阶段流程,以下接口均要求超级管理员登录态。后台每 6 小时自动检查一次稳定版 v3 GitHub Release 和站点资源包;升级类型只有 `application`(主程序,前端版本由后端 Release 中的 `version.py` 决定)与 `resources`(认证资源和索引资源)。下载完成前不重启服务,安装接口只消费已下载并校验的完整制品,启动器会先应用主程序包,再应用资源包,之后才启动进程;启动后的初始化不会再次下载或触发资源重启。原 Dev 更新入口继续保留,但 `/system/upgrade` 只接受请求体 `"dev"`,不再处理 Release 更新。
|
||||
系统 Release 更新采用“检查、后台下载、确认安装”三阶段流程,以下接口均要求超级管理员登录态。后台每 6 小时按独立开关检查更新:`MOVIEPILOT_AUTO_UPDATE=true` 检查稳定版 v3 GitHub Release,`AUTO_UPDATE_RESOURCE=true` 检查站点资源包,并分别提示升级。任一开关开启即启用定时服务;两者均关闭时移除定时服务并隐藏版本提醒。手动检查、下载和安装仍可用。独立布尔配置 `MOVIEPILOT_UPDATE_DEV` 控制启动时跟踪 Dev 分支;升级类型只有 `application`(主程序,前端版本由后端 Release 中的 `version.py` 决定)与 `resources`(认证资源和索引资源)。下载完成前不重启服务,安装接口只消费已下载并校验的完整制品,启动器会先应用主程序包,再应用资源包,之后才启动进程;启动后的初始化不会再次下载或触发资源重启。原 Dev 更新入口继续保留,但 `/system/upgrade` 只接受请求体 `"dev"`,不再处理 Release 更新。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/system/update/status` | 查询聚合状态及 `updates` 中两类升级明细的 `idle`、`available`、`downloading`、`ready`、`installing` 或 `failed` 状态,以及版本、字节数和进度 |
|
||||
| GET | `/api/v1/system/update/status` | 查询聚合状态、实时提醒开关 `auto_update` / `auto_update_resource` 及 `updates` 中两类升级明细的 `idle`、`available`、`downloading`、`ready`、`installing` 或 `failed` 状态,以及版本、字节数和进度 |
|
||||
| POST | `/api/v1/system/update/check` | 立即检查最新稳定版 v3 Release 和当前平台站点资源包 |
|
||||
| POST | `/api/v1/system/update/download` | 请求体可传 `{"target":"application"}` 或 `{"target":"resources"}`;后台下载并校验对应制品 |
|
||||
| POST | `/api/v1/system/update/install` | 请求体可传 `{"target":"application"}` 或 `{"target":"resources"}`;再次校验对应制品,写入安装意图并重启 |
|
||||
@@ -278,7 +278,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
||||
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 |
|
||||
| POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 |
|
||||
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
|
||||
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
|
||||
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
|
||||
| GET | `/api/v1/transfer/tasks/manual-reviews` | 管理员分页查询 durable 人工复核任务;`state` 仅允许 `manual_review`(默认)或已经人工判定、等待调度恢复的 `retry_wait`,支持 `page` 与 `page_size`。响应只公开任务、源文件、状态、步骤意图/证据/错误和复核修订号,不返回 lease 或 attempt 身份 |
|
||||
| GET | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员查询单个 durable 人工复核任务详情;仅可读取 `manual_review` 或已经人工判定的 `retry_wait` 任务,其余状态按不存在处理 |
|
||||
| POST | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员判定处于 `manual_review` 的 durable 整理步骤;请求包含 `operation_id`、`decision=not_applied|applied`、`reason`,`applied` 还必须提供 `result_payload`。`failed` 不属于公开决策,失败终态只能由持租约的 durable 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
|
||||
|
||||
@@ -177,7 +177,7 @@ moviepilot update all --ref latest --frontend-version latest
|
||||
moviepilot update all --skip-resources
|
||||
```
|
||||
|
||||
`MOVIEPILOT_AUTO_UPDATE` defaults to `false`. Setting it to `true` enables the background Release check; setting it to `dev` retains branch-tracking updates during `start/restart`. The setting is hot-reloaded by the scheduler.
|
||||
`MOVIEPILOT_AUTO_UPDATE` is a boolean (default `false`): `true` enables the background Release check and version reminders, and `false` disables application checks and reminders. `AUTO_UPDATE_RESOURCE` independently enables resource checks and reminders; the scheduled service exists when either switch is enabled and checks only enabled targets. The scheduler hot-reloads this switch. `MOVIEPILOT_UPDATE_DEV` is an independent boolean (default `false`) that enables development-branch updates during `start/restart`. Legacy `dev`/`release` values of `MOVIEPILOT_AUTO_UPDATE` normalize to `true`; legacy `dev` also preserves Dev tracking when the new switch is not explicitly configured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -139,46 +139,46 @@ A field name ending in `*` is required. Put every action parameter in the `argum
|
||||
| `server.users.count` | Read provider user count.; no arguments |
|
||||
|
||||
### `activity.backdrops`
|
||||
Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia`.
|
||||
Read recent provider backdrop images. Effect: `safe_read`. Providers: `ugreen, trimemedia, mediavault`.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- `remote` (boolean; default `False`): Return provider URLs that are remotely accessible.
|
||||
|
||||
### `activity.latest`
|
||||
Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read recently added provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
|
||||
|
||||
### `activity.resume`
|
||||
Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read in-progress/resumable provider items. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- `username` (string): Read for this username; supported by Emby, Jellyfin, and ZSpace.
|
||||
|
||||
### `capabilities.list`
|
||||
List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
List supported media-server actions and their complete argument contracts. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `action_name` (string): Optional exact action name used to return one capability contract.
|
||||
|
||||
### `instances.list`
|
||||
List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
List configured media-server instances without connection secrets. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `items.count`
|
||||
Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Count items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
|
||||
- Rule: parent is required except for Navidrome, which defaults to music.
|
||||
|
||||
### `items.detail`
|
||||
Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read one provider item by native ID. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `item_id*` (string): Provider-native item ID returned by the selected media server.
|
||||
|
||||
### `items.list`
|
||||
Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Page items below one library or parent. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `parent` (string|integer): Library or parent item ID; Navidrome may omit it and use music.
|
||||
- `offset` (integer; default `0`): Zero-based list offset.
|
||||
- `limit` (integer; default `50`): Number of items to return, from 1 to 200.
|
||||
- Rule: parent is required except for Navidrome, which ignores it.
|
||||
|
||||
### `items.movies.search`
|
||||
Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`.
|
||||
Search provider-native movie items by title and optional year. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, mediavault`.
|
||||
- `title*` (string): Movie title.
|
||||
- `year` (string|integer): Optional release year.
|
||||
|
||||
@@ -190,7 +190,7 @@ Search provider-native music by title, artist, or album. Effect: `safe_read`. Pr
|
||||
- Rule: Provide at least one of title, artist, and album.
|
||||
|
||||
### `items.season_episodes`
|
||||
Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia`.
|
||||
Read native episode coverage for one series and optional season. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, mediavault`.
|
||||
- `item_id` (string): Provider-native item ID returned by the selected media server.
|
||||
- `title` (string): Series title; provide it or item_id.
|
||||
- `year` (string|integer): Optional premiere year.
|
||||
@@ -198,12 +198,12 @@ Read native episode coverage for one series and optional season. Effect: `safe_r
|
||||
- Rule: Provide at least one of item_id and title.
|
||||
|
||||
### `libraries.list`
|
||||
List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
List visible provider libraries. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `hidden` (boolean; default `False`): Return only libraries configured for synchronization.
|
||||
- `username` (string): Read libraries visible to this username; supported by Emby, Jellyfin, and ZSpace.
|
||||
|
||||
### `library.scan`
|
||||
Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Trigger a provider library scan. Effect: `external_side_effect`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `scan_mode` (string|integer): UGREEN-native scan mode; omit it for every other provider.
|
||||
|
||||
### `metadata.refresh`
|
||||
@@ -215,11 +215,11 @@ Read active playback sessions. Effect: `safe_read`. Providers: `emby, jellyfin,
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `playback.url`
|
||||
Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Build the provider play URL for one item. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `item_id*` (string): Provider-native item ID returned by the selected media server.
|
||||
|
||||
### `server.statistics`
|
||||
Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read media counts and provider statistics. Effect: `safe_read`. Providers: `emby, jellyfin, plex, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `server.user.library_folders`
|
||||
@@ -227,7 +227,7 @@ Read the current user's visible library folders. Effect: `safe_read`. Providers:
|
||||
- `arguments`: `{}`
|
||||
|
||||
### `server.users.count`
|
||||
Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome`.
|
||||
Read provider user count. Effect: `safe_read`. Providers: `emby, jellyfin, zspace, ugreen, trimemedia, navidrome, mediavault`.
|
||||
- `arguments`: `{}`
|
||||
|
||||
## Safety And Verification
|
||||
|
||||
@@ -25,6 +25,7 @@ ALL_PROVIDERS = (
|
||||
"ugreen",
|
||||
"trimemedia",
|
||||
"navidrome",
|
||||
"mediavault",
|
||||
)
|
||||
PROVIDER_CLASSES = {
|
||||
"emby": "app.modules.emby.emby:Emby",
|
||||
@@ -34,6 +35,7 @@ PROVIDER_CLASSES = {
|
||||
"ugreen": "app.modules.ugreen.ugreen:Ugreen",
|
||||
"trimemedia": "app.modules.trimemedia.trimemedia:TrimeMedia",
|
||||
"navidrome": "app.modules.navidrome.navidrome:Navidrome",
|
||||
"mediavault": "app.modules.mediavault.mediavault:MediaVault",
|
||||
}
|
||||
_UNSET = object()
|
||||
|
||||
@@ -122,7 +124,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"server.users.count": ActionSpec(
|
||||
"Read provider user count.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome"),
|
||||
("emby", "jellyfin", "zspace", "ugreen", "trimemedia", "navidrome", "mediavault"),
|
||||
),
|
||||
"server.user.library_folders": ActionSpec(
|
||||
"Read the current user's visible library folders.",
|
||||
@@ -157,7 +159,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"items.movies.search": ActionSpec(
|
||||
"Search provider-native movie items by title and optional year.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"),
|
||||
(
|
||||
ArgumentSpec("title", "string", "Movie title.", required=True),
|
||||
ArgumentSpec("year", "string|integer", "Optional release year."),
|
||||
@@ -177,7 +179,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"items.season_episodes": ActionSpec(
|
||||
"Read native episode coverage for one series and optional season.",
|
||||
"safe_read",
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia"),
|
||||
("emby", "jellyfin", "plex", "zspace", "ugreen", "trimemedia", "mediavault"),
|
||||
(
|
||||
ITEM_ID,
|
||||
ArgumentSpec("title", "string", "Series title; provide it or item_id."),
|
||||
@@ -199,7 +201,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
||||
"activity.backdrops": ActionSpec(
|
||||
"Read recent provider backdrop images.",
|
||||
"safe_read",
|
||||
("ugreen", "trimemedia"),
|
||||
("ugreen", "trimemedia", "mediavault"),
|
||||
(
|
||||
LIMIT,
|
||||
ArgumentSpec("remote", "boolean", "Return provider URLs that are remotely accessible.", default=False),
|
||||
|
||||
@@ -1460,6 +1460,7 @@ Purpose: Recommend an episode-number extraction template from supplied file samp
|
||||
### `transfer.file`
|
||||
`POST /api/v1/transfer/manual`; policy effect: `external_side_effect`.
|
||||
Purpose: Run MoviePilot's manual file-transfer and organization workflow.
|
||||
Failed durable history, including a `logid` request, retries the frozen plan through the scheduler unless an explicit media identity or `reorganize=true` requests replanning. Explicit replanning must first validate and discard the settled failed task; a pending manual review must be resolved before retrying.
|
||||
- `path_params`: none
|
||||
- `query`: `background` (boolean|null; default `False`): Run the transfer asynchronously and return before completion.
|
||||
- `body`: `episode_detail` (string|null): Episode mapping details used by manual transfer.; `episode_format` (string|null): Episode-number formatting rule used by manual transfer.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_offset` (string|null): Integer offset added to detected episode numbers.; `episode_part` (string|null): Episode part number used when one episode is split across files.; `fileitem` (FileItem-Input): One complete source storage item returned by storage.list.; `fileitems` (array<FileItem-Input>|null): Additional source storage items included in the same manual transfer.; `from_history` (boolean|null; default `False`): Treat the transfer input as originating from an existing history record.; `library_category_folder` (boolean|null): Create or use a category-level folder in the target library.; `library_type_folder` (boolean|null): Create or use a media-type folder in the target library.; `logid` (integer|null): One download-history or transfer-log identifier used by manual transfer.; `logids` (array<integer>|null): Multiple download-history or transfer-log identifiers included in manual transfer.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_filesize` (integer|null; default `0`): Minimum source file size accepted by manual transfer, in bytes.; `music_type` (string(recording,album)|null): Music identity level: recording, album, or artist where supported.; `preview` (boolean|null; default `False`): Validate and preview manual-transfer output without committing file changes.; `reorganize` (boolean|null; default `False`): Allow manual transfer to organize an item that was already processed.; `scrape` (boolean|null; default `False`): Generate metadata and images after manual transfer.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `target_path` (string|null): Destination path used by manual transfer.; `target_storage` (string|null): Configured storage name receiving the manual transfer.; `transfer_type` (string|null): Manual-transfer mode, such as move, copy, link, or softlink.; `type_name` (string|null): Explicit media type name used when source IDs alone are ambiguous.
|
||||
|
||||
+1
-1
@@ -590,7 +590,7 @@
|
||||
},
|
||||
"app/runtime/state.py:threading.Timer": {
|
||||
"owners": {
|
||||
"SystemHelper._schedule_supervisor_restart": 1
|
||||
"SystemHelper._schedule_supervisor_command": 1
|
||||
},
|
||||
"target": "threading.Timer"
|
||||
},
|
||||
|
||||
+46
-3
@@ -1074,8 +1074,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8263,
|
||||
"edge_sha256": "461585d4f5b0334163e10192294a20261e68f47f39094326f7c4590c4f1451cc",
|
||||
"edge_count": 8302,
|
||||
"edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1270,6 +1270,8 @@
|
||||
"app.adapters.system.update -> app.foundation.singleton",
|
||||
"app.adapters.system.update -> app.foundation.version",
|
||||
"app.adapters.system.update -> app.runtime",
|
||||
"app.adapters.system.update -> app.runtime.dependencies",
|
||||
"app.adapters.system.update -> app.runtime.dependencies.profile",
|
||||
"app.adapters.system.update -> app.runtime.log",
|
||||
"app.adapters.system.update -> app.runtime.settings",
|
||||
"app.adapters.system.update -> app.runtime.thread",
|
||||
@@ -5289,10 +5291,15 @@
|
||||
"app.chain.workflow -> app.schemas",
|
||||
"app.chain.workflow -> app.schemas.types",
|
||||
"app.chain.workflow -> app.schemas.workflow",
|
||||
"app.cli -> app.adapters",
|
||||
"app.cli -> app.adapters.system",
|
||||
"app.cli -> app.adapters.system.update",
|
||||
"app.cli -> app.application",
|
||||
"app.cli -> app.application.backup",
|
||||
"app.cli -> app.doctor",
|
||||
"app.cli -> app.doctor.formatters",
|
||||
"app.cli -> app.foundation",
|
||||
"app.cli -> app.foundation.environment",
|
||||
"app.cli -> app.runtime",
|
||||
"app.cli -> app.runtime.config",
|
||||
"app.cli -> app.runtime.settings",
|
||||
@@ -6891,6 +6898,38 @@
|
||||
"app.modules.lrclib -> app.runtime.settings",
|
||||
"app.modules.lrclib -> app.schemas",
|
||||
"app.modules.lrclib -> app.schemas.types",
|
||||
"app.modules.mediavault.api -> app.adapters",
|
||||
"app.modules.mediavault.api -> app.adapters.network",
|
||||
"app.modules.mediavault.api -> app.adapters.network.http",
|
||||
"app.modules.mediavault.api -> app.foundation",
|
||||
"app.modules.mediavault.api -> app.foundation.url",
|
||||
"app.modules.mediavault.api -> app.runtime",
|
||||
"app.modules.mediavault.api -> app.runtime.log",
|
||||
"app.modules.mediavault.api -> app.runtime.settings",
|
||||
"app.modules.mediavault.mediavault -> app.application",
|
||||
"app.modules.mediavault.mediavault -> app.application.mediaserver",
|
||||
"app.modules.mediavault.mediavault -> app.foundation",
|
||||
"app.modules.mediavault.mediavault -> app.foundation.url",
|
||||
"app.modules.mediavault.mediavault -> app.modules",
|
||||
"app.modules.mediavault.mediavault -> app.modules.mediavault",
|
||||
"app.modules.mediavault.mediavault -> app.modules.mediavault.api",
|
||||
"app.modules.mediavault.mediavault -> app.runtime",
|
||||
"app.modules.mediavault.mediavault -> app.runtime.log",
|
||||
"app.modules.mediavault.mediavault -> app.schemas",
|
||||
"app.modules.mediavault.mediavault -> app.schemas.dashboard",
|
||||
"app.modules.mediavault.mediavault -> app.schemas.mediaserver",
|
||||
"app.modules.mediavault.mediavault -> app.schemas.types",
|
||||
"app.modules.mediavault.module -> app.modules",
|
||||
"app.modules.mediavault.module -> app.modules._base",
|
||||
"app.modules.mediavault.module -> app.modules._base.mediaserver",
|
||||
"app.modules.mediavault.module -> app.modules.mediavault",
|
||||
"app.modules.mediavault.module -> app.modules.mediavault.mediavault",
|
||||
"app.modules.mediavault.module -> app.runtime",
|
||||
"app.modules.mediavault.module -> app.runtime.log",
|
||||
"app.modules.mediavault.module -> app.schemas",
|
||||
"app.modules.mediavault.module -> app.schemas.dashboard",
|
||||
"app.modules.mediavault.module -> app.schemas.mediaserver",
|
||||
"app.modules.mediavault.module -> app.schemas.types",
|
||||
"app.modules.musicbrainz -> app.adapters",
|
||||
"app.modules.musicbrainz -> app.adapters.network",
|
||||
"app.modules.musicbrainz -> app.adapters.network.http",
|
||||
@@ -9341,7 +9380,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 976,
|
||||
"module_count": 980,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -10002,6 +10041,10 @@
|
||||
"app.modules.jellyfin.jellyfin",
|
||||
"app.modules.listenbrainz",
|
||||
"app.modules.lrclib",
|
||||
"app.modules.mediavault",
|
||||
"app.modules.mediavault.api",
|
||||
"app.modules.mediavault.mediavault",
|
||||
"app.modules.mediavault.module",
|
||||
"app.modules.musicbrainz",
|
||||
"app.modules.musicbrainz.cache",
|
||||
"app.modules.musixmatch",
|
||||
|
||||
+21
-60
@@ -1395,39 +1395,34 @@
|
||||
"empty-body": 4,
|
||||
"misc": 3,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-def": 7,
|
||||
"union-attr": 4
|
||||
},
|
||||
"app/modules/_base/downloader.py": {
|
||||
"assignment": 1,
|
||||
"attr-defined": 5,
|
||||
"no-untyped-call": 1
|
||||
"attr-defined": 5
|
||||
},
|
||||
"app/modules/_base/mediaserver.py": {
|
||||
"arg-type": 2,
|
||||
"assignment": 3,
|
||||
"attr-defined": 5,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"var-annotated": 1
|
||||
},
|
||||
"app/modules/_base/notification.py": {
|
||||
"arg-type": 1,
|
||||
"assignment": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 5
|
||||
},
|
||||
"app/modules/acoustid/__init__.py": {
|
||||
"arg-type": 1,
|
||||
"list-item": 1,
|
||||
"no-untyped-call": 1
|
||||
"list-item": 1
|
||||
},
|
||||
"app/modules/anilist/__init__.py": {
|
||||
"assignment": 4,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 5,
|
||||
"return-value": 1,
|
||||
"type-arg": 12
|
||||
@@ -1442,7 +1437,6 @@
|
||||
"app/modules/bangumi/__init__.py": {
|
||||
"arg-type": 2,
|
||||
"assignment": 4,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 5,
|
||||
"return-value": 1,
|
||||
"type-arg": 7
|
||||
@@ -1452,9 +1446,7 @@
|
||||
},
|
||||
"app/modules/dingtalk/__init__.py": {
|
||||
"arg-type": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"override": 1
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/modules/dingtalk/dingtalk.py": {
|
||||
"no-untyped-def": 1
|
||||
@@ -1466,7 +1458,6 @@
|
||||
"empty-body": 1,
|
||||
"misc": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"truthy-function": 1,
|
||||
"type-arg": 8
|
||||
@@ -1487,7 +1478,7 @@
|
||||
"empty-body": 1,
|
||||
"misc": 2,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-def": 7,
|
||||
"return-value": 2,
|
||||
"type-arg": 13,
|
||||
@@ -1512,7 +1503,6 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 13,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -1530,7 +1520,6 @@
|
||||
"app/modules/fanart/__init__.py": {
|
||||
"misc": 2,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 10,
|
||||
"var-annotated": 1
|
||||
@@ -1539,7 +1528,6 @@
|
||||
"arg-type": 4,
|
||||
"assignment": 6,
|
||||
"attr-defined": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"return-value": 1,
|
||||
"type-arg": 5,
|
||||
@@ -1561,7 +1549,7 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-def": 5,
|
||||
"type-arg": 3,
|
||||
"var-annotated": 3
|
||||
@@ -1668,7 +1656,7 @@
|
||||
"assignment": 4,
|
||||
"empty-body": 1,
|
||||
"index": 1,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"operator": 2,
|
||||
"return": 1,
|
||||
@@ -1678,9 +1666,7 @@
|
||||
"app/modules/imdb/__init__.py": {
|
||||
"assignment": 10,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"override": 1,
|
||||
"type-arg": 5
|
||||
},
|
||||
"app/modules/imdb/api.py": {
|
||||
@@ -1694,7 +1680,7 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-def": 1,
|
||||
"return-value": 5,
|
||||
"type-arg": 19,
|
||||
@@ -1913,7 +1899,6 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 13,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -1932,22 +1917,17 @@
|
||||
},
|
||||
"app/modules/listenbrainz/__init__.py": {
|
||||
"misc": 2,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 1,
|
||||
"override": 1
|
||||
"no-any-return": 2
|
||||
},
|
||||
"app/modules/lrclib/__init__.py": {
|
||||
"misc": 1,
|
||||
"no-untyped-call": 1,
|
||||
"override": 1
|
||||
"misc": 1
|
||||
},
|
||||
"app/modules/musicbrainz/__init__.py": {
|
||||
"arg-type": 1,
|
||||
"assignment": 5,
|
||||
"misc": 4,
|
||||
"no-untyped-call": 4,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-def": 2,
|
||||
"override": 1,
|
||||
"type-arg": 2
|
||||
},
|
||||
"app/modules/musicbrainz/cache.py": {
|
||||
@@ -1958,13 +1938,10 @@
|
||||
"union-attr": 1
|
||||
},
|
||||
"app/modules/musixmatch/__init__.py": {
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1
|
||||
"no-any-return": 1
|
||||
},
|
||||
"app/modules/navidrome/__init__.py": {
|
||||
"arg-type": 9,
|
||||
"no-untyped-call": 1,
|
||||
"override": 1,
|
||||
"type-arg": 1,
|
||||
"union-attr": 1
|
||||
},
|
||||
@@ -1976,7 +1953,7 @@
|
||||
"arg-type": 7,
|
||||
"assignment": 16,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 5,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -1994,7 +1971,6 @@
|
||||
},
|
||||
"app/modules/postgresql/__init__.py": {
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/modules/qbittorrent/__init__.py": {
|
||||
@@ -2002,7 +1978,6 @@
|
||||
"assignment": 16,
|
||||
"empty-body": 1,
|
||||
"no-redef": 2,
|
||||
"no-untyped-call": 1,
|
||||
"operator": 1,
|
||||
"str-bytes-safe": 2,
|
||||
"type-arg": 8,
|
||||
@@ -2029,7 +2004,6 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 4,
|
||||
"var-annotated": 1
|
||||
@@ -2042,7 +2016,7 @@
|
||||
},
|
||||
"app/modules/redis/__init__.py": {
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1
|
||||
},
|
||||
"app/modules/rtorrent/__init__.py": {
|
||||
@@ -2050,7 +2024,6 @@
|
||||
"assignment": 16,
|
||||
"empty-body": 1,
|
||||
"no-redef": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"operator": 1,
|
||||
"str-bytes-safe": 2,
|
||||
@@ -2072,7 +2045,6 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"index": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 7
|
||||
},
|
||||
@@ -2089,7 +2061,6 @@
|
||||
"app/modules/subtitle/__init__.py": {
|
||||
"arg-type": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"var-annotated": 1
|
||||
},
|
||||
@@ -2099,7 +2070,6 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 4,
|
||||
"var-annotated": 1
|
||||
@@ -2115,7 +2085,6 @@
|
||||
"assignment": 9,
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 1,
|
||||
"type-arg": 11,
|
||||
"union-attr": 1
|
||||
@@ -2135,9 +2104,7 @@
|
||||
"app/modules/theaudiodb/__init__.py": {
|
||||
"assignment": 4,
|
||||
"misc": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"override": 1
|
||||
"no-untyped-def": 2
|
||||
},
|
||||
"app/modules/themoviedb/__init__.py": {
|
||||
"arg-type": 5,
|
||||
@@ -2145,7 +2112,7 @@
|
||||
"empty-body": 1,
|
||||
"index": 2,
|
||||
"no-any-return": 4,
|
||||
"no-untyped-call": 6,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-def": 3,
|
||||
"return-value": 2,
|
||||
"type-arg": 19,
|
||||
@@ -2291,7 +2258,7 @@
|
||||
"app/modules/thetvdb/__init__.py": {
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 4,
|
||||
"type-arg": 2
|
||||
},
|
||||
@@ -2311,7 +2278,7 @@
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-redef": 2,
|
||||
"no-untyped-call": 10,
|
||||
"no-untyped-call": 9,
|
||||
"no-untyped-def": 7,
|
||||
"str-bytes-safe": 1,
|
||||
"type-arg": 6
|
||||
@@ -2333,7 +2300,7 @@
|
||||
"assignment": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 7,
|
||||
"return": 1,
|
||||
"type-arg": 2
|
||||
@@ -2363,7 +2330,6 @@
|
||||
"assignment": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 3,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 7,
|
||||
"type-arg": 2
|
||||
},
|
||||
@@ -2383,7 +2349,6 @@
|
||||
"assignment": 6,
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 4,
|
||||
"union-attr": 1
|
||||
@@ -2398,7 +2363,6 @@
|
||||
"arg-type": 1,
|
||||
"assignment": 1,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 1,
|
||||
"union-attr": 1
|
||||
@@ -2409,7 +2373,7 @@
|
||||
"attr-defined": 1,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 3,
|
||||
"no-untyped-call": 2,
|
||||
"no-untyped-def": 5,
|
||||
"type-arg": 4,
|
||||
"union-attr": 4
|
||||
@@ -2435,7 +2399,6 @@
|
||||
"assignment": 3,
|
||||
"empty-body": 1,
|
||||
"no-any-return": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 4,
|
||||
"type-arg": 2,
|
||||
"union-attr": 1
|
||||
@@ -2453,7 +2416,6 @@
|
||||
"arg-type": 3,
|
||||
"assignment": 12,
|
||||
"empty-body": 1,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 2,
|
||||
"type-arg": 1
|
||||
},
|
||||
@@ -2653,7 +2615,7 @@
|
||||
"app/runtime/reload.py": {
|
||||
"misc": 1,
|
||||
"no-untyped-call": 5,
|
||||
"no-untyped-def": 6
|
||||
"no-untyped-def": 5
|
||||
},
|
||||
"app/runtime/scheduling.py": {
|
||||
"import-untyped": 1,
|
||||
@@ -2668,7 +2630,6 @@
|
||||
"app/runtime/state.py": {
|
||||
"attr-defined": 2,
|
||||
"no-any-return": 2,
|
||||
"no-untyped-call": 1,
|
||||
"no-untyped-def": 3,
|
||||
"type-arg": 1
|
||||
},
|
||||
|
||||
@@ -504,9 +504,6 @@
|
||||
"app/runtime/reload.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/runtime/state.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/schemas/common.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -672,9 +669,6 @@
|
||||
"tests/test_db_session_lifecycle.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_docker_entrypoint_permissions.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_docker_payload_contract.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -928,9 +922,6 @@
|
||||
"E402": 5,
|
||||
"I001": 2
|
||||
},
|
||||
"tests/test_system_utils.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_systemconfig_oper.py": {
|
||||
"I001": 1
|
||||
},
|
||||
|
||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
|
||||
|
||||
|
||||
@@ -20,6 +22,7 @@ class _DummySystemHelper:
|
||||
|
||||
|
||||
def load_cli_module():
|
||||
"""隔离加载 CLI,使用真实布尔配置形状验证启动更新决策。"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
settings = SimpleNamespace(
|
||||
@@ -35,7 +38,8 @@ def load_cli_module():
|
||||
PROXY_HOST="",
|
||||
PIP_PROXY="",
|
||||
GITHUB_TOKEN="",
|
||||
MOVIEPILOT_AUTO_UPDATE="false",
|
||||
MOVIEPILOT_AUTO_UPDATE=False,
|
||||
MOVIEPILOT_UPDATE_DEV=False,
|
||||
PROXY={},
|
||||
REPO_GITHUB_HEADERS=lambda _repo: {},
|
||||
)
|
||||
@@ -95,8 +99,9 @@ def test_resolve_auto_update_targets_keeps_dev_branch_tracking():
|
||||
|
||||
|
||||
def test_one_shot_dev_update_overrides_disabled_default():
|
||||
"""一次性手动更新不受两个自动开关关闭的影响。"""
|
||||
module = load_cli_module()
|
||||
module.settings.MOVIEPILOT_AUTO_UPDATE = "false"
|
||||
module.settings.MOVIEPILOT_AUTO_UPDATE = False
|
||||
|
||||
with patch.object(
|
||||
module.SystemHelper, "consume_one_shot_dev_update", return_value=True
|
||||
@@ -104,6 +109,16 @@ def test_one_shot_dev_update_overrides_disabled_default():
|
||||
assert module._auto_update_mode() == "dev"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auto_update", [True, False])
|
||||
@pytest.mark.parametrize("update_dev", [True, False])
|
||||
def test_dev_tracking_is_independent_of_automatic_checks(auto_update, update_dev):
|
||||
"""检查开关不触发启动更新,Dev 开关单独选择开发分支。"""
|
||||
module = load_cli_module()
|
||||
module.settings.MOVIEPILOT_AUTO_UPDATE = auto_update
|
||||
module.settings.MOVIEPILOT_UPDATE_DEV = update_dev
|
||||
assert module._auto_update_mode() == ("dev" if update_dev else "false")
|
||||
|
||||
|
||||
def test_release_mode_does_not_update_during_start():
|
||||
module = load_cli_module()
|
||||
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.config import Settings, settings
|
||||
|
||||
|
||||
def test_update_float_setting_accepts_json_integer(monkeypatch) -> None:
|
||||
@@ -38,3 +38,34 @@ def test_update_float_setting_accepts_json_integer(monkeypatch) -> None:
|
||||
"original_value": 1,
|
||||
"converted_value": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def test_short_api_token_update_does_not_log_token(monkeypatch) -> None:
|
||||
"""短 API_TOKEN 自动替换时日志不得包含令牌原文。"""
|
||||
config = Settings(API_TOKEN="0123456789abcdef")
|
||||
messages: list[str] = []
|
||||
monkeypatch.setattr(Settings, "update_env_config", lambda *_args: (True, ""))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.config.logger.warning",
|
||||
messages.append,
|
||||
)
|
||||
|
||||
success, message = config.update_setting("API_TOKEN", "short-token")
|
||||
|
||||
assert success is True
|
||||
assert message == ""
|
||||
assert config.API_TOKEN != "short-token"
|
||||
assert messages
|
||||
assert "short-token" not in messages[0]
|
||||
|
||||
|
||||
def test_rust_accel_update_uses_field_policy(monkeypatch) -> None:
|
||||
"""free-threaded 运行时的 Rust 加速约束由字段策略执行。"""
|
||||
config = Settings(RUST_ACCEL=True)
|
||||
monkeypatch.setattr("app.runtime.config.is_free_threaded_runtime", lambda: True)
|
||||
|
||||
success, message = config.update_setting("RUST_ACCEL", False)
|
||||
|
||||
assert success is False
|
||||
assert message == "free-threaded 运行时必须启用 Rust 加速"
|
||||
assert config.RUST_ACCEL is True
|
||||
|
||||
@@ -6,6 +6,8 @@ from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.configuration import SchedulerRuntimeConfig
|
||||
from app.scheduler import catalog as scheduler_catalog
|
||||
from app.scheduler import maintenance as scheduler_maintenance
|
||||
@@ -58,6 +60,7 @@ def _config(**changes) -> SchedulerRuntimeConfig:
|
||||
usage_statistic_share=False,
|
||||
site_link=None,
|
||||
auto_update=False,
|
||||
auto_update_resource=False,
|
||||
)
|
||||
return replace(config, **changes)
|
||||
|
||||
@@ -74,8 +77,9 @@ def test_database_backup_schedule_only_watches_job_shape() -> None:
|
||||
|
||||
|
||||
def test_auto_update_setting_is_hot_reloadable() -> None:
|
||||
"""自动更新开关变更时应触发 Scheduler 重建。"""
|
||||
"""主程序或资源开关变更时均应触发 Scheduler 重建。"""
|
||||
assert "MOVIEPILOT_AUTO_UPDATE" in Scheduler.CONFIG_WATCH
|
||||
assert "AUTO_UPDATE_RESOURCE" in Scheduler.CONFIG_WATCH
|
||||
|
||||
|
||||
def test_disabled_database_backup_does_not_register_job() -> None:
|
||||
@@ -108,8 +112,12 @@ def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -
|
||||
assert scheduler._scheduler.jobs["database_backup"]["replace_existing"] is True
|
||||
|
||||
|
||||
def test_auto_update_check_is_registered_only_when_enabled(monkeypatch) -> None:
|
||||
"""只有显式开启自动更新时才注册 Release 检查任务。"""
|
||||
@pytest.mark.parametrize("auto_update", [False, True])
|
||||
@pytest.mark.parametrize("auto_update_resource", [False, True])
|
||||
def test_auto_update_check_is_registered_only_when_enabled(
|
||||
monkeypatch, auto_update, auto_update_resource
|
||||
) -> None:
|
||||
"""任一开关开启即注册检查任务,均关闭则不注册。"""
|
||||
scheduler = _scheduler()
|
||||
scheduler._services = Mock()
|
||||
background_scheduler = Mock()
|
||||
@@ -120,20 +128,14 @@ def test_auto_update_check_is_registered_only_when_enabled(monkeypatch) -> None:
|
||||
monkeypatch.setattr(scheduler, "init_agent_task_jobs", lambda: None)
|
||||
monkeypatch.setattr(scheduler, "init_plugin_jobs", lambda: None)
|
||||
|
||||
scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(scheduler, _config(auto_update=False))
|
||||
assert not any(
|
||||
call.kwargs.get("id") == "system_update_check"
|
||||
for call in background_scheduler.add_job.call_args_list
|
||||
scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(
|
||||
scheduler, _config(auto_update=auto_update, auto_update_resource=auto_update_resource)
|
||||
)
|
||||
assert "system_update_check" not in scheduler._jobs
|
||||
|
||||
background_scheduler.add_job.reset_mock()
|
||||
scheduler_catalog.SchedulerCatalogOwner._initialize_catalog(scheduler, _config(auto_update=True))
|
||||
assert any(
|
||||
call.kwargs.get("id") == "system_update_check"
|
||||
for call in background_scheduler.add_job.call_args_list
|
||||
)
|
||||
assert "system_update_check" in scheduler._jobs
|
||||
) is (auto_update or auto_update_resource)
|
||||
assert ("system_update_check" in scheduler._jobs) is (auto_update or auto_update_resource)
|
||||
|
||||
|
||||
def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None:
|
||||
|
||||
+35
-127
@@ -1,5 +1,3 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
@@ -796,17 +794,27 @@ def test_updater_package_proxy_stays_command_scoped(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "install_result", "expected"),
|
||||
(("false", "unused", "noop"), ("dev", "success", "updated"), ("dev", "failure", "failed")),
|
||||
("mode", "dev_update", "install_result", "expected"),
|
||||
(
|
||||
("false", "false", "unused", "noop"),
|
||||
("true", "false", "unused", "noop"),
|
||||
("false", "true", "success", "updated"),
|
||||
("true", "True", "failure", "failed"),
|
||||
("dev", "", "success", "updated"),
|
||||
("dev", "false", "unused", "noop"),
|
||||
("release", "", "unused", "noop"),
|
||||
),
|
||||
)
|
||||
def test_updater_exposes_explicit_result(
|
||||
tmp_path: Path, mode: str, install_result: str, expected: str
|
||||
tmp_path: Path, mode: str, dev_update: str, install_result: str, expected: str
|
||||
) -> None:
|
||||
"""Docker 由独立 Dev 开关决定启动更新,并兼容首次迁移的旧模式。"""
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
MOVIEPILOT_AUTO_UPDATE="$2"
|
||||
INSTALL_RESULT="$3"
|
||||
MOVIEPILOT_UPDATE_DEV="$4"
|
||||
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
|
||||
source {UPDATER!s}
|
||||
INFO() {{ :; }}
|
||||
@@ -827,7 +835,7 @@ def test_updater_exposes_explicit_result(
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "updater-test", str(tmp_path / "config"), mode, install_result],
|
||||
["bash", "-c", script, "updater-test", str(tmp_path / "config"), mode, install_result, dev_update],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
@@ -836,130 +844,14 @@ def test_updater_exposes_explicit_result(
|
||||
assert result.stdout == f"{expected}\n"
|
||||
|
||||
|
||||
def test_prepared_release_is_verified_and_installed_without_release_lookup(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
update_root = config_dir / "temp" / "moviepilot-update"
|
||||
update_root.mkdir(parents=True)
|
||||
backend = update_root / "backend.zip"
|
||||
frontend = update_root / "frontend.zip"
|
||||
backend.write_bytes(b"backend-package")
|
||||
frontend.write_bytes(b"frontend-package")
|
||||
backend_sha256 = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
frontend_sha256 = hashlib.sha256(frontend.read_bytes()).hexdigest()
|
||||
(update_root / "install.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": "v3.1.0",
|
||||
"frontend_version": "v3.1.0",
|
||||
"backend_archive": str(backend),
|
||||
"frontend_archive": str(frontend),
|
||||
"backend_sha256": backend_sha256,
|
||||
"frontend_sha256": frontend_sha256,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
release_probe = tmp_path / "release-probe"
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
|
||||
RELEASE_PROBE="$2"
|
||||
source {UPDATER!s}
|
||||
INFO() {{ :; }}
|
||||
WARN() {{ :; }}
|
||||
ERROR() {{ :; }}
|
||||
test_connectivity_github() {{ touch "${{RELEASE_PROBE}}"; return 1; }}
|
||||
install_backend_and_download_resources() {{
|
||||
test "${{MOVIEPILOT_PREPARED_UPDATE}}" = true
|
||||
test "$1" = tags/v3.1.0.zip
|
||||
MOVIEPILOT_UPDATE_RESULT=updated
|
||||
}}
|
||||
run_moviepilot_update
|
||||
printf '%s\n' "${{MOVIEPILOT_UPDATE_RESULT}}"
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "prepared-update-test", str(config_dir), str(release_probe)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert result.stdout == "updated\n"
|
||||
assert not release_probe.exists()
|
||||
assert not (update_root / "install.json").exists()
|
||||
|
||||
|
||||
def test_prepared_resource_update_is_applied_without_backend_or_release_lookup(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
update_root = config_dir / "temp" / "moviepilot-update"
|
||||
resource_dir = update_root / "resources"
|
||||
resource_dir.mkdir(parents=True)
|
||||
resource_files = []
|
||||
for name, content in (("user.sites.v3.bin", b"index"), ("sites.cpython-test.so", b"auth")):
|
||||
path = resource_dir / name
|
||||
path.write_bytes(content)
|
||||
resource_files.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
)
|
||||
(update_root / "install.json").write_text(
|
||||
json.dumps({"targets": ["resources"], "resource_files": resource_files}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
release_probe = tmp_path / "release-probe"
|
||||
backend_probe = tmp_path / "backend-probe"
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
|
||||
RELEASE_PROBE="$2"
|
||||
BACKEND_PROBE="$3"
|
||||
source {UPDATER!s}
|
||||
INFO() {{ :; }}
|
||||
WARN() {{ :; }}
|
||||
ERROR() {{ :; }}
|
||||
test_connectivity_github() {{ touch "${{RELEASE_PROBE}}"; return 1; }}
|
||||
install_backend_and_download_resources() {{ touch "${{BACKEND_PROBE}}"; return 1; }}
|
||||
apply_prepared_resources() {{ test "${{MOVIEPILOT_PREPARED_UPDATE}}" = true; return 0; }}
|
||||
run_moviepilot_update
|
||||
printf '%s\\n' "${{MOVIEPILOT_UPDATE_RESULT}}"
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
script,
|
||||
"prepared-resource-update-test",
|
||||
str(config_dir),
|
||||
str(release_probe),
|
||||
str(backend_probe),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert result.stdout == "noop\n"
|
||||
assert not release_probe.exists()
|
||||
assert not backend_probe.exists()
|
||||
assert not (update_root / "install.json").exists()
|
||||
|
||||
|
||||
def test_release_mode_no_longer_checks_or_installs_during_restart(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Release 模式只能消费准备清单,不得保留启动时查版本的旧实现。"""
|
||||
"""非 Dev 模式不再由 update.sh 查版本或替换已下载程序。"""
|
||||
updater = UPDATER.read_text(encoding="utf-8")
|
||||
assert "install.json" not in updater
|
||||
assert "MOVIEPILOT_PREPARED_UPDATE" not in updater
|
||||
assert "apply_prepared" not in updater
|
||||
for retired_function in (
|
||||
"fetch_latest_v3_release",
|
||||
"compare_versions",
|
||||
@@ -1027,13 +919,29 @@ def test_entrypoint_delegates_restart_to_external_supervisor() -> None:
|
||||
assert "docker_http_proxy" not in entrypoint
|
||||
assert "/var/run/docker.sock" not in entrypoint
|
||||
assert "docker_http_proxy" not in dockerfile
|
||||
assert "exec /usr/bin/supervisord -n" in entrypoint
|
||||
assert "/usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf" in entrypoint
|
||||
assert "run_pending_dev_update_after_supervisor_shutdown" in entrypoint
|
||||
assert "apply_pending_release_update_at_startup" in entrypoint
|
||||
assert "-m app.cli apply-prepared-update" in entrypoint
|
||||
assert "supervisor_exit_code=$?" in entrypoint
|
||||
assert "supervisor" in dockerfile
|
||||
assert "[program:moviepilot-nginx]" in supervisor
|
||||
assert "[program:moviepilot-backend]" in supervisor
|
||||
assert "[program:moviepilot-update-worker]" in supervisor
|
||||
assert "user=root" in supervisor
|
||||
assert "-name '*.sh' ! -name 'launcher.sh'" in dockerfile
|
||||
assert supervisor.count("autorestart=true") == 2
|
||||
|
||||
|
||||
def test_release_update_worker_applies_before_supervisor_shutdown() -> None:
|
||||
"""Release worker 必须先调用后端安装器,再关闭 supervisor 触发入口重载。"""
|
||||
worker = (ROOT / "docker" / "update-worker.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "-m app.cli apply-prepared-update" in worker
|
||||
assert "supervisorctl -c \"${SUPERVISOR_CONFIG}\" shutdown" in worker
|
||||
assert worker.index("apply-prepared-update") < worker.index("shutdown")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"),
|
||||
(
|
||||
|
||||
@@ -3,7 +3,6 @@ import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@@ -588,7 +587,13 @@ def test_supervisor_manages_backend_and_nginx() -> None:
|
||||
assert "supervisord -n" in entrypoint
|
||||
assert "[program:moviepilot-nginx]" in supervisor
|
||||
assert "[program:moviepilot-backend]" in supervisor
|
||||
assert "[program:moviepilot-update-worker]" in supervisor
|
||||
assert "command=/bin/bash /usr/local/lib/moviepilot/control/update-worker.sh" in supervisor
|
||||
assert "user=root" in supervisor
|
||||
assert "file=/run/moviepilot/supervisor.sock" in supervisor
|
||||
assert "chmod=0770" in supervisor
|
||||
assert "chown=root:moviepilot" in supervisor
|
||||
assert "username=moviepilot" in supervisor
|
||||
assert supervisor.count("password=%(ENV_MOVIEPILOT_SUPERVISOR_PASSWORD)s") == 2
|
||||
assert "openssl rand -hex 32" in entrypoint
|
||||
assert supervisor.count("autorestart=true") == 2
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints.transfer import (
|
||||
manual_transfer as manual_transfer_endpoint,
|
||||
)
|
||||
@@ -210,6 +212,86 @@ def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch):
|
||||
assert captured["cleanup_dest_fileitem"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accepted", [True, False])
|
||||
@pytest.mark.parametrize("background", [True, False])
|
||||
def test_failed_history_manual_auto_uses_durable_retry(monkeypatch, accepted, background):
|
||||
"""历史入口选择自动识别时仍须检查失败任务,不能绕过调度器重新准入。"""
|
||||
chain = make_transfer_chain()
|
||||
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
|
||||
history = SimpleNamespace(
|
||||
id=14,
|
||||
transfer_task_id="transfer-task-14",
|
||||
status=False,
|
||||
mode="copy",
|
||||
src_fileitem=fileitem.model_dump(),
|
||||
dest_fileitem=None,
|
||||
download_hash=None,
|
||||
downloader=None,
|
||||
)
|
||||
planned, deleted, retries = [], [], []
|
||||
_patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted)
|
||||
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", lambda: chain)
|
||||
|
||||
def request_retry(record, *, requested_by):
|
||||
"""记录旧任务重试,模拟调度器接受或拒绝请求。"""
|
||||
retries.append((record.transfer_task_id, requested_by))
|
||||
return accepted, "已提交重试" if accepted else "任务需要人工处理"
|
||||
|
||||
def reject_new_admission(task):
|
||||
"""模拟旧任务仍占用源路径时,新规划输入必然冲突。"""
|
||||
raise AssertionError(f"旧任务重试不应重新准入:{task.fileitem.path}")
|
||||
|
||||
monkeypatch.setattr(chain, "_request_durable_transfer_retry", request_retry)
|
||||
monkeypatch.setattr(chain, "put_to_queue", reject_new_admission)
|
||||
|
||||
response = manual_transfer_endpoint(
|
||||
transer_item=ManualTransferItem(logid=history.id, from_history=False),
|
||||
background=background,
|
||||
history_query=SimpleNamespace(get=lambda _history_id: history),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert response.success is accepted
|
||||
assert retries == [(history.transfer_task_id, "manual_reorganize")]
|
||||
assert planned == []
|
||||
assert deleted == []
|
||||
if not accepted:
|
||||
assert "任务需要人工处理" in response.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [True, False])
|
||||
def test_manual_history_auto_preserves_legacy_retry_and_success_force(monkeypatch, status):
|
||||
"""旧版失败历史仍清理后重试,成功历史保留原有强制整理行为。"""
|
||||
chain = make_transfer_chain()
|
||||
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
|
||||
history = SimpleNamespace(
|
||||
id=15,
|
||||
status=status,
|
||||
mode="copy",
|
||||
src=fileitem.path,
|
||||
src_storage=fileitem.storage,
|
||||
src_fileitem=fileitem.model_dump(),
|
||||
dest_fileitem=None,
|
||||
download_hash=None,
|
||||
downloader=None,
|
||||
)
|
||||
planned, deleted = [], []
|
||||
_patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted)
|
||||
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", lambda: chain)
|
||||
chain.transfer_execution_repository = None
|
||||
|
||||
response = manual_transfer_endpoint(
|
||||
transer_item=ManualTransferItem(logid=history.id, from_history=False),
|
||||
background=False,
|
||||
history_query=SimpleNamespace(get=lambda _history_id: history),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert planned == [fileitem.path]
|
||||
assert deleted == ([] if status else [("history", history.id)])
|
||||
|
||||
|
||||
def test_success_history_directory_query_excludes_failed_and_siblings():
|
||||
"""目录历史查询应限定路径边界,并且只返回成功记录。"""
|
||||
transfer_history_oper = _history_repository()
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"""MediaVault 自建媒体库客户端的行为契约,全部走假 API 不发真实请求。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.mediavault.api import Result
|
||||
from app.modules.mediavault.mediavault import MediaVault
|
||||
from app.schemas.mediaserver import RefreshMediaItem
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
class _FakeApi:
|
||||
"""按路径返回预置数据,并记录调用参数。"""
|
||||
|
||||
def __init__(self, routes: dict, host: str = "http://mv.local"):
|
||||
self.routes = routes
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
self._host = host
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str:
|
||||
return f"{host or self._host}/api/v1/media-library/items/{item_id}/image/{image_type}?api_key=k"
|
||||
|
||||
def request(self, api, method=None, params=None, data=None, base_path=None, suppress_log=False):
|
||||
self.calls.append({"api": api, "method": method, "params": params or {}, "data": data,
|
||||
"base_path": base_path})
|
||||
handler = self.routes.get(api)
|
||||
if handler is None:
|
||||
return Result(False, None, "not found", 404)
|
||||
return handler(params or {}, data) if callable(handler) else handler
|
||||
|
||||
|
||||
def _client(routes: dict, **kwargs) -> MediaVault:
|
||||
"""构造一个绕过网络探测的客户端。"""
|
||||
client = MediaVault.__new__(MediaVault)
|
||||
client._host = "http://mv.local"
|
||||
client._playhost = kwargs.get("play_host")
|
||||
client._apikey = "k"
|
||||
client._sync_libraries = kwargs.get("sync_libraries") or []
|
||||
client._api = _FakeApi(routes)
|
||||
client._active = True
|
||||
return client
|
||||
|
||||
|
||||
def _item_row(index: int, kind: str = "Movie", **extra) -> dict:
|
||||
row = {"id": f"id-{index}", "library_id": "lib-1", "parent_id": "", "kind": kind,
|
||||
"title": f"影片{index}", "year": 2020, "tmdb_id": 1000 + index, "overview": "",
|
||||
"genres": [], "has_poster": True, "has_backdrop": False, "season": 0, "episode": 0,
|
||||
"duration_ticks": 0, "is_missing": False, "metadata_info": {}, "user_data": {}}
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def _paged_items(total_rows: list):
|
||||
"""按 page/page_size 切分预置条目,模拟 MediaVault 的分页语义。"""
|
||||
|
||||
def handler(params, _data):
|
||||
page = int(params.get("page", 1))
|
||||
size = int(params.get("page_size", 40))
|
||||
start = (page - 1) * size
|
||||
return Result(True, {"items": total_rows[start:start + size], "total": len(total_rows)})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# ── 分页 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [1, 30, 100, 130, 250, 356, 400])
|
||||
def test_get_items_limit_matches_full_scan_prefix(limit):
|
||||
"""限量遍历的结果必须是全量遍历的前缀,不重复也不跳条。"""
|
||||
rows = [_item_row(i) for i in range(356)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
full = [item.item_id for item in client.get_items("lib-1")]
|
||||
assert len(full) == len(set(full)) == 356
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
got = [item.item_id for item in client.get_items("lib-1", limit=limit)]
|
||||
assert got == full[:limit]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start_index", [0, 30, 100, 130, 250])
|
||||
def test_get_items_start_index_matches_full_scan_slice(start_index):
|
||||
"""起始偏移不是页大小整数倍时,也必须精确对齐到全量切片。"""
|
||||
rows = [_item_row(i) for i in range(356)]
|
||||
full = [item.item_id for item in _client({"/items": _paged_items(rows)}).get_items("lib-1")]
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
got = [item.item_id for item in client.get_items("lib-1", start_index=start_index, limit=20)]
|
||||
assert got == full[start_index:start_index + 20]
|
||||
|
||||
|
||||
def test_get_items_always_requests_full_pages():
|
||||
"""页大小恒为上限,页码才能稳定换算成偏移量。"""
|
||||
rows = [_item_row(i) for i in range(250)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
list(client.get_items("lib-1", limit=130))
|
||||
sizes = {call["params"]["page_size"] for call in client._api.calls}
|
||||
pages = [call["params"]["page"] for call in client._api.calls]
|
||||
assert sizes == {MediaVault.PAGE_LIMIT}
|
||||
assert pages == [1, 2]
|
||||
|
||||
|
||||
def test_get_items_stops_when_request_fails():
|
||||
"""中途请求失败时停止产出,不把失败当成遍历结束的空库。"""
|
||||
rows = [_item_row(i) for i in range(150)]
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(params, _data):
|
||||
calls["n"] += 1
|
||||
if calls["n"] > 1:
|
||||
return None
|
||||
return _paged_items(rows)(params, None)
|
||||
|
||||
client = _client({"/items": flaky})
|
||||
assert len([*client.get_items("lib-1")]) == 100
|
||||
|
||||
|
||||
# ── 媒体库与统计 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_librarys_maps_type_and_builds_image_url():
|
||||
"""媒体库类型按 MediaVault 的 library_type 映射,封面走带鉴权的图片直链。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "电影库", "library_type": "movies", "root_paths": ["/mnt/movies"]},
|
||||
{"id": "lib-2", "name": "剧集库", "library_type": "tvshows", "root_path": "/mnt/tv"},
|
||||
{"id": "lib-3", "name": "未知库", "library_type": "other", "root_paths": []},
|
||||
]}),
|
||||
"/items": _paged_items([_item_row(0)]),
|
||||
}
|
||||
libraries = _client(routes).get_librarys()
|
||||
assert [lib.type for lib in libraries] == [
|
||||
MediaType.MOVIE.value, MediaType.TV.value, MediaType.UNKNOWN.value
|
||||
]
|
||||
assert libraries[0].path == ["/mnt/movies"]
|
||||
assert libraries[1].path == "/mnt/tv"
|
||||
assert libraries[0].image.endswith("/items/lib-1/image/primary?api_key=k")
|
||||
assert libraries[0].server_type == "mediavault"
|
||||
|
||||
|
||||
def test_get_librarys_hidden_respects_sync_selection():
|
||||
"""开启过滤时只保留已勾选同步的媒体库。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "A", "library_type": "movies"},
|
||||
{"id": "lib-2", "name": "B", "library_type": "movies"},
|
||||
]}),
|
||||
"/items": _paged_items([]),
|
||||
}
|
||||
client = _client(routes, sync_libraries=["lib-2"])
|
||||
assert [lib.id for lib in client.get_librarys(hidden=True)] == ["lib-2"]
|
||||
assert [lib.id for lib in client.get_librarys(hidden=False)] == ["lib-1", "lib-2"]
|
||||
|
||||
|
||||
def test_get_librarys_returns_none_when_unreachable():
|
||||
"""连接失败返回 None,与"媒体库为空"区分开。"""
|
||||
assert _client({"/libraries": None}).get_librarys() is None
|
||||
|
||||
|
||||
def test_get_medias_count_maps_statistics_fields():
|
||||
"""统计接口字段映射到 MoviePilot 的统计模型。"""
|
||||
routes = {"/statistics": Result(True, {"movie_count": 3030, "series_count": 1501,
|
||||
"episode_count": 69597, "item_count": 74128})}
|
||||
statistic = _client(routes).get_medias_count()
|
||||
assert (statistic.movie_count, statistic.tv_count, statistic.episode_count) == (3030, 1501, 69597)
|
||||
|
||||
|
||||
def test_get_items_count_reads_total_not_page_length():
|
||||
"""条目总数取分页返回的 total,不受页大小影响。"""
|
||||
rows = [_item_row(i) for i in range(356)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert client.get_items_count("lib-1") == 356
|
||||
|
||||
|
||||
# ── 存在性判断 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_movies_filters_by_title_year_and_identity():
|
||||
"""电影匹配要求标题全等、年份一致且媒体身份不冲突。"""
|
||||
rows = [
|
||||
_item_row(1, title="沙丘", year=2021, tmdb_id=438631),
|
||||
_item_row(2, title="沙丘", year=2024, tmdb_id=693134),
|
||||
_item_row(3, title="沙丘前传", year=2021, tmdb_id=111),
|
||||
]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert [m.item_id for m in client.get_movies(title="沙丘")] == ["id-1", "id-2"]
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert [m.item_id for m in client.get_movies(title="沙丘", year="2021")] == ["id-1"]
|
||||
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
matched = client.get_movies(title="沙丘", media_source=MediaSource.TMDB, media_id="693134")
|
||||
assert [m.item_id for m in matched] == ["id-2"]
|
||||
|
||||
|
||||
def test_get_movies_requests_movie_kind_only():
|
||||
"""存在性查询按类型收窄,避免剧集与分集混进电影结果。"""
|
||||
client = _client({"/items": _paged_items([])})
|
||||
client.get_movies(title="沙丘")
|
||||
assert client._api.calls[0]["params"]["kinds"] == "Movie"
|
||||
assert client._api.calls[0]["params"]["keyword"] == "沙丘"
|
||||
|
||||
|
||||
def test_get_tv_episodes_by_item_id_returns_season_map():
|
||||
"""按条目 ID 查询直接返回季集映射。"""
|
||||
routes = {
|
||||
"/items/series-1": Result(True, _item_row(1, kind="Series", title="剧A", tmdb_id=99)),
|
||||
"/items/series-1/episodes": Result(True, {"seasons": {"1": [1, 2, 3], "2": [1]}}),
|
||||
}
|
||||
item_id, seasons = _client(routes).get_tv_episodes(item_id="series-1")
|
||||
assert item_id == "series-1"
|
||||
assert seasons == {1: [1, 2, 3], 2: [1]}
|
||||
|
||||
|
||||
def test_get_tv_episodes_filters_requested_season():
|
||||
"""指定季号时只返回该季。"""
|
||||
routes = {
|
||||
"/items/series-1": Result(True, _item_row(1, kind="Series")),
|
||||
"/items/series-1/episodes": Result(True, {"seasons": {"1": [1, 2], "2": [1]}}),
|
||||
}
|
||||
_, seasons = _client(routes).get_tv_episodes(item_id="series-1", season=2)
|
||||
assert seasons == {2: [1]}
|
||||
|
||||
|
||||
def test_get_tv_episodes_falls_back_to_title_when_cached_id_is_stale():
|
||||
"""缓存的条目 ID 失效时退回按标题重新定位,不误判整部剧缺失。"""
|
||||
routes = {
|
||||
"/items/stale-id": Result(False, None, "not found", 404),
|
||||
"/items": _paged_items([_item_row(7, kind="Series", title="剧A", year=2020)]),
|
||||
"/items/id-7/episodes": Result(True, {"seasons": {"1": [1, 2]}}),
|
||||
}
|
||||
item_id, seasons = _client(routes).get_tv_episodes(item_id="stale-id", title="剧A", year="2020")
|
||||
assert item_id == "id-7"
|
||||
assert seasons == {1: [1, 2]}
|
||||
|
||||
|
||||
def test_get_tv_episodes_returns_empty_when_series_absent():
|
||||
"""剧集不在库中返回空季集:与 Emby 一致用 (None, {}) 表示"查得到但没有"。"""
|
||||
routes = {"/items": _paged_items([])}
|
||||
assert _client(routes).get_tv_episodes(title="不存在的剧") == (None, {})
|
||||
|
||||
|
||||
def test_get_tv_episodes_returns_none_when_unreachable():
|
||||
"""服务不可达时返回 None,避免被当成"这部剧一集都没有"。"""
|
||||
routes = {"/items": lambda params, data: None}
|
||||
assert _client(routes).get_tv_episodes(title="剧A") == (None, None)
|
||||
|
||||
|
||||
def test_get_season_episode_ids_maps_episode_number_to_item_id():
|
||||
"""季集条目 ID 映射先定位季,再遍历该季的分集。"""
|
||||
|
||||
def items(params, _data):
|
||||
if params.get("kinds") == "Season":
|
||||
return Result(True, {"items": [_item_row(1, kind="Season", season=1),
|
||||
_item_row(2, kind="Season", season=2)], "total": 2})
|
||||
if params.get("parent_id") == "id-2":
|
||||
return Result(True, {"items": [_item_row(10, kind="Episode", season=2, episode=1),
|
||||
_item_row(11, kind="Episode", season=2, episode=2)],
|
||||
"total": 2})
|
||||
return Result(True, {"items": [], "total": 0})
|
||||
|
||||
assert _client({"/items": items}).get_season_episode_ids("series-1", 2) == {
|
||||
1: "id-10", 2: "id-11",
|
||||
}
|
||||
|
||||
|
||||
def test_get_season_episode_ids_returns_empty_for_missing_season():
|
||||
"""季不存在时返回空映射。"""
|
||||
client = _client({"/items": _paged_items([_item_row(1, kind="Season", season=1)])})
|
||||
assert client.get_season_episode_ids("series-1", 9) == {}
|
||||
|
||||
|
||||
# ── 条目转换 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_iteminfo_extracts_identity_and_path_from_sources():
|
||||
"""条目详情带媒体源时取文件路径,身份按 ProviderIds 优先级解析。"""
|
||||
row = _item_row(1, tmdb_id=0, metadata_info={"original_title": "Dune",
|
||||
"external_ids": {"imdb_id": "tt1160419"}},
|
||||
sources=[{"id": "s1", "path": "/mnt/movies/Dune/Dune.mkv"}])
|
||||
client = _client({"/items/id-1": Result(True, row)})
|
||||
item = client.get_iteminfo("id-1")
|
||||
assert item.media_source == MediaSource.IMDb
|
||||
assert item.media_id == "tt1160419"
|
||||
assert item.original_title == "Dune"
|
||||
assert item.path == "/mnt/movies/Dune/Dune.mkv"
|
||||
|
||||
|
||||
def test_iteminfo_prefers_tmdb_over_other_providers():
|
||||
"""同时有 TMDB 与 IMDb 时按统一优先级取 TMDB。"""
|
||||
row = _item_row(1, tmdb_id=438631, metadata_info={"external_ids": {"imdb_id": "tt1160419"}})
|
||||
item = _client({"/items/id-1": Result(True, row)}).get_iteminfo("id-1")
|
||||
assert (item.media_source, item.media_id) == (MediaSource.TMDB, "438631")
|
||||
|
||||
|
||||
def test_iteminfo_returns_none_for_missing_item():
|
||||
"""条目不存在返回 None。"""
|
||||
assert _client({}).get_iteminfo("nope") is None
|
||||
|
||||
|
||||
# ── 展示与图片 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_resume_builds_episode_subtitle_and_percent():
|
||||
"""继续观看的分集用剧名做标题,进度按播放位置换算。"""
|
||||
row = _item_row(1, kind="Episode", season=2, episode=5, title="第五集",
|
||||
series_id="series-1", series_name="剧A",
|
||||
duration_ticks=1000, user_data={"position_ticks": 250})
|
||||
client = _client({"/items": _paged_items([row])})
|
||||
played = client.get_resume(num=5)[0]
|
||||
assert played.title == "剧A"
|
||||
assert played.subtitle == "S2:5 - 第五集"
|
||||
assert played.type == MediaType.TV.value
|
||||
assert played.percent == 25.0
|
||||
# 分集海报取所属剧集,避免每集一张缩略图
|
||||
assert "/items/series-1/image/primary" in played.image
|
||||
|
||||
|
||||
def test_get_latest_backdrops_fills_up_to_requested_count():
|
||||
"""没有背景图的条目不占名额,取满请求数量为止。"""
|
||||
rows = [_item_row(i, has_backdrop=(i % 3 == 0)) for i in range(60)]
|
||||
client = _client({"/items": _paged_items(rows)})
|
||||
assert len(client.get_latest_backdrops(num=10)) == 10
|
||||
|
||||
|
||||
def test_get_latest_backdrops_uses_play_host_when_remote():
|
||||
"""外网场景改用播放地址拼图片链接。"""
|
||||
rows = [_item_row(0, has_backdrop=True)]
|
||||
client = _client({"/items": _paged_items(rows)}, play_host="https://mv.example.com")
|
||||
assert client.get_latest_backdrops(num=1, remote=True)[0].startswith("https://mv.example.com")
|
||||
assert client.get_latest_backdrops(num=1, remote=False)[0].startswith("http://mv.local")
|
||||
|
||||
|
||||
# ── 入库刷新 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_refresh_library_by_items_scans_only_matched_libraries():
|
||||
"""入库路径命中哪个媒体库就只扫哪个,同一媒体库不重复排队。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [
|
||||
{"id": "lib-1", "name": "电影", "root_paths": ["/mnt/movies"]},
|
||||
{"id": "lib-2", "name": "剧集", "root_paths": ["/mnt/tv"]},
|
||||
]}),
|
||||
"/libraries/lib-1/scan-task": Result(True, {}),
|
||||
"/libraries/lib-2/scan-task": Result(True, {}),
|
||||
}
|
||||
client = _client(routes)
|
||||
assert client.refresh_library_by_items([
|
||||
RefreshMediaItem(title="A", target_path=Path("/mnt/movies/A (2020)")),
|
||||
RefreshMediaItem(title="B", target_path=Path("/mnt/movies/B (2021)")),
|
||||
]) is True
|
||||
scanned = [call["api"] for call in client._api.calls if call["api"].endswith("scan-task")]
|
||||
assert scanned == ["/libraries/lib-1/scan-task"]
|
||||
|
||||
|
||||
def test_refresh_library_by_items_falls_back_to_full_scan_when_unmatched():
|
||||
"""路径落在所有媒体库之外时退回全库扫描,避免新片一直不入库。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [{"id": "lib-1", "root_paths": ["/mnt/movies"]}]}),
|
||||
"/libraries/lib-1/scan-task": Result(True, {}),
|
||||
}
|
||||
client = _client(routes)
|
||||
assert client.refresh_library_by_items([
|
||||
RefreshMediaItem(title="C", target_path=Path("/data/other/C")),
|
||||
]) is True
|
||||
assert [call["api"] for call in client._api.calls if "scan-task" in call["api"]] == [
|
||||
"/libraries/lib-1/scan-task"
|
||||
]
|
||||
|
||||
|
||||
def test_refresh_library_by_items_returns_none_when_unreachable():
|
||||
"""媒体库列表拿不到时返回 None,交由上层判定为服务不可用。"""
|
||||
client = _client({"/libraries": None})
|
||||
assert client.refresh_library_by_items([RefreshMediaItem(title="A", target_path=Path("/x"))]) is None
|
||||
|
||||
|
||||
def test_refresh_root_library_queues_every_library():
|
||||
"""全库刷新对每个媒体库各排一次后台扫描。"""
|
||||
routes = {
|
||||
"/libraries": Result(True, {"items": [{"id": "lib-1"}, {"id": "lib-2"}]}),
|
||||
"/libraries/lib-1/scan-task": Result(True, {}),
|
||||
"/libraries/lib-2/scan-task": Result(True, {}),
|
||||
}
|
||||
client = _client(routes)
|
||||
assert client.refresh_root_library() is True
|
||||
assert [call["api"] for call in client._api.calls if "scan-task" in call["api"]] == [
|
||||
"/libraries/lib-1/scan-task", "/libraries/lib-2/scan-task",
|
||||
]
|
||||
assert all(call["method"] == "post" for call in client._api.calls if "scan-task" in call["api"])
|
||||
|
||||
|
||||
# ── 连接与认证 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reconnect_marks_inactive_when_credentials_rejected():
|
||||
"""凭据被拒时标记为失活,交给定时重连重试。"""
|
||||
client = _client({"/libraries": Result(False, None, "unauthorized", 401)})
|
||||
assert client.reconnect() is False
|
||||
assert client.is_inactive() is True
|
||||
assert client.is_authenticated() is False
|
||||
|
||||
|
||||
def test_unconfigured_client_never_reports_inactive():
|
||||
"""配置不完整的实例不参与重连,避免定时任务空转。"""
|
||||
client = _client({})
|
||||
client._apikey = None
|
||||
assert client.is_configured() is False
|
||||
assert client.is_inactive() is False
|
||||
assert client.reconnect() is False
|
||||
|
||||
|
||||
def test_authenticate_posts_to_user_auth_and_returns_token():
|
||||
"""用户认证走 MediaVault 账号体系,返回访问令牌。"""
|
||||
routes = {"/login": Result(True, {"access_token": "jwt-token", "token": "legacy"})}
|
||||
client = _client(routes)
|
||||
assert client.authenticate("someone", "secret") == "jwt-token"
|
||||
call = client._api.calls[0]
|
||||
assert (call["base_path"], call["method"]) == ("/api/v1/user-auth", "post")
|
||||
assert call["data"] == {"username": "someone", "password": "secret"}
|
||||
|
||||
|
||||
def test_authenticate_returns_none_on_rejection():
|
||||
"""认证失败返回 None,不把错误响应当成令牌。"""
|
||||
assert _client({"/login": Result(False, None, "bad credentials", 401)}).authenticate("a", "b") is None
|
||||
assert _client({}).authenticate("", "") is None
|
||||
|
||||
|
||||
def test_disconnect_closes_session():
|
||||
"""断开时释放底层会话。"""
|
||||
client = _client({})
|
||||
client.disconnect()
|
||||
assert client._api.closed is True
|
||||
assert client.is_authenticated() is False
|
||||
@@ -583,7 +583,7 @@ from app.runtime.extensions.module.adapter import (
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 40
|
||||
assert len(specs) == 41
|
||||
|
||||
adapter = HostModuleAdapter()
|
||||
lifecycle_events = []
|
||||
@@ -630,7 +630,7 @@ from app.schemas.types import EventType
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 40
|
||||
assert len(specs) == 41
|
||||
spec_by_id = {spec.id: spec for spec in specs}
|
||||
|
||||
events = {spec.id: [] for spec in specs}
|
||||
@@ -797,7 +797,7 @@ from app.runtime.extensions.module.adapter import (
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 40
|
||||
assert len(specs) == 41
|
||||
configured_specs = tuple(
|
||||
spec for spec in specs
|
||||
if spec.activation is ActivationPolicy.WHEN_CONFIGURED
|
||||
@@ -893,12 +893,12 @@ from app.application.module import configure_module_runtime
|
||||
configure_module_runtime(lambda: ModuleManager())
|
||||
|
||||
manager = ModuleManager()
|
||||
assert len(manager.list_specs()) == 40
|
||||
assert len(manager.list_specs()) == 41
|
||||
assert manager.get_specs() == manager.list_specs()
|
||||
|
||||
from app.api.endpoints.system import modulelist
|
||||
response = modulelist(None)
|
||||
assert len(response.data["modules"]) == 40
|
||||
assert len(response.data["modules"]) == 41
|
||||
|
||||
heavy_prefixes = (
|
||||
"lark_oapi",
|
||||
@@ -1007,7 +1007,7 @@ from app.runtime.extensions.module.manager import ModuleManager
|
||||
|
||||
manager = ModuleManager()
|
||||
modules = manager.get_modules()
|
||||
assert len(modules) == len(manager.list_specs()) == 40
|
||||
assert len(modules) == len(manager.list_specs()) == 41
|
||||
for spec in manager.list_specs():
|
||||
implementation = modules[spec.id]
|
||||
assert implementation.get_name() == spec.metadata["name"]
|
||||
|
||||
@@ -103,17 +103,17 @@ def test_music_rename_context_contains_audio_fields():
|
||||
assert context["fileExt"] == ".flac"
|
||||
|
||||
|
||||
def test_music_rename_prefers_track_meta_over_album_media():
|
||||
"""专辑整理时应使用每个文件的曲名和曲序,不能把专辑名写成所有目标文件名。"""
|
||||
def test_music_rename_uses_album_identity_and_track_meta():
|
||||
"""专辑整理应采用所选专辑身份,同时保留每个文件的曲名和曲序。"""
|
||||
meta = MetaMusic(
|
||||
org_string="10. 明天晴天.m4a",
|
||||
title="明天晴天",
|
||||
artists=["孙燕姿"],
|
||||
album="完美的一天",
|
||||
album_artist="孙燕姿",
|
||||
year=2005,
|
||||
album="错误专辑·全精选集",
|
||||
album_artist="错误艺术家(资源发布者)",
|
||||
year=1999,
|
||||
track_number=10,
|
||||
total_tracks=11,
|
||||
total_tracks=99,
|
||||
)
|
||||
album = MusicInfo(
|
||||
media_source="musicbrainz",
|
||||
@@ -137,6 +137,9 @@ def test_music_rename_prefers_track_meta_over_album_media():
|
||||
|
||||
assert context["title"] == "明天晴天"
|
||||
assert context["track"] == "10"
|
||||
assert context["album"] == "完美的一天"
|
||||
assert context["album_artist"] == "孙燕姿"
|
||||
assert context["year"] == 2005
|
||||
assert rendered == "孙燕姿/完美的一天 (2005)/10 - 明天晴天.m4a"
|
||||
|
||||
|
||||
|
||||
@@ -24,10 +24,82 @@ def _manager(monkeypatch, tmp_path: Path):
|
||||
return manager
|
||||
|
||||
|
||||
def _docker_manager(monkeypatch, tmp_path: Path):
|
||||
"""创建指向隔离 Docker 目录的更新管理器。"""
|
||||
runtime_settings = {
|
||||
"TEMP_PATH": tmp_path / "config" / "temp",
|
||||
"ROOT_PATH": tmp_path / "app",
|
||||
"FRONTEND_PATH": tmp_path / "public",
|
||||
"VENV_PATH": tmp_path / "venv",
|
||||
"UV_BIN": tmp_path / "uv",
|
||||
"MOVIEPILOT_AUTO_UPDATE": False,
|
||||
"AUTO_UPDATE_RESOURCE": True,
|
||||
"PIP_PROXY": "",
|
||||
"PROXY_HOST": "",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
update_module,
|
||||
"get_runtime_setting",
|
||||
lambda key: runtime_settings[key],
|
||||
)
|
||||
monkeypatch.setattr(update_module, "is_docker", lambda: True)
|
||||
manager = object.__new__(update_module.SystemUpdateManager)
|
||||
manager._lock = threading.RLock()
|
||||
manager._download_active = False
|
||||
manager._active_target = None
|
||||
return manager
|
||||
|
||||
|
||||
def _response(payload, status_code=200):
|
||||
return SimpleNamespace(status_code=status_code, json=lambda: payload)
|
||||
|
||||
|
||||
def test_status_reads_live_auto_update_setting_without_discarding_cached_update(monkeypatch, tmp_path):
|
||||
"""切换提醒设置立即反映到状态,缓存版本仍供手动升级使用。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
manager._write_state(state="available", version="v3.1.0", can_update=True)
|
||||
for enabled in (True, False, True):
|
||||
monkeypatch.setattr(
|
||||
update_module, "get_runtime_setting",
|
||||
lambda key: tmp_path if key == "TEMP_PATH" else enabled,
|
||||
)
|
||||
status = manager.get_status()
|
||||
assert status.auto_update is enabled
|
||||
assert status.state == "available"
|
||||
assert status.version == "v3.1.0"
|
||||
assert status.can_update is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auto_update", [False, True])
|
||||
@pytest.mark.parametrize("auto_update_resource", [False, True])
|
||||
def test_scheduled_check_respects_independent_switches(
|
||||
monkeypatch, tmp_path, auto_update, auto_update_resource
|
||||
):
|
||||
"""自动检查只访问已开启的目标;手动检查仍可访问两类更新。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
values = {
|
||||
"TEMP_PATH": tmp_path,
|
||||
"MOVIEPILOT_AUTO_UPDATE": auto_update,
|
||||
"AUTO_UPDATE_RESOURCE": auto_update_resource,
|
||||
}
|
||||
monkeypatch.setattr(update_module, "get_runtime_setting", values.get)
|
||||
checked = []
|
||||
monkeypatch.setattr(manager, "_check_application", lambda: checked.append("application"))
|
||||
monkeypatch.setattr(manager, "_check_resources", lambda: checked.append("resources"))
|
||||
|
||||
status = manager.check_scheduled()
|
||||
assert checked == [
|
||||
target for target, enabled in (("application", auto_update), ("resources", auto_update_resource))
|
||||
if enabled
|
||||
]
|
||||
assert status.auto_update is auto_update
|
||||
assert status.auto_update_resource is auto_update_resource
|
||||
|
||||
checked.clear()
|
||||
manager.check()
|
||||
assert checked == ["application", "resources"]
|
||||
|
||||
|
||||
def test_check_exposes_new_stable_release(monkeypatch, tmp_path):
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
logs = []
|
||||
@@ -342,3 +414,125 @@ def test_cancel_install_returns_prepared_update_to_ready(monkeypatch, tmp_path):
|
||||
assert status.can_install is True
|
||||
assert status.error == "restart failed"
|
||||
assert not manager._install_file.exists()
|
||||
|
||||
|
||||
def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugins(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""Docker root worker 应替换前后端目录,同时保留运行时插件和站点资源。"""
|
||||
manager = _docker_manager(monkeypatch, tmp_path)
|
||||
app_dir = manager._docker_app_dir
|
||||
public_dir = manager._docker_public_dir
|
||||
plugin_dir = app_dir / "app" / "plugins"
|
||||
resource_dir = app_dir / "app" / "application" / "site"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
resource_dir.mkdir(parents=True)
|
||||
public_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text("# compatibility\n", encoding="utf-8")
|
||||
(plugin_dir / "local_plugin.py").write_text("local\n", encoding="utf-8")
|
||||
(resource_dir / "user.sites.v3.bin").write_text("old-resource\n", encoding="utf-8")
|
||||
(app_dir / "old.py").write_text("old\n", encoding="utf-8")
|
||||
(app_dir / "pyproject.toml").write_text("old-project\n", encoding="utf-8")
|
||||
(app_dir / "uv.lock").write_text("old-lock\n", encoding="utf-8")
|
||||
(public_dir / "index.html").write_text("old-front\n", encoding="utf-8")
|
||||
manager._root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(manager._backend_archive, "w") as archive:
|
||||
archive.writestr(
|
||||
"MoviePilot-v3.1.0/version.py",
|
||||
"APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n",
|
||||
)
|
||||
archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n")
|
||||
archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n")
|
||||
archive.writestr("MoviePilot-v3.1.0/new.py", "new\n")
|
||||
with zipfile.ZipFile(manager._frontend_archive, "w") as archive:
|
||||
archive.writestr("dist/index.html", "new-front\n")
|
||||
archive.writestr("dist/version.txt", "v3.1.0\n")
|
||||
|
||||
prepared = {
|
||||
"targets": ["application"],
|
||||
"version": "v3.1.0",
|
||||
"frontend_version": "v3.1.0",
|
||||
"backend_archive": str(manager._backend_archive),
|
||||
"frontend_archive": str(manager._frontend_archive),
|
||||
"backend_sha256": manager._sha256(manager._backend_archive),
|
||||
"frontend_sha256": manager._sha256(manager._frontend_archive),
|
||||
}
|
||||
(manager._root / "prepared.json").write_text(
|
||||
json.dumps(prepared), encoding="utf-8"
|
||||
)
|
||||
manager._install_file.write_text(json.dumps(prepared), encoding="utf-8")
|
||||
sync_calls = []
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_sync_docker_dependencies",
|
||||
lambda project_dir, **kwargs: sync_calls.append((project_dir, kwargs)),
|
||||
)
|
||||
|
||||
success, message = manager.apply_prepared_update()
|
||||
|
||||
assert success is True
|
||||
assert message == "已下载的更新已替换到 Docker 程序目录"
|
||||
assert len(sync_calls) == 1
|
||||
assert sync_calls[0][0].name == "App"
|
||||
assert sync_calls[0][1] == {}
|
||||
assert (app_dir / "new.py").read_text(encoding="utf-8") == "new\n"
|
||||
assert not (app_dir / "old.py").exists()
|
||||
assert (app_dir / "app" / "plugins" / "local_plugin.py").exists()
|
||||
assert (resource_dir / "user.sites.v3.bin").read_text(encoding="utf-8") == "old-resource\n"
|
||||
assert (public_dir / "index.html").read_text(encoding="utf-8") == "new-front\n"
|
||||
assert not manager._install_file.exists()
|
||||
assert not (manager._root / "prepared.json").exists()
|
||||
assert not manager._docker_pending_file.exists()
|
||||
assert not manager._docker_previous_app_dir.exists()
|
||||
assert not manager._docker_previous_public_dir.exists()
|
||||
|
||||
|
||||
def test_apply_prepared_resources_replaces_complete_docker_resource_package(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""Docker root worker 应原子替换完整站点资源包而不触碰主程序目录。"""
|
||||
manager = _docker_manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
update_module.ResourceHelper,
|
||||
"_get_needed_files",
|
||||
classmethod(lambda cls: ["user.sites.v3.bin", "sites.cpython-test.so"]),
|
||||
)
|
||||
monkeypatch.setattr(update_module, "get_resource_versions", lambda: ("1", "1"))
|
||||
resource_dir = manager._docker_app_dir / "app" / "application" / "site"
|
||||
resource_dir.mkdir(parents=True)
|
||||
(manager._docker_app_dir / "keep.py").parent.mkdir(parents=True, exist_ok=True)
|
||||
(manager._docker_app_dir / "keep.py").write_text("keep\n", encoding="utf-8")
|
||||
(resource_dir / "user.sites.v3.bin").write_bytes(b"old-index")
|
||||
(resource_dir / "sites.cpython-old.so").write_bytes(b"old-native")
|
||||
prepared_files = []
|
||||
for name, content in (
|
||||
("user.sites.v3.bin", b"new-index"),
|
||||
("sites.cpython-test.so", b"new-native"),
|
||||
):
|
||||
path = manager._resource_dir / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
prepared_files.append(
|
||||
{"name": name, "path": str(path), "sha256": manager._sha256(path)}
|
||||
)
|
||||
prepared = {
|
||||
"targets": ["resources"],
|
||||
"resource_package_version": "10",
|
||||
"resource_files": prepared_files,
|
||||
}
|
||||
manager._root.mkdir(parents=True, exist_ok=True)
|
||||
(manager._root / "prepared.json").write_text(
|
||||
json.dumps(prepared), encoding="utf-8"
|
||||
)
|
||||
manager._install_file.write_text(json.dumps(prepared), encoding="utf-8")
|
||||
|
||||
success, _message = manager.apply_prepared_update()
|
||||
|
||||
assert success is True
|
||||
assert (manager._docker_app_dir / "keep.py").read_text(encoding="utf-8") == "keep\n"
|
||||
assert (resource_dir / "user.sites.v3.bin").read_bytes() == b"new-index"
|
||||
assert (resource_dir / "sites.cpython-test.so").read_bytes() == b"new-native"
|
||||
assert not (resource_dir / "sites.cpython-old.so").exists()
|
||||
assert not manager._install_file.exists()
|
||||
assert not (manager._root / "prepared.json").exists()
|
||||
|
||||
+111
-8
@@ -13,9 +13,9 @@ from unittest.mock import MagicMock, call, patch
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.config import ConfigModel, Settings
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.runtime.config import ConfigModel, Settings
|
||||
from app.runtime.state import SystemHelper
|
||||
|
||||
|
||||
def test_get_config_path_uses_repository_config_for_source_runtime():
|
||||
@@ -91,19 +91,50 @@ def test_docker_restart_delegates_to_supervisor():
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisor_config") as supervisor_config, \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisorctl") as supervisorctl, \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisor_socket") as supervisor_socket, \
|
||||
patch.object(SystemHelper, "_SystemHelper__prepared_update_manifest") as prepared_manifest, \
|
||||
patch.object(SystemHelper, "_SystemHelper__one_shot_dev_update_flag_file") as dev_update_flag, \
|
||||
patch.object(SystemHelper, "_schedule_supervisor_restart") as restart_mock, \
|
||||
patch.object(SystemHelper, "_schedule_supervisor_shutdown") as shutdown_mock, \
|
||||
patch("app.runtime.state.os.kill") as kill_mock:
|
||||
supervisor_config.exists.return_value = True
|
||||
supervisorctl.exists.return_value = True
|
||||
supervisor_socket.exists.return_value = True
|
||||
prepared_manifest.is_file.return_value = False
|
||||
dev_update_flag.is_file.return_value = False
|
||||
ret, msg = SystemHelper.restart()
|
||||
|
||||
assert ret
|
||||
assert msg == ""
|
||||
restart_mock.assert_called_once_with()
|
||||
shutdown_mock.assert_not_called()
|
||||
kill_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_docker_update_restart_reenters_entrypoint_for_pending_install():
|
||||
"""待安装更新先启动 root worker 替换 Docker 程序目录。"""
|
||||
with patch("app.runtime.state.is_docker", return_value=True), \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisor_config") as supervisor_config, \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisorctl") as supervisorctl, \
|
||||
patch.object(SystemHelper, "_SystemHelper__supervisor_socket") as supervisor_socket, \
|
||||
patch.object(SystemHelper, "_SystemHelper__prepared_update_manifest") as prepared_manifest, \
|
||||
patch.object(SystemHelper, "_SystemHelper__one_shot_dev_update_flag_file") as dev_update_flag, \
|
||||
patch.object(SystemHelper, "_schedule_supervisor_restart") as restart_mock, \
|
||||
patch.object(SystemHelper, "_schedule_supervisor_shutdown") as shutdown_mock, \
|
||||
patch.object(SystemHelper, "_schedule_supervisor_command") as command_mock:
|
||||
supervisor_config.exists.return_value = True
|
||||
supervisorctl.exists.return_value = True
|
||||
supervisor_socket.exists.return_value = True
|
||||
prepared_manifest.is_file.return_value = True
|
||||
dev_update_flag.is_file.return_value = False
|
||||
ret, msg = SystemHelper.restart()
|
||||
|
||||
assert ret
|
||||
assert msg == ""
|
||||
command_mock.assert_called_once_with("start", "moviepilot-update-worker")
|
||||
restart_mock.assert_not_called()
|
||||
shutdown_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_supervisor_restart_command_restarts_frontend_and_backend(monkeypatch):
|
||||
"""延迟任务必须通过本地 supervisor 同时重启前后端进程。"""
|
||||
callback = None
|
||||
@@ -126,6 +157,40 @@ def test_supervisor_restart_command_restarts_frontend_and_backend(monkeypatch):
|
||||
assert popen_mock.call_args.args[0][-2:] == ["restart", "all"]
|
||||
|
||||
|
||||
def test_supervisor_shutdown_command(monkeypatch):
|
||||
"""一次性 Dev 更新使用 supervisor shutdown,交回 root 入口执行更新流程。"""
|
||||
callback = None
|
||||
|
||||
class ImmediateTimer:
|
||||
def __init__(self, _delay, timer_callback):
|
||||
nonlocal callback
|
||||
callback = timer_callback
|
||||
self.daemon = False
|
||||
|
||||
def start(self):
|
||||
callback()
|
||||
|
||||
popen_mock = MagicMock()
|
||||
monkeypatch.setattr("app.runtime.state.threading.Timer", ImmediateTimer)
|
||||
monkeypatch.setattr("app.runtime.state.subprocess.Popen", popen_mock)
|
||||
|
||||
SystemHelper._schedule_supervisor_shutdown()
|
||||
|
||||
assert popen_mock.call_args.args[0][-1:] == ["shutdown"]
|
||||
|
||||
|
||||
def test_upgrade_dev_always_marks_bootstrap_update():
|
||||
"""Dev 更新即使已配置 dev 模式也要留下入口消费标记。"""
|
||||
with patch.object(SystemHelper, "queue_one_shot_dev_update", return_value=(True, "")) as queue_mock, \
|
||||
patch.object(SystemHelper, "restart", return_value=(True, "")) as restart_mock:
|
||||
ret, msg = SystemHelper.upgrade_dev()
|
||||
|
||||
assert ret
|
||||
assert msg == "已安排 Dev 更新并重启"
|
||||
queue_mock.assert_called_once_with()
|
||||
restart_mock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_execute_with_subprocess_passes_env_to_subprocess():
|
||||
with patch("app.adapters.system.host.subprocess.run") as run_mock:
|
||||
run_mock.return_value.stdout = "ok"
|
||||
@@ -473,8 +538,9 @@ def test_btrfs_fsid_dedup_setting_is_opt_in():
|
||||
assert ConfigModel(BTRFS_FSID_DEDUP="true").BTRFS_FSID_DEDUP is True
|
||||
|
||||
|
||||
def test_auto_update_mode_is_normalized(monkeypatch):
|
||||
"""自动更新仅保留 true、dev 和 false 三种运行模式。"""
|
||||
@pytest.mark.parametrize("mode", ["release", "dev", " DEV ", "RELEASE"])
|
||||
def test_auto_update_mode_is_normalized(monkeypatch, mode):
|
||||
"""旧模式规范化为布尔 true,已有的新 Dev 偏好不被覆盖。"""
|
||||
updates = []
|
||||
monkeypatch.setattr(
|
||||
Settings,
|
||||
@@ -484,14 +550,51 @@ def test_auto_update_mode_is_normalized(monkeypatch):
|
||||
),
|
||||
)
|
||||
|
||||
assert Settings(MOVIEPILOT_AUTO_UPDATE="release").MOVIEPILOT_AUTO_UPDATE == "false"
|
||||
assert Settings(MOVIEPILOT_AUTO_UPDATE="true").MOVIEPILOT_AUTO_UPDATE == "true"
|
||||
assert Settings(MOVIEPILOT_AUTO_UPDATE="dev").MOVIEPILOT_AUTO_UPDATE == "dev"
|
||||
config = Settings(MOVIEPILOT_AUTO_UPDATE=mode, MOVIEPILOT_UPDATE_DEV=False)
|
||||
assert config.MOVIEPILOT_AUTO_UPDATE is True
|
||||
assert config.MOVIEPILOT_UPDATE_DEV is False
|
||||
assert updates == [
|
||||
("MOVIEPILOT_AUTO_UPDATE", "release", "false"),
|
||||
("MOVIEPILOT_AUTO_UPDATE", mode, True),
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_dev_tracking_is_migrated_once(monkeypatch, tmp_path):
|
||||
"""首次拆分配置时持久化两个开关,重读后继续保留 Dev 跟踪。"""
|
||||
env_file = tmp_path / "app.env"
|
||||
env_file.write_text("MOVIEPILOT_AUTO_UPDATE='dev'\n", encoding="utf-8")
|
||||
monkeypatch.setattr("app.runtime.config.get_env_path", lambda: env_file)
|
||||
config = Settings(_env_file=env_file)
|
||||
assert config.MOVIEPILOT_AUTO_UPDATE is True
|
||||
assert config.MOVIEPILOT_UPDATE_DEV is True
|
||||
assert "MOVIEPILOT_AUTO_UPDATE='true'" in env_file.read_text(encoding="utf-8")
|
||||
assert "MOVIEPILOT_UPDATE_DEV='true'" in env_file.read_text(encoding="utf-8")
|
||||
reloaded = Settings(_env_file=env_file)
|
||||
assert reloaded.MOVIEPILOT_AUTO_UPDATE is True
|
||||
assert reloaded.MOVIEPILOT_UPDATE_DEV is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False, "true", "false"])
|
||||
def test_update_switches_remain_independent_booleans(enabled):
|
||||
"""部署设置与调度快照仅暴露布尔值,Dev 跟踪不影响自动检查。"""
|
||||
from app.startup.composition.configuration import build_scheduler_runtime_config
|
||||
|
||||
expected = str(enabled).lower() == "true"
|
||||
config = Settings(MOVIEPILOT_AUTO_UPDATE=enabled, MOVIEPILOT_UPDATE_DEV=not expected)
|
||||
assert config.MOVIEPILOT_AUTO_UPDATE is expected
|
||||
assert config.MOVIEPILOT_UPDATE_DEV is not expected
|
||||
assert build_scheduler_runtime_config(config).auto_update is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["dev", "release", True, False])
|
||||
def test_update_setting_normalizes_auto_update_on_save(monkeypatch, value):
|
||||
"""设置写入入口与启动读取入口使用同一套布尔转换规则。"""
|
||||
config = Settings(MOVIEPILOT_AUTO_UPDATE=False, MOVIEPILOT_UPDATE_DEV=False)
|
||||
monkeypatch.setattr(Settings, "update_env_config", lambda *_args: (True, ""))
|
||||
config.update_setting("MOVIEPILOT_AUTO_UPDATE", value)
|
||||
assert config.MOVIEPILOT_AUTO_UPDATE is (value is not False)
|
||||
assert config.MOVIEPILOT_UPDATE_DEV is False
|
||||
|
||||
|
||||
def test_space_usage_default_path_does_not_read_fsid():
|
||||
with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2:
|
||||
paths = [Path(tmp1), Path(tmp2)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""验证 TransferChain 步骤 runner 与文件执行器的崩溃恢复边界。"""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
@@ -30,7 +31,10 @@ from app.db.base import Base
|
||||
from app.db.models.transferexecutionstep import TransferExecutionStep
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.models.transferpending import TransferPending
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.modules.filemanager.transhandler import TransHandler
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
|
||||
@@ -384,7 +388,7 @@ def test_cross_storage_move_materializes_before_independent_source_delete(tmp_pa
|
||||
|
||||
result, error = TransHandler._TransHandler__execute_transfer_with_steps(
|
||||
step_runner=runner,
|
||||
fileitem=source_item,
|
||||
source_fileitem=source_item.model_dump(mode="json"),
|
||||
target_storage="remote",
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
@@ -444,3 +448,79 @@ def test_remote_to_local_transfer_creates_target_directory_before_download(tmp_p
|
||||
path=target_file.parent,
|
||||
)
|
||||
source_oper.delete.assert_called_once_with(source_item)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sparse", [False, True])
|
||||
@pytest.mark.parametrize("directory", [False, True])
|
||||
def test_frozen_disc_plan_executes_and_replays_with_real_step_ledger(
|
||||
execution_repository, monkeypatch, sparse, directory,
|
||||
):
|
||||
"""原盘及旧快照必须通过真实意图校验,重启后不再复制或重复删除源。"""
|
||||
source = FileItem(
|
||||
storage="local", path="/disc" if directory else "/disc.iso",
|
||||
name="disc" if directory else "disc.iso", type="dir" if directory else "file",
|
||||
size=100,
|
||||
).model_dump(mode="json", exclude_unset=sparse)
|
||||
leaf = (
|
||||
FileItem(storage="local", path="/disc/BDMV/STREAM/00001.m2ts",
|
||||
name="00001.m2ts", type="file", size=200).model_dump(
|
||||
mode="json", exclude_unset=sparse,
|
||||
)
|
||||
if directory else dict(source)
|
||||
)
|
||||
target = "/library/disc" if directory else "/library/disc.iso"
|
||||
planning_input = TransferPlanningInput(source_fileitem=source)
|
||||
checkpoint = replace(
|
||||
_runner_plan_checkpoint(), planning_input=planning_input,
|
||||
target_storage="remote", final_target_path=target,
|
||||
resolved_transfer_type="move",
|
||||
items=(TransferPlanItem(
|
||||
sequence=0, source_fileitem=leaf, target_storage="remote",
|
||||
target_path=f"{target}/BDMV/STREAM/00001.m2ts" if directory else target,
|
||||
),),
|
||||
)
|
||||
with execution_repository._session_factory() as session:
|
||||
pending = session.query(TransferPending).one()
|
||||
pending.planning_input = planning_input.to_payload()
|
||||
pending.input_fingerprint = planning_input.fingerprint
|
||||
pending.checkpoint_payload = checkpoint.to_payload()
|
||||
session.commit()
|
||||
|
||||
def intercept(**kwargs):
|
||||
"""模拟插件修正运行期大小,冻结计划和步骤身份不应随之变化。"""
|
||||
kwargs["fileitem"].size = 999
|
||||
return True, ""
|
||||
|
||||
def materialize(**kwargs):
|
||||
"""模拟存储适配器更新临时链接,源删除步骤仍须沿用冻结输入。"""
|
||||
assert kwargs["fileitem"].size == leaf["size"]
|
||||
kwargs["fileitem"].url = "https://temporary.invalid/refreshed"
|
||||
return FileItem(storage="remote", path=kwargs["target_file"].as_posix()), ""
|
||||
|
||||
handler = TransHandler()
|
||||
monkeypatch.setattr(handler, "_TransHandler__intercept_transfer", intercept)
|
||||
transfer = Mock(side_effect=materialize)
|
||||
monkeypatch.setattr(TransHandler, "_TransHandler__transfer_command", transfer)
|
||||
source_oper = Mock()
|
||||
target_oper = Mock()
|
||||
target_oper.get_item_strict.return_value = None
|
||||
target_oper.get_folder.return_value = FileItem(storage="remote", path="/library", type="dir")
|
||||
|
||||
for _ in range(2):
|
||||
runner = transfer_chain_module._DurableTransferStepRunner(
|
||||
task_id="task-runner", lease_token="lease",
|
||||
checkpoint_fingerprint=checkpoint.fingerprint,
|
||||
repository=execution_repository,
|
||||
)
|
||||
result = handler.execute_transfer_plan(
|
||||
checkpoint, meta=MetaBase("disc.iso"),
|
||||
mediainfo=MediaInfo(type=MediaType.MOVIE, title="Disc"),
|
||||
source_oper=source_oper, target_oper=target_oper, step_runner=runner,
|
||||
)
|
||||
assert result.success
|
||||
assert runner.checkpoint(result).payload["outcome"] == "succeeded"
|
||||
transfer.assert_called_once()
|
||||
source_oper.delete.assert_called_once()
|
||||
assert source_oper.delete.call_args.args[0].url is None
|
||||
assert checkpoint.planning_input.source_fileitem == source
|
||||
assert checkpoint.items[0].source_fileitem == leaf
|
||||
|
||||
Reference in New Issue
Block a user