mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-08 14:43:34 +08:00
feat(plugin): sync default markets from wiki at release
This commit is contained in:
@@ -71,6 +71,7 @@ test_*
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
rust/**/target/
|
||||
|
||||
27
.github/workflows/beta.yml
vendored
27
.github/workflows/beta.yml
vendored
@@ -2,6 +2,10 @@ name: MoviePilot Builder Beta
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -16,6 +20,25 @@ jobs:
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Wiki Plugin Market
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jxxghp/MoviePilot-Wiki
|
||||
ref: main
|
||||
path: .build/moviepilot-wiki
|
||||
sparse-checkout: plugin.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Plugin Market Default
|
||||
id: plugin_market
|
||||
run: |
|
||||
python3 -m scripts.generate_plugin_market_default \
|
||||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -55,6 +78,8 @@ jobs:
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
57
.github/workflows/build.yml
vendored
57
.github/workflows/build.yml
vendored
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'version.py'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,6 +27,39 @@ jobs:
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Wiki Plugin Market
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jxxghp/MoviePilot-Wiki
|
||||
ref: main
|
||||
path: .build/moviepilot-wiki
|
||||
sparse-checkout: plugin.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Plugin Market Default
|
||||
id: plugin_market
|
||||
run: |
|
||||
python3 -m scripts.generate_plugin_market_default \
|
||||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release Snapshot
|
||||
id: release_snapshot
|
||||
env:
|
||||
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add app/core/config.py
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
|
||||
fi
|
||||
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
@@ -65,7 +102,10 @@ jobs:
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
@@ -78,9 +118,9 @@ jobs:
|
||||
|
||||
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" HEAD)
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" "${SOURCE_COMMIT}")
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" ${PREVIOUS_TAG}..HEAD)
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
|
||||
fi
|
||||
|
||||
# 分类收集 commit 消息(使用关联数组去重)
|
||||
@@ -188,6 +228,17 @@ jobs:
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish Release Tag
|
||||
env:
|
||||
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
|
||||
run: |
|
||||
tag_name="v${{ env.app_version }}"
|
||||
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
|
||||
git tag -d "$tag_name"
|
||||
fi
|
||||
git tag "$tag_name" "$RELEASE_COMMIT"
|
||||
git push origin "refs/tags/${tag_name}"
|
||||
|
||||
- name: Generate Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -36,6 +36,12 @@ from app.db.user_oper import (
|
||||
)
|
||||
from app.helper.image import ImageHelper
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.market import (
|
||||
PLUGIN_MARKET_WIKI_URL,
|
||||
extract_plugin_market_repos_from_wiki,
|
||||
merge_plugin_market_repos,
|
||||
split_plugin_market_repo_urls,
|
||||
)
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
@@ -70,13 +76,6 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
|
||||
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
|
||||
_LOG_DOWNLOAD_LIMIT = 10
|
||||
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_PLUGIN_MARKET_WIKI_START = "<!-- plugin-market-repos:start -->"
|
||||
_PLUGIN_MARKET_WIKI_END = "<!-- plugin-market-repos:end -->"
|
||||
_PLUGIN_MARKET_WIKI_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
|
||||
_PLUGIN_MARKET_REPO_PATTERN = re.compile(
|
||||
r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||
@@ -120,25 +119,6 @@ def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""
|
||||
规范化插件仓库地址,便于跨来源合并去重。
|
||||
"""
|
||||
repo_url = (repo_url or "").strip().rstrip("/")
|
||||
if not repo_url:
|
||||
return None
|
||||
repo_url = repo_url.removesuffix(".git")
|
||||
parsed_url = urlparse(repo_url)
|
||||
if parsed_url.scheme not in {"http", "https"}:
|
||||
return None
|
||||
if (parsed_url.hostname or "").lower() != "github.com":
|
||||
return None
|
||||
paths = [item for item in parsed_url.path.split("/") if item]
|
||||
if len(paths) < 2:
|
||||
return None
|
||||
return f"https://github.com/{paths[0]}/{paths[1]}"
|
||||
|
||||
|
||||
def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
|
||||
"""
|
||||
校验插件市场 Wiki 地址是否属于固定文档源。
|
||||
@@ -156,55 +136,6 @@ def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _split_plugin_market_repo_urls(value: Optional[str]) -> list[str]:
|
||||
"""
|
||||
拆分插件市场仓库配置并保持原有顺序去重。
|
||||
"""
|
||||
repos: list[str] = []
|
||||
seen_repos = set()
|
||||
for item in re.split(r"[\n,,]+", value or ""):
|
||||
normalized_repo = _normalize_plugin_market_repo_url(item)
|
||||
if not normalized_repo or normalized_repo.lower() in seen_repos:
|
||||
continue
|
||||
repos.append(normalized_repo)
|
||||
seen_repos.add(normalized_repo.lower())
|
||||
return repos
|
||||
|
||||
|
||||
def _extract_plugin_market_repos_from_wiki(markdown: str) -> list[str]:
|
||||
"""
|
||||
从 Wiki 插件文档中提取插件仓库地址。
|
||||
"""
|
||||
content = markdown or ""
|
||||
if _PLUGIN_MARKET_WIKI_START in content and _PLUGIN_MARKET_WIKI_END in content:
|
||||
content = content.split(_PLUGIN_MARKET_WIKI_START, 1)[1].split(_PLUGIN_MARKET_WIKI_END, 1)[0]
|
||||
|
||||
repos: list[str] = []
|
||||
seen_repos = set()
|
||||
for item in _PLUGIN_MARKET_REPO_PATTERN.findall(content):
|
||||
normalized_repo = _normalize_plugin_market_repo_url(item)
|
||||
if not normalized_repo or normalized_repo.lower() in seen_repos:
|
||||
continue
|
||||
repos.append(normalized_repo)
|
||||
seen_repos.add(normalized_repo.lower())
|
||||
return repos
|
||||
|
||||
|
||||
def _merge_plugin_market_repos(local_repos: list[str], wiki_repos: list[str]) -> list[str]:
|
||||
"""
|
||||
合并本地与 Wiki 插件仓库地址,保留本地顺序并追加 Wiki 新地址。
|
||||
"""
|
||||
merged_repos: list[str] = []
|
||||
seen_repos = set()
|
||||
for repo in local_repos + wiki_repos:
|
||||
normalized_repo = _normalize_plugin_market_repo_url(repo)
|
||||
if not normalized_repo or normalized_repo.lower() in seen_repos:
|
||||
continue
|
||||
merged_repos.append(normalized_repo)
|
||||
seen_repos.add(normalized_repo.lower())
|
||||
return merged_repos
|
||||
|
||||
|
||||
def _match_nettest_prefix(url: str, prefix: str) -> bool:
|
||||
"""
|
||||
判断目标URL是否仍然落在允许的协议、主机、端口和路径前缀内。
|
||||
@@ -889,7 +820,7 @@ async def sync_plugin_market_from_wiki(
|
||||
"""
|
||||
从 Wiki 插件文档同步插件市场仓库地址。
|
||||
"""
|
||||
wiki_url = (request.wiki_url if request else None) or _PLUGIN_MARKET_WIKI_URL
|
||||
wiki_url = (request.wiki_url if request else None) or PLUGIN_MARKET_WIKI_URL
|
||||
wiki_url = wiki_url.strip()
|
||||
if not _is_allowed_plugin_market_wiki_url(wiki_url):
|
||||
return schemas.Response(success=False, message="不支持的 Wiki 同步地址")
|
||||
@@ -909,14 +840,14 @@ async def sync_plugin_market_from_wiki(
|
||||
message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}",
|
||||
)
|
||||
|
||||
wiki_repos = _extract_plugin_market_repos_from_wiki(res.text)
|
||||
wiki_repos = extract_plugin_market_repos_from_wiki(res.text)
|
||||
if not wiki_repos:
|
||||
return schemas.Response(success=False, message="未在 Wiki 中识别到插件仓库地址")
|
||||
|
||||
local_repos = _split_plugin_market_repo_urls(settings.PLUGIN_MARKET)
|
||||
local_repos = split_plugin_market_repo_urls(settings.PLUGIN_MARKET)
|
||||
local_repo_keys = {repo.lower() for repo in local_repos}
|
||||
added_count = len([repo for repo in wiki_repos if repo.lower() not in local_repo_keys])
|
||||
merged_repos = _merge_plugin_market_repos(local_repos, wiki_repos)
|
||||
merged_repos = merge_plugin_market_repos(local_repos, wiki_repos)
|
||||
merged_value = ",".join(merged_repos)
|
||||
|
||||
success, message = settings.update_setting("PLUGIN_MARKET", merged_value)
|
||||
|
||||
@@ -430,26 +430,7 @@ class ConfigModel(BaseModel):
|
||||
# ==================== 插件配置 ====================
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = (
|
||||
"https://github.com/jxxghp/MoviePilot-Plugins,"
|
||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||
"https://github.com/honue/MoviePilot-Plugins,"
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
||||
"https://github.com/DDSRem-Dev/MoviePilot-Plugins,"
|
||||
"https://github.com/madrays/MoviePilot-Plugins,"
|
||||
"https://github.com/justzerock/MoviePilot-Plugins,"
|
||||
"https://github.com/KoWming/MoviePilot-Plugins,"
|
||||
"https://github.com/wikrin/MoviePilot-Plugins,"
|
||||
"https://github.com/HankunYu/MoviePilot-Plugins,"
|
||||
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
|
||||
"https://github.com/Aqr-K/MoviePilot-Plugins,"
|
||||
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/gxterry/MoviePilot-Plugins,"
|
||||
"https://github.com/DzAvril/MoviePilot-Plugins,"
|
||||
"https://github.com/mrtian2016/MoviePilot-Plugins,"
|
||||
"https://github.com/Hqyel/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/xijin285/MoviePilot-Plugins,"
|
||||
"https://github.com/Seed680/MoviePilot-Plugins,"
|
||||
"https://github.com/imaliang/MoviePilot-Plugins"
|
||||
"https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
)
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
|
||||
98
app/helper/market.py
Normal file
98
app/helper/market.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
PLUGIN_MARKET_WIKI_START = "<!-- plugin-market-repos:start -->"
|
||||
PLUGIN_MARKET_WIKI_END = "<!-- plugin-market-repos:end -->"
|
||||
PLUGIN_MARKET_WIKI_URL = (
|
||||
"https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
|
||||
)
|
||||
PLUGIN_MARKET_REPO_PATTERN = re.compile(
|
||||
r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""
|
||||
规范化插件仓库地址,便于跨来源合并去重。
|
||||
"""
|
||||
repo_url = (repo_url or "").strip().rstrip("/")
|
||||
if not repo_url:
|
||||
return None
|
||||
repo_url = repo_url.removesuffix(".git")
|
||||
parsed_url = urlparse(repo_url)
|
||||
if parsed_url.scheme not in {"http", "https"}:
|
||||
return None
|
||||
if (parsed_url.hostname or "").lower() != "github.com":
|
||||
return None
|
||||
paths = [item for item in parsed_url.path.split("/") if item]
|
||||
if len(paths) < 2:
|
||||
return None
|
||||
return f"https://github.com/{paths[0]}/{paths[1]}"
|
||||
|
||||
|
||||
def split_plugin_market_repo_urls(value: Optional[str]) -> list[str]:
|
||||
"""
|
||||
拆分插件市场仓库配置并保持原有顺序去重。
|
||||
"""
|
||||
repos: list[str] = []
|
||||
seen_repos = set()
|
||||
for item in re.split(r"[\n,,]+", value or ""):
|
||||
normalized_repo = normalize_plugin_market_repo_url(item)
|
||||
if not normalized_repo or normalized_repo.lower() in seen_repos:
|
||||
continue
|
||||
repos.append(normalized_repo)
|
||||
seen_repos.add(normalized_repo.lower())
|
||||
return repos
|
||||
|
||||
|
||||
def extract_plugin_market_repos_from_wiki(
|
||||
markdown: str, require_markers: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
从 Wiki 插件文档中提取插件仓库地址。
|
||||
|
||||
:param markdown: Wiki 插件文档 Markdown 内容
|
||||
:param require_markers: 是否要求文档包含唯一且有序的清单边界标记
|
||||
:return: 规范化并按文档顺序去重的插件仓库地址
|
||||
"""
|
||||
content = markdown or ""
|
||||
start_count = content.count(PLUGIN_MARKET_WIKI_START)
|
||||
end_count = content.count(PLUGIN_MARKET_WIKI_END)
|
||||
start_index = content.find(PLUGIN_MARKET_WIKI_START)
|
||||
end_index = content.find(PLUGIN_MARKET_WIKI_END)
|
||||
if start_count == 1 and end_count == 1 and start_index < end_index:
|
||||
content = content[
|
||||
start_index + len(PLUGIN_MARKET_WIKI_START):end_index
|
||||
]
|
||||
elif require_markers:
|
||||
raise ValueError("Wiki 插件仓库清单必须包含唯一且有序的开始和结束标记")
|
||||
|
||||
repos: list[str] = []
|
||||
seen_repos = set()
|
||||
for item in PLUGIN_MARKET_REPO_PATTERN.findall(content):
|
||||
normalized_repo = normalize_plugin_market_repo_url(item)
|
||||
if not normalized_repo or normalized_repo.lower() in seen_repos:
|
||||
continue
|
||||
repos.append(normalized_repo)
|
||||
seen_repos.add(normalized_repo.lower())
|
||||
return repos
|
||||
|
||||
|
||||
def merge_plugin_market_repos(
|
||||
local_repos: list[str], wiki_repos: list[str]
|
||||
) -> list[str]:
|
||||
"""
|
||||
合并本地与 Wiki 插件仓库地址,保留本地顺序并追加 Wiki 新地址。
|
||||
"""
|
||||
merged_repos: list[str] = []
|
||||
seen_repos = set()
|
||||
for repo in local_repos + wiki_repos:
|
||||
normalized_repo = normalize_plugin_market_repo_url(repo)
|
||||
if not normalized_repo or normalized_repo.lower() in seen_repos:
|
||||
continue
|
||||
merged_repos.append(normalized_repo)
|
||||
seen_repos.add(normalized_repo.lower())
|
||||
return merged_repos
|
||||
@@ -110,6 +110,25 @@ chmod +x scripts/start-local.sh
|
||||
|
||||
如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。
|
||||
|
||||
### 4.1 GitHub 发版时生成插件市场默认值
|
||||
|
||||
源码分支中的 `ConfigModel.PLUGIN_MARKET` 只保留官方插件仓库作为离线兜底。GitHub 的正式版与 Beta 镜像构建会检出 `MoviePilot-Wiki` 的 `main` 分支,并由 `scripts/generate_plugin_market_default.py` 读取 `plugin.md` 中 `plugin-market-repos:start/end` 标记区域,将规范化、去重后的公开仓库清单写入构建工作区。
|
||||
|
||||
生成过程遵循以下约束:
|
||||
|
||||
- 标记必须唯一、顺序正确,清单不能为空且必须包含 `jxxghp/MoviePilot-Plugins`;不满足时直接终止构建。
|
||||
- 生成脚本只替换 `ConfigModel` 中的 `PLUGIN_MARKET` 默认值,不写入运行时环境变量,因此用户仍可通过系统环境变量或 `/config/app.env` 覆盖。
|
||||
- 正式版工作流会创建仅由 Release Tag 引用的本地快照提交,Docker 镜像和 Tag 源码归档均来自该快照;Actions 不会将生成结果回写到 `v2` 分支。
|
||||
- Release Tag 快照提交信息和镜像标签会记录本次使用的 MoviePilot Wiki Commit,便于追溯清单来源。
|
||||
|
||||
本地验证生成结果时,先激活项目虚拟环境,再执行:
|
||||
|
||||
```bash
|
||||
python -m scripts.generate_plugin_market_default \
|
||||
--wiki-file /path/to/MoviePilot-Wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
```
|
||||
|
||||
### 5. 运行安全检查
|
||||
|
||||
我们使用 `safety` 工具检查依赖项中是否存在已知安全漏洞。更新运行时依赖后,应至少检查运行时入口;更新开发测试依赖时,也应覆盖开发入口。
|
||||
|
||||
@@ -285,4 +285,20 @@ bash scripts/collect-site-adapter.sh
|
||||
- Never put a Cookie or other credential in command arguments or shell history.
|
||||
- Feature Request attachments are public. Review all four files in the generated ZIP before attaching it, and never attach raw HTML, HAR, or browser network archives.
|
||||
|
||||
*Last Updated: 2026-07-12*
|
||||
---
|
||||
|
||||
## Plugin Market Release Default
|
||||
|
||||
```bash
|
||||
# Run after activating the project virtual environment
|
||||
python -m scripts.generate_plugin_market_default \
|
||||
--wiki-file /path/to/MoviePilot-Wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- The Wiki document must contain exactly one `plugin-market-repos:start/end` marker pair.
|
||||
- The marked list must be nonempty and include `jxxghp/MoviePilot-Plugins`.
|
||||
- This command rewrites only `ConfigModel.PLUGIN_MARKET`; inspect the resulting diff before committing or packaging.
|
||||
|
||||
*Last Updated: 2026-08-06*
|
||||
|
||||
84
scripts/generate_plugin_market_default.py
Normal file
84
scripts/generate_plugin_market_default.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
根据 MoviePilot Wiki 清单生成发版快照中的插件市场默认值。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.helper.market import extract_plugin_market_repos_from_wiki
|
||||
|
||||
|
||||
OFFICIAL_PLUGIN_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
|
||||
|
||||
def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="生成插件市场发版默认值")
|
||||
parser.add_argument("--wiki-file", type=Path, required=True)
|
||||
parser.add_argument("--config-file", type=Path, required=True)
|
||||
return parser.parse_args(args)
|
||||
|
||||
|
||||
def _find_plugin_market_assignment(source: str) -> tuple[int, int, str]:
|
||||
tree = ast.parse(source)
|
||||
source_lines = source.splitlines(keepends=True)
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or node.name != "ConfigModel":
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, ast.AnnAssign):
|
||||
continue
|
||||
if not isinstance(item.target, ast.Name):
|
||||
continue
|
||||
if item.target.id != "PLUGIN_MARKET" or item.end_lineno is None:
|
||||
continue
|
||||
start = item.lineno - 1
|
||||
indent_size = len(source_lines[start]) - len(source_lines[start].lstrip())
|
||||
indent = source_lines[start][:indent_size]
|
||||
return start, item.end_lineno, indent
|
||||
raise ValueError("未在 ConfigModel 中找到 PLUGIN_MARKET 默认值")
|
||||
|
||||
|
||||
def _format_plugin_market_assignment(repos: list[str], indent: str) -> str:
|
||||
lines = [f"{indent}PLUGIN_MARKET: str = (\n"]
|
||||
for index, repo in enumerate(repos):
|
||||
suffix = "," if index < len(repos) - 1 else ""
|
||||
lines.append(f'{indent} "{repo}{suffix}"\n')
|
||||
lines.append(f"{indent})\n")
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def _generate_plugin_market_default(wiki_file: Path, config_file: Path) -> list[str]:
|
||||
markdown = wiki_file.read_text(encoding="utf-8")
|
||||
repos = extract_plugin_market_repos_from_wiki(markdown, require_markers=True)
|
||||
if not repos:
|
||||
raise ValueError("Wiki 插件仓库清单为空")
|
||||
if OFFICIAL_PLUGIN_MARKET not in repos:
|
||||
raise ValueError("Wiki 插件仓库清单缺少 MoviePilot 官方插件仓库")
|
||||
|
||||
source = config_file.read_text(encoding="utf-8")
|
||||
start, end, indent = _find_plugin_market_assignment(source)
|
||||
source_lines = source.splitlines(keepends=True)
|
||||
replacement = _format_plugin_market_assignment(repos, indent)
|
||||
updated_source = (
|
||||
"".join(source_lines[:start])
|
||||
+ replacement
|
||||
+ "".join(source_lines[end:])
|
||||
)
|
||||
config_file.write_text(updated_source, encoding="utf-8")
|
||||
return repos
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> int:
|
||||
"""
|
||||
读取 Wiki 清单并更新指定配置文件中的插件市场默认值。
|
||||
"""
|
||||
options = _parse_args(args)
|
||||
repos = _generate_plugin_market_default(options.wiki_file, options.config_file)
|
||||
print(f"已生成 PLUGIN_MARKET 默认值,共 {len(repos)} 个仓库")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
129
tests/test_plugin_market_default.py
Normal file
129
tests/test_plugin_market_default.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.helper.market import extract_plugin_market_repos_from_wiki
|
||||
from scripts.generate_plugin_market_default import (
|
||||
OFFICIAL_PLUGIN_MARKET,
|
||||
_generate_plugin_market_default,
|
||||
)
|
||||
|
||||
|
||||
def _read_plugin_market_default(config_file: Path) -> str:
|
||||
tree = ast.parse(config_file.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or node.name != "ConfigModel":
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, ast.AnnAssign):
|
||||
continue
|
||||
if isinstance(item.target, ast.Name) and item.target.id == "PLUGIN_MARKET":
|
||||
return ast.literal_eval(item.value)
|
||||
raise AssertionError("未找到 PLUGIN_MARKET 默认值")
|
||||
|
||||
|
||||
def test_extract_plugin_market_repos_uses_marked_section_and_deduplicates() -> None:
|
||||
"""
|
||||
Wiki 清单解析只读取标记区域,并规范化、去重仓库地址。
|
||||
"""
|
||||
markdown = """
|
||||
- https://github.com/outside/ignored
|
||||
<!-- plugin-market-repos:start -->
|
||||
- https://github.com/jxxghp/MoviePilot-Plugins/
|
||||
- https://github.com/demo/Market.git
|
||||
- https://github.com/demo/Market
|
||||
<!-- plugin-market-repos:end -->
|
||||
- https://github.com/outside/ignored-again
|
||||
"""
|
||||
|
||||
assert extract_plugin_market_repos_from_wiki(
|
||||
markdown, require_markers=True
|
||||
) == [
|
||||
OFFICIAL_PLUGIN_MARKET,
|
||||
"https://github.com/demo/Market",
|
||||
]
|
||||
|
||||
|
||||
def test_extract_plugin_market_repos_requires_unique_markers_for_build() -> None:
|
||||
"""
|
||||
构建模式拒绝缺失或重复边界标记的 Wiki 文档。
|
||||
"""
|
||||
with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"):
|
||||
extract_plugin_market_repos_from_wiki(
|
||||
f"- {OFFICIAL_PLUGIN_MARKET}", require_markers=True
|
||||
)
|
||||
|
||||
markdown = f"""
|
||||
<!-- plugin-market-repos:start -->
|
||||
<!-- plugin-market-repos:start -->
|
||||
- {OFFICIAL_PLUGIN_MARKET}
|
||||
<!-- plugin-market-repos:end -->
|
||||
"""
|
||||
with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"):
|
||||
extract_plugin_market_repos_from_wiki(markdown, require_markers=True)
|
||||
|
||||
|
||||
def test_generate_plugin_market_default_updates_assignment_idempotently(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
生成脚本只替换 ConfigModel 默认值,并保持重复执行结果一致。
|
||||
"""
|
||||
wiki_file = tmp_path / "plugin.md"
|
||||
wiki_file.write_text(
|
||||
f"""
|
||||
<!-- plugin-market-repos:start -->
|
||||
- {OFFICIAL_PLUGIN_MARKET}
|
||||
- https://github.com/demo/MoviePilot-Plugins
|
||||
<!-- plugin-market-repos:end -->
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_file = tmp_path / "config.py"
|
||||
config_file.write_text(
|
||||
"""class ConfigModel(BaseModel):
|
||||
PLUGIN_MARKET: str = "https://github.com/old/Market"
|
||||
OTHER_SETTING: bool = True
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
repos = _generate_plugin_market_default(wiki_file, config_file)
|
||||
first_result = config_file.read_text(encoding="utf-8")
|
||||
_generate_plugin_market_default(wiki_file, config_file)
|
||||
|
||||
assert repos == [
|
||||
OFFICIAL_PLUGIN_MARKET,
|
||||
"https://github.com/demo/MoviePilot-Plugins",
|
||||
]
|
||||
assert _read_plugin_market_default(config_file) == ",".join(repos)
|
||||
assert "OTHER_SETTING: bool = True" in first_result
|
||||
assert config_file.read_text(encoding="utf-8") == first_result
|
||||
|
||||
|
||||
def test_generate_plugin_market_default_requires_official_repo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
发版默认清单缺少官方仓库时终止生成。
|
||||
"""
|
||||
wiki_file = tmp_path / "plugin.md"
|
||||
wiki_file.write_text(
|
||||
"""
|
||||
<!-- plugin-market-repos:start -->
|
||||
- https://github.com/demo/MoviePilot-Plugins
|
||||
<!-- plugin-market-repos:end -->
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_file = tmp_path / "config.py"
|
||||
config_file.write_text(
|
||||
"""class ConfigModel(BaseModel):
|
||||
PLUGIN_MARKET: str = "https://github.com/old/Market"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="缺少 MoviePilot 官方插件仓库"):
|
||||
_generate_plugin_market_default(wiki_file, config_file)
|
||||
Reference in New Issue
Block a user