diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 792cc4c71..51a639949 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -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: diff --git a/app/agent/tools/impl/service.py b/app/agent/tools/impl/service.py index f115ad815..d5c17fc4d 100644 --- a/app/agent/tools/impl/service.py +++ b/app/agent/tools/impl/service.py @@ -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." ) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 564249e49..50b613d43 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -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 diff --git a/app/application/configuration.py b/app/application/configuration.py index 889ae885f..dcb7b3ffd 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -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) diff --git a/app/application/messaging/message.py b/app/application/messaging/message.py index cd5f83c36..e1857bb3a 100644 --- a/app/application/messaging/message.py +++ b/app/application/messaging/message.py @@ -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 diff --git a/app/application/system.py b/app/application/system.py index 593b6dc43..a22489d03 100644 --- a/app/application/system.py +++ b/app/application/system.py @@ -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) diff --git a/app/cli.py b/app/cli.py index 0583d73ea..3d54340eb 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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 前后端服务状态""" diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 5268d15de..3e59ac9ce 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -34,6 +34,9 @@ "JellyfinModule": { "name": "Jellyfin" }, + "MediaVaultModule": { + "name": "MediaVault" + }, "PlexModule": { "name": "Plex" }, diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json index fc01646c4..f5be3af97 100644 --- a/app/locales/zh-CN.json +++ b/app/locales/zh-CN.json @@ -34,6 +34,9 @@ "JellyfinModule": { "name": "Jellyfin" }, + "MediaVaultModule": { + "name": "MediaVault" + }, "PlexModule": { "name": "Plex" }, diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 6865fa371..12d1e11ab 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -34,6 +34,9 @@ "JellyfinModule": { "name": "Jellyfin" }, + "MediaVaultModule": { + "name": "MediaVault" + }, "PlexModule": { "name": "Plex" }, diff --git a/app/modules/__init__.py b/app/modules/__init__.py index ad49cd91c..2312e9625 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -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判断 diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 85fa859fc..5273b48fc 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -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, diff --git a/app/modules/mediavault/__init__.py b/app/modules/mediavault/__init__.py new file mode 100644 index 000000000..fd163595b --- /dev/null +++ b/app/modules/mediavault/__init__.py @@ -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"] diff --git a/app/modules/mediavault/api.py b/app/modules/mediavault/api.py new file mode 100644 index 000000000..cf14537fe --- /dev/null +++ b/app/modules/mediavault/api.py @@ -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] diff --git a/app/modules/mediavault/capability.toml b/app/modules/mediavault/capability.toml new file mode 100644 index 000000000..70c517782 --- /dev/null +++ b/app/modules/mediavault/capability.toml @@ -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" diff --git a/app/modules/mediavault/mediavault.py b/app/modules/mediavault/mediavault.py new file mode 100644 index 000000000..564dc0dac --- /dev/null +++ b/app/modules/mediavault/mediavault.py @@ -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) diff --git a/app/modules/mediavault/module.py b/app/modules/mediavault/module.py new file mode 100644 index 000000000..010f0e932 --- /dev/null +++ b/app/modules/mediavault/module.py @@ -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 [] diff --git a/app/runtime/config.py b/app/runtime/config.py index 3efc7d19b..e9d52396c 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -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) diff --git a/app/runtime/extensions/module/quality.py b/app/runtime/extensions/module/quality.py index c7d670c2d..b27f40f5b 100644 --- a/app/runtime/extensions/module/quality.py +++ b/app/runtime/extensions/module/quality.py @@ -67,6 +67,7 @@ BASELINE_ASSESSED_MODULES = frozenset( "jellyfin", "listenbrainz", "lrclib", + "mediavault", "musicbrainz", "musixmatch", "navidrome", diff --git a/app/runtime/reload.py b/app/runtime/reload.py index dac3e52a1..c3df4a88e 100644 --- a/app/runtime/reload.py +++ b/app/runtime/reload.py @@ -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) diff --git a/app/runtime/state.py b/app/runtime/state.py index ec1b92821..1112f5588 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -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() diff --git a/app/scheduler/catalog.py b/app/scheduler/catalog.py index 7c844ff10..320a2c0bb 100644 --- a/app/scheduler/catalog.py +++ b/app/scheduler/catalog.py @@ -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", diff --git a/app/scheduler/facade.py b/app/scheduler/facade.py index 2a7d871bb..d612b8975 100644 --- a/app/scheduler/facade.py +++ b/app/scheduler/facade.py @@ -73,6 +73,7 @@ class Scheduler( "DB_BACKUP_CRON", "USAGE_STATISTIC_SHARE", "MOVIEPILOT_AUTO_UPDATE", + "AUTO_UPDATE_RESOURCE", } def __init__(self) -> None: diff --git a/app/schemas/system.py b/app/schemas/system.py index b052fd3f5..d47f2c673 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -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", diff --git a/app/schemas/types.py b/app/schemas/types.py index 67e8ab8d7..8ab951639 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -594,6 +594,8 @@ class MediaServerType(Enum): Ugreen = "Ugreen" # Navidrome 音乐服务器 Navidrome = "Navidrome" + # MediaVault 自建媒体库 + MediaVault = "MediaVault" # 识别器类型 diff --git a/app/startup/composition/configuration.py b/app/startup/composition/configuration.py index 805b21472..af4998d7d 100644 --- a/app/startup/composition/configuration.py +++ b/app/startup/composition/configuration.py @@ -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, ) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1ff8f423d..8d8e9b86c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 9854e2caa..b5c9a818b 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -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 diff --git a/docker/update-worker.sh b/docker/update-worker.sh new file mode 100644 index 000000000..e215c5219 --- /dev/null +++ b/docker/update-worker.sh @@ -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 diff --git a/docker/update.sh b/docker/update.sh index 8bb107f27..0b710d602 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -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 } diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 6b9a48308..fe6b9f7f3 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index f8ae122e1..881dba8f0 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -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 热点文件 diff --git a/docs/cli.md b/docs/cli.md index 26adfd458..c1b51f0f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 安装模式也会复用同一套前后端进程管理完成重启 diff --git a/docs/docker-startup.md b/docs/docker-startup.md index 8c1281720..23c776a56 100644 --- a/docs/docker-startup.md +++ b/docs/docker-startup.md @@ -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 --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/`;历史目录仅用于更新旧载荷时读取兼容资源。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index f166a3b0c..c240a1152 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 | diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index c4b29e0a4..cac383cab 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -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. --- diff --git a/skills/mediaserver-operation/SKILL.md b/skills/mediaserver-operation/SKILL.md index 89ae5f0ba..9f9a43f84 100644 --- a/skills/mediaserver-operation/SKILL.md +++ b/skills/mediaserver-operation/SKILL.md @@ -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 diff --git a/skills/mediaserver-operation/scripts/mp-mediaserver.py b/skills/mediaserver-operation/scripts/mp-mediaserver.py index 3848ac095..227671e59 100644 --- a/skills/mediaserver-operation/scripts/mp-mediaserver.py +++ b/skills/mediaserver-operation/scripts/mp-mediaserver.py @@ -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), diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index e03e37ff7..eed08663f 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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|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|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. diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index 7c68b0791..989de8cea 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -590,7 +590,7 @@ }, "app/runtime/state.py:threading.Timer": { "owners": { - "SystemHelper._schedule_supervisor_restart": 1 + "SystemHelper._schedule_supervisor_command": 1 }, "target": "threading.Timer" }, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 47e1c1c50..ad55e6b6e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 9a7f96214..88875bcfe 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -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 }, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 45829ab8e..65732a158 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -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 }, diff --git a/tests/test_cli_auto_update.py b/tests/test_cli_auto_update.py index fe420ccfa..217bdbfe1 100644 --- a/tests/test_cli_auto_update.py +++ b/tests/test_cli_auto_update.py @@ -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( diff --git a/tests/test_config_type_conversion.py b/tests/test_config_type_conversion.py index fa4713f58..40b9979b4 100644 --- a/tests/test_config_type_conversion.py +++ b/tests/test_config_type_conversion.py @@ -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 diff --git a/tests/test_database_backup_scheduler.py b/tests/test_database_backup_scheduler.py index f86727327..3e5c32b42 100644 --- a/tests/test_database_backup_scheduler.py +++ b/tests/test_database_backup_scheduler.py @@ -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: diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index fc61e4b42..954a8d819 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -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"), ( diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index a5438c7d0..7921facf0 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -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 diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py index e4abc6a5e..754c13415 100644 --- a/tests/test_manual_transfer_history.py +++ b/tests/test_manual_transfer_history.py @@ -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() diff --git a/tests/test_mediavault_module.py b/tests/test_mediavault_module.py new file mode 100644 index 000000000..7f0c86f05 --- /dev/null +++ b/tests/test_mediavault_module.py @@ -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 diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index e8b9534ad..d25d1dbc4 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -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"] diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index 2f872e738..624330c08 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -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" diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index e621a6154..26bc48c6a 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -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() diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index afa223d00..825cc58e6 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -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)] diff --git a/tests/test_transfer_execution_runner.py b/tests/test_transfer_execution_runner.py index dea0d89d9..9eb4515b4 100644 --- a/tests/test_transfer_execution_runner.py +++ b/tests/test_transfer_execution_runner.py @@ -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