diff --git a/.dockerignore b/.dockerignore index f36af573..7787a1ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -71,6 +71,7 @@ test_* # Build artifacts build/ +.build/ dist/ *.egg-info/ rust/**/target/ diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 83065456..bdd5d358 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf5da774..bd833348 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 37418267..69e6d9d0 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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_WIKI_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) diff --git a/app/core/config.py b/app/core/config.py index c23c1085..2c201828 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/helper/market.py b/app/helper/market.py new file mode 100644 index 00000000..6ea5c270 --- /dev/null +++ b/app/helper/market.py @@ -0,0 +1,98 @@ +import re +from typing import Optional +from urllib.parse import urlparse + + +PLUGIN_MARKET_WIKI_START = "" +PLUGIN_MARKET_WIKI_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 diff --git a/docs/development-setup.md b/docs/development-setup.md index 8de8c41e..099dbcb1 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -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` 工具检查依赖项中是否存在已知安全漏洞。更新运行时依赖后,应至少检查运行时入口;更新开发测试依赖时,也应覆盖开发入口。 diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 8f7ae709..16a8287b 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -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* diff --git a/scripts/generate_plugin_market_default.py b/scripts/generate_plugin_market_default.py new file mode 100644 index 00000000..072418f3 --- /dev/null +++ b/scripts/generate_plugin_market_default.py @@ -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()) diff --git a/tests/test_plugin_market_default.py b/tests/test_plugin_market_default.py new file mode 100644 index 00000000..131390fc --- /dev/null +++ b/tests/test_plugin_market_default.py @@ -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 + +- https://github.com/jxxghp/MoviePilot-Plugins/ +- https://github.com/demo/Market.git +- https://github.com/demo/Market + +- 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""" + + +- {OFFICIAL_PLUGIN_MARKET} + +""" + 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""" + +- {OFFICIAL_PLUGIN_MARKET} +- https://github.com/demo/MoviePilot-Plugins + +""", + 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( + """ + +- https://github.com/demo/MoviePilot-Plugins + +""", + 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)