Merge remote-tracking branch 'origin/v3' into codex/manual-source-classification

This commit is contained in:
yanjunpu
2026-09-07 23:27:11 +08:00
55 changed files with 3166 additions and 656 deletions
+517 -5
View File
@@ -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:
+1 -1
View File
@@ -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."
)
+2 -2
View File
@@ -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
+6
View File
@@ -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)
+10 -10
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 前后端服务状态"""
+3
View File
@@ -34,6 +34,9 @@
"JellyfinModule": {
"name": "Jellyfin"
},
"MediaVaultModule": {
"name": "MediaVault"
},
"PlexModule": {
"name": "Plex"
},
+3
View File
@@ -34,6 +34,9 @@
"JellyfinModule": {
"name": "Jellyfin"
},
"MediaVaultModule": {
"name": "MediaVault"
},
"PlexModule": {
"name": "Plex"
},
+3
View File
@@ -34,6 +34,9 @@
"JellyfinModule": {
"name": "Jellyfin"
},
"MediaVaultModule": {
"name": "MediaVault"
},
"PlexModule": {
"name": "Plex"
},
+1 -1
View File
@@ -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判断
+16 -9
View File
@@ -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,
+29
View File
@@ -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"]
+124
View File
@@ -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]
+22
View File
@@ -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"
+589
View File
@@ -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)
+231
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -67,6 +67,7 @@ BASELINE_ASSESSED_MODULES = frozenset(
"jellyfin",
"listenbrainz",
"lrclib",
"mediavault",
"musicbrainz",
"musixmatch",
"navidrome",
+2 -1
View File
@@ -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
View File
@@ -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()
+4 -4
View File
@@ -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",
+1
View File
@@ -73,6 +73,7 @@ class Scheduler(
"DB_BACKUP_CRON",
"USAGE_STATISTIC_SHARE",
"MOVIEPILOT_AUTO_UPDATE",
"AUTO_UPDATE_RESOURCE",
}
def __init__(self) -> None:
+3
View File
@@ -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",
+2
View File
@@ -594,6 +594,8 @@ class MediaServerType(Enum):
Ugreen = "Ugreen"
# Navidrome 音乐服务器
Navidrome = "Navidrome"
# MediaVault 自建媒体库
MediaVault = "MediaVault"
# 识别器类型
+2 -1
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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 |
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
+1 -1
View File
@@ -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.
---
+15 -15
View File
@@ -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),
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
},
-9
View File
@@ -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
},
+17 -2
View File
@@ -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(
+32 -1
View File
@@ -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
+15 -13
View File
@@ -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
View File
@@ -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"),
(
+6 -1
View File
@@ -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
+82
View File
@@ -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()
+443
View File
@@ -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"]
+9 -6
View File
@@ -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"
+194
View File
@@ -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
View File
@@ -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)]
+81 -1
View File
@@ -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