Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ set -eu
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"

python3 .github/scripts/check_plugin_versions.py package.json package.v2.json
python3 .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json
114 changes: 103 additions & 11 deletions .github/scripts/check_plugin_versions.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""校验插件市场版本与插件源码版本一致
"""校验可发布到 MoviePilot V3 的插件市场版本与源码版本一致

Release workflow 依赖 package.json/package.v2.json 生成 tag 和资产名;
若插件目录内的 plugin_version 不同步,运行时会继续展示旧版本。这里在打包前失败退出,
避免发布资产与插件自报版本不一致。
Release workflow 同时处理 V1、V2 兼容实现和 V3 专用实现。旧索引中显式声明
``v3: false`` 的实现不会再发布;V3 专用实现还必须满足迁移版本和元数据合同。
"""

from __future__ import annotations

import ast
import json
import re
import sys
import warnings
from pathlib import Path
Expand All @@ -18,28 +18,100 @@


def _load_package(path: Path) -> dict:
"""读取 package 文件;文件不存在时返回空字典,便于同一脚本兼容 v1/v2。"""
"""读取 package 文件;文件不存在时返回空字典。"""
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as file_obj:
return json.load(file_obj)


def _plugin_dir(package_file: Path, plugin_id: str) -> Path | None:
"""按 package 文件定位对应插件目录,避免 v1/v2 同名插件互相串线。"""
"""按 package 文件定位对应插件目录,避免不同代际同名插件互相串线。"""
plugin_id_lc = plugin_id.lower()
base_dir = Path("plugins.v2") if package_file.name == "package.v2.json" else Path("plugins")
base_dirs = {
"package.json": Path("plugins"),
"package.v2.json": Path("plugins.v2"),
"package.v3.json": Path("plugins.v3"),
}
base_dir = base_dirs.get(package_file.name)
if base_dir is None:
return None
candidate = package_file.parent / base_dir / plugin_id_lc
return candidate if candidate.is_dir() else None


def _expected_plugin_dir(package_file: Path, plugin_id: str) -> Path:
"""返回 package 条目对应的插件目录,用于缺失目录时输出可定位错误。"""
plugin_id_lc = plugin_id.lower()
base_dir = Path("plugins.v2") if package_file.name == "package.v2.json" else Path("plugins")
base_dirs = {
"package.json": Path("plugins"),
"package.v2.json": Path("plugins.v2"),
"package.v3.json": Path("plugins.v3"),
}
base_dir = base_dirs.get(package_file.name, Path("plugins.v3"))
return package_file.parent / base_dir / plugin_id_lc


def _version_parts(value: object) -> tuple[int, ...] | None:
"""解析只含数字段的插件版本。"""
match = re.fullmatch(r"\d+(?:\.\d+)*", str(value or "").strip())
if not match:
return None
return tuple(int(part) for part in match.group().split("."))


def _normalized_version(value: object, width: int = 4) -> tuple[int, ...] | None:
"""补齐版本段用于大小比较。"""
parts = _version_parts(value)
if parts is None:
return None
return parts + (0,) * max(0, width - len(parts))


def _check_v3_metadata(path: Path, plugin_id: str, metadata: dict) -> list[str]:
"""校验 V3 专用实现的系统版本、history 与旧版本迁移规则。"""
errors: list[str] = []
version = str(metadata.get("version") or "").strip()
version_parts = _version_parts(version)
if version_parts is None or len(version_parts) < 2:
errors.append(f"{path}: {plugin_id} V3 版本必须至少包含主版本和小版本:{version}")

if metadata.get("system_version") != ">=3.0.0":
errors.append(f'{path}: {plugin_id} system_version 必须为 ">=3.0.0"')

history = metadata.get("history")
expected_history_key = f"v{version}"
if not isinstance(history, dict) or list(history) != [expected_history_key]:
errors.append(f"{path}: {plugin_id} history 必须只保留当前版本 {expected_history_key}")
else:
expected_changelog = f'MoviePilot V3 版本{metadata.get("name", "")}插件'
if history[expected_history_key] != expected_changelog:
errors.append(f"{path}: {plugin_id} history 文案必须为 {expected_changelog}")

legacy_path = path.with_name("package.v2.json")
legacy_metadata = _load_package(legacy_path).get(plugin_id)
if not isinstance(legacy_metadata, dict):
errors.append(f"{path}: {plugin_id} 在 {legacy_path.name} 中没有对应旧版本条目")
return errors
if legacy_metadata.get("v3") is not False:
errors.append(f"{legacy_path}: {plugin_id} 必须声明 v3=false")

old_version = str(legacy_metadata.get("version") or "").strip()
old_parts = _version_parts(old_version)
normalized_old = _normalized_version(old_version)
normalized_new = _normalized_version(version)
if old_parts is None or normalized_old is None or normalized_new is None or version_parts is None:
errors.append(f"{path}: {plugin_id} 无法比较版本 {old_version} -> {version}")
return errors
if version_parts[0] != old_parts[0]:
errors.append(f"{path}: {plugin_id} V3 不得提升主版本:{old_version} -> {version}")
if version_parts[1] <= (old_parts[1] if len(old_parts) > 1 else 0):
errors.append(f"{path}: {plugin_id} V3 必须提升小版本:{old_version} -> {version}")
if normalized_new <= normalized_old:
errors.append(f"{path}: {plugin_id} V3 版本必须高于旧版本:{old_version} -> {version}")
return errors


def _plugin_version(init_file: Path) -> str | None:
"""从 __init__.py 类级属性中提取 plugin_version 字面量。"""
tree = ast.parse(init_file.read_text(encoding="utf-8"), filename=str(init_file))
Expand All @@ -60,12 +132,26 @@ def _plugin_version(init_file: Path) -> str | None:
return None


def _is_v3_release_entry(path: Path, metadata: dict) -> bool:
"""按主程序索引回退规则判断旧代条目是否仍面向 V3 发布。"""
if metadata.get("release") is not True or metadata.get("v3") is False:
return False
if path.name == "package.v2.json":
return True
if path.name == "package.json":
return metadata.get("v3") is True or metadata.get("v2") is True
return False


def check_package(path: Path) -> list[str]:
"""校验单个 package 文件,返回所有错误文本。"""
"""校验单个 package 文件中仍面向 V3 发布的条目。"""
errors: list[str] = []
package = _load_package(path)
for plugin_id, meta in package.items():
if not isinstance(meta, dict) or meta.get("release") is not True:
if not isinstance(meta, dict):
errors.append(f"{path}: {plugin_id} 元数据必须为对象")
continue
if path.name != "package.v3.json" and not _is_v3_release_entry(path, meta):
continue
package_version = str(meta.get("version") or "").strip()
plugin_dir = _plugin_dir(path, plugin_id)
Expand All @@ -85,12 +171,18 @@ def check_package(path: Path) -> list[str]:
f"{path}: {plugin_id} 版本不一致,package={package_version}, "
f"plugin_version={source_version} ({init_file})"
)
if path.name == "package.v3.json":
errors.extend(_check_v3_metadata(path, plugin_id, meta))
return errors


def main() -> int:
"""命令入口:所有 package 均通过时返回 0,否则打印错误并返回 1。"""
package_files = [Path(arg) for arg in sys.argv[1:]] or [Path("package.json"), Path("package.v2.json")]
package_files = [Path(arg) for arg in sys.argv[1:]] or [
Path("package.json"),
Path("package.v2.json"),
Path("package.v3.json"),
]
errors: list[str] = []
for package_file in package_files:
errors.extend(check_package(package_file))
Expand Down
3 changes: 3 additions & 0 deletions .github/scripts/select_plugin_release_dir.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ case "$(basename "$package_file")" in
package.v2.json)
plugin_dir="plugins.v2/${plugin_id}"
;;
package.v3.json)
plugin_dir="plugins.v3/${plugin_id}"
;;
*)
echo "Unsupported package file: ${package_file}" >&2
exit 2
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/frontend-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ on:
- main
paths:
- '.github/workflows/frontend-test.yml'
- 'plugins.v2/subscribeassistantenhanced/frontend/**'
- 'tests/v2/subscribeassistantenhanced/frontend/**'
- 'plugins.v3/subscribeassistantenhanced/frontend/**'
- 'tests/v3/subscribeassistantenhanced/frontend/**'

permissions:
contents: read
Expand All @@ -22,7 +22,7 @@ jobs:
timeout-minutes: 20
defaults:
run:
working-directory: plugins.v2/subscribeassistantenhanced/frontend
working-directory: plugins.v3/subscribeassistantenhanced/frontend
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -32,7 +32,7 @@ jobs:
with:
node-version: '24'
cache: yarn
cache-dependency-path: plugins.v2/subscribeassistantenhanced/frontend/yarn.lock
cache-dependency-path: plugins.v3/subscribeassistantenhanced/frontend/yarn.lock

- name: Install dependencies
run: yarn --frozen-lockfile
Expand All @@ -45,7 +45,7 @@ jobs:
timeout-minutes: 20
defaults:
run:
working-directory: plugins.v2/subscribeassistantenhanced/frontend
working-directory: plugins.v3/subscribeassistantenhanced/frontend
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -55,7 +55,7 @@ jobs:
with:
node-version: '24'
cache: yarn
cache-dependency-path: plugins.v2/subscribeassistantenhanced/frontend/yarn.lock
cache-dependency-path: plugins.v3/subscribeassistantenhanced/frontend/yarn.lock

- name: Install dependencies
run: yarn --frozen-lockfile
Expand All @@ -68,7 +68,7 @@ jobs:
timeout-minutes: 20
defaults:
run:
working-directory: plugins.v2/subscribeassistantenhanced/frontend
working-directory: plugins.v3/subscribeassistantenhanced/frontend
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -78,7 +78,7 @@ jobs:
with:
node-version: '24'
cache: yarn
cache-dependency-path: plugins.v2/subscribeassistantenhanced/frontend/yarn.lock
cache-dependency-path: plugins.v3/subscribeassistantenhanced/frontend/yarn.lock

- name: Install dependencies
run: yarn --frozen-lockfile
Expand Down
40 changes: 37 additions & 3 deletions .github/workflows/plugin-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,47 @@ jobs:
fetch-depth: 0

- name: Check plugin versions
run: python .github/scripts/check_plugin_versions.py package.json package.v2.json
run: python .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json

- name: Check new plugin tests
run: |
git fetch origin main:refs/remotes/origin/main --depth=1
python scripts/check_new_plugin_tests.py --base-ref origin/main

plugin-test-gate:
name: Plugin test gate
runs-on: ubuntu-latest
steps:
- name: Checkout plugin repository
uses: actions/checkout@v6
with:
path: MoviePilot-Plugins

- name: Checkout MoviePilot V3 backend
uses: actions/checkout@v6
with:
repository: jxxghp/MoviePilot
ref: v3
path: MoviePilot

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: MoviePilot/requirements-dev.in

- name: Install backend test dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -r MoviePilot/requirements-dev.in

- name: Run V3 plugin tests
env:
MOVIEPILOT_BACKEND_PATH: ${{ github.workspace }}/MoviePilot
working-directory: MoviePilot-Plugins
run: python tests/run.py

plugin-coverage-gate:
name: Plugin coverage gate
runs-on: ubuntu-latest
Expand Down Expand Up @@ -60,7 +94,7 @@ jobs:
}
for item in config.get("coverage", []):
generation = item["generation"]
base = "plugins.v2" if generation == "v2" else "plugins"
base = f"plugins.{generation}"
plugin = item["plugin"]
paths.add(f"{base}/{plugin}/")
paths.add(f"tests/{generation}/{plugin}/")
Expand Down Expand Up @@ -89,7 +123,7 @@ jobs:
uses: actions/checkout@v6
with:
repository: jxxghp/MoviePilot
ref: v2
ref: v3
path: MoviePilot

- name: Set up Python
Expand Down
28 changes: 23 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
paths:
- 'package.json'
- 'package.v2.json'
- 'package.v3.json'
workflow_dispatch:

permissions:
Expand All @@ -27,7 +28,7 @@ jobs:
sudo apt-get install -y jq zip

- name: Check plugin versions
run: python .github/scripts/check_plugin_versions.py package.json package.v2.json
run: python .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json

- name: Build and release changed plugins
env:
Expand All @@ -44,8 +45,24 @@ jobs:

echo "Processing $pkg_file"

# Only entries explicitly marked release=true are packaged.
mapfile -t entries < <(jq -r 'to_entries | map(select(.value.release == true)) | .[] | "\(.key)|\(.value.version)"' "$pkg_file")
case "$pkg_file" in
package.json)
# Default-index implementations need an explicit V2/V3 opt-in before V3 can load them.
entry_filter='select(.value.release == true and .value.v3 != false and (.value.v3 == true or .value.v2 == true))'
;;
package.v2.json)
# V3 inherits V2 implementations unless the entry explicitly opts out.
entry_filter='select(.value.release == true and .value.v3 != false)'
;;
package.v3.json)
entry_filter='select(.value.release == true)'
;;
*)
echo "Unsupported package file: $pkg_file" >&2
exit 2
;;
esac
mapfile -t entries < <(jq -r "to_entries | map($entry_filter) | .[] | \"\\(.key)|\\(.value.version)\"" "$pkg_file")

if [ "${#entries[@]}" -eq 0 ]; then
echo "No plugins with release=true in $pkg_file"
Expand All @@ -58,8 +75,8 @@ jobs:
plugin_id_lc="$(echo "$plugin_id" | tr '[:upper:]' '[:lower:]')"

if ! plugin_dir="$(bash .github/scripts/select_plugin_release_dir.sh "$pkg_file" "$plugin_id_lc")"; then
echo "WARN: directory for $plugin_id not found under plugins/ or plugins.v2/, skip."
continue
echo "Missing plugin directory for $plugin_id in $pkg_file" >&2
exit 1
fi

tag="${plugin_id}_v${plugin_version}"
Expand Down Expand Up @@ -119,3 +136,4 @@ jobs:

process_package "package.json"
process_package "package.v2.json"
process_package "package.v3.json"
Loading