diff --git a/.gitea/issue_template/epic.md b/.gitea/issue_template/epic.md new file mode 100644 index 00000000..bac378d6 --- /dev/null +++ b/.gitea/issue_template/epic.md @@ -0,0 +1,21 @@ +--- +name: Epic +about: 长期目标与 MVP 索引 +title: "[Epic] " +labels: "" +assignees: "" +--- + +## 目标 + +## 非目标 + +## 主要风险 + +## MVP + +- [ ] #编号 + +## 完成条件 + +Epic 只维护目标、风险和子项索引,不复制单元任务证据。 diff --git a/.gitea/issue_template/mvp.md b/.gitea/issue_template/mvp.md new file mode 100644 index 00000000..11ffbe7e --- /dev/null +++ b/.gitea/issue_template/mvp.md @@ -0,0 +1,21 @@ +--- +name: MVP +about: 可独立验收的阶段性交付 +title: "[MVP] " +labels: "" +assignees: "" +--- + +## 目标 + +## 非目标 + +## 单元任务 + +- [ ] #编号 + +## 集成验收 + +## 风险与回退 + +MVP 只维护阶段目标和子任务索引,不复制单元任务全文。 diff --git a/.gitea/issue_template/task.md b/.gitea/issue_template/task.md new file mode 100644 index 00000000..311affcc --- /dev/null +++ b/.gitea/issue_template/task.md @@ -0,0 +1,49 @@ +--- +name: 单元任务 +about: 一个可独立验证和回退的实现任务 +title: "" +labels: "" +assignees: "" +--- + +## 原始需求 + +- 来源与时间: +- 关键原话或脱敏摘要: + +## 目标 + +## 非目标 + +## 已确认方案 + +## 前置工单 + +无;如有请列出编号、是否允许并行及原因。 + +## 影响范围 + +## 风险与安全边界 + +## 回退方式 + +## 验收标准 + +- [ ] 实现符合确认范围 +- [ ] 受影响测试通过 +- [ ] 未验证内容已记录 +- [ ] 文档影响已处理 + +## 验证方法 + +## 设计证据 + +原型、技术设计或无需原型的原因;记录版本、确认人、时间和覆盖范围。 + +## 文档影响 + +选择长期 Wiki 页面,或说明“无长期文档影响”及原因。 + +## 状态 + +待确认 diff --git a/.gitignore b/.gitignore index 05859fd5..4f04979e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ /auto.key /internal/core/auto.crt /internal/core/auto.key +/ip_camera.env +/__pycache__/ +/dev_scripts/__pycache__/ +/tests/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..180f92f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# MediaMTX 定制版开发规则 + +本仓库是 MediaMTX 的长期定制分支。上游代码、官方 `docs/`、测试与发布流程继续有效;DevHarness 只管理 OPC 定制需求、长期维护事实和实施证据。 + +开始工作前阅读当前工单、相关 Wiki 镜像和任务涉及目录的规则。代码发现优先使用 codebase-memory MCP;不可用时再使用 `rg`。 + +## 事实来源 + +- Gitea 单元工单:单次需求、方案、实施、测试和验收。 +- Gitea Wiki:定制版长期有效事实。 +- `docs/maintainer/`:Wiki 的只读镜像,禁止直接编辑。 +- Git:代码和版本绑定资料。 +- MediaMTX 原有 `docs/`:上游产品文档,不由 DevHarness 覆盖。 + +## 永久规则 + +- 不提交摄像头 URL、用户名、密码、Token、Cookie、私钥、个人数据或生产数据。 +- `ip_camera.env` 必须保持本地且被 Git 忽略;日志、测试和工单使用虚构数据。 +- 不覆盖或顺手恢复用户已有的无关改动,包括上游文件的删除。 +- 不执行未经明确授权的发布、删除数据、破坏性迁移或破坏性 Git 操作。 +- 测试结果必须真实,未执行和未覆盖部分必须记录。 +- Control API、认证、权限和公网暴露属于高风险,方案变化时先停止并等待确认。 + +## 上游边界 + +- `origin` 是 `https://git.ilapage.cn/OPC/mediamtx.git`。 +- `upstream` 是 `https://github.com/bluenviron/mediamtx.git`。 +- 当前建设基线记录在 `docs/maintainer/1-project-profile.md`。 +- 定制实现应集中、命名清楚、测试独立,避免无关重构和上游合并冲突。 +- 不用 DevHarness 替换 MediaMTX 原有 `.github/`、Go 测试、Makefile、release 或官方文档体系。 + +## 需求到实施 + +1. 先只读检查代码、工作区、工单和相关长期文档,区分事实与假设。 +2. 给出目标、非目标、方案、影响、风险、回退、验证和文档影响。 +3. 用户确认方案后建立一个可独立测试和回退的 Gitea 单元工单。 +4. 检查工单前置依赖和工作区,只修改工单范围。 +5. 执行与风险相称的测试,把关键结果和未验证内容写回工单。 +6. 只有长期事实变化时更新 Wiki,回读 revision 后运行文档同步。 +7. 完成实现后回写最终差异、测试、提交哈希和文档影响,保持“待验收”。 +8. 只有用户明确验收通过后才能关闭工单。 + +新页面或重大交互必须先提供可审阅原型,确认页面、状态、权限、错误处理和验收范围后再实现。简单文案修改使用最低成本的审阅证据。 + +## 常用命令 + +```powershell +git status --short --branch +python dev_scripts/harness.py check --strict +python dev_scripts/harness.py sync --check +python -m unittest discover -s tests -v +go test ./internal/api ./internal/core +go test ./... +go build ./... +``` + +只运行与当前风险和验收有关的检查;代码修改后必须重新运行受影响测试。 + +## 快捷指令 + +- `只分析`:只读检查并给出方案,不建单、不修改。 +- `建工单`:创建已确认方案的单元工单,不修改代码。 +- `执行工单 #N`:实施、测试、提交并回写证据,停在待验收。 +- `检查工单 #N`:只读核对范围和证据,不自动修复。 +- `同步文档`:从 Wiki 单向更新 `docs/maintainer/`,不反向覆盖 Wiki。 +- `#N 验收通过`:记录明确验收结论并关闭工单。 + +快捷指令不得绕过方案确认、安全边界、工单范围、必要测试或人工验收。 + +## 完成条件 + +- 当前工单验收项完成,必要测试通过。 +- 无关工作区改动未被提交。 +- 长期文档影响已更新 Wiki 并同步,或明确记录无影响。 +- 最终证据已回写工单;用户尚未验收时工单保持开启。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c32fa6ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code 入口 + +@AGENTS.md + +共同规则只修改 `AGENTS.md`。本文件仅作为 Claude Code 的规则入口,不复制第二份规则。 diff --git a/dev_scripts/harness.py b/dev_scripts/harness.py new file mode 100644 index 00000000..3b5c768f --- /dev/null +++ b/dev_scripts/harness.py @@ -0,0 +1,126 @@ +"""Project-specific DevHarness entry point for the MediaMTX customization fork.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from wiki_docs import WikiClient, WikiDocsError, load_config, parse_mirror, sync_all + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = ROOT / "wiki-docs.json" +REQUIRED_FILES = ( + "AGENTS.md", + "CLAUDE.md", + ".gitea/issue_template/epic.md", + ".gitea/issue_template/mvp.md", + ".gitea/issue_template/task.md", + "wiki-docs.json", + "dev_scripts/harness.py", + "dev_scripts/wiki_docs.py", + "tests/test_harness.py", +) +EXPECTED_MAPPINGS = { + "Home": "docs/maintainer/README.md", + "Project-Profile": "docs/maintainer/1-project-profile.md", + "Development-Workflow": "docs/maintainer/2-development-workflow.md", + "Architecture-and-Code-Map": "docs/maintainer/3-architecture-and-code-map.md", + "Product-Requirements-Overview": "docs/maintainer/4-product-requirements-overview.md", + "Local-Development-and-Verification": "docs/maintainer/5-local-development-and-verification.md", + "Common-Changes": "docs/maintainer/6-common-changes.md", + "Troubleshooting": "docs/maintainer/7-troubleshooting.md", +} + + +def check(strict: bool) -> list[str]: + errors: list[str] = [] + for relative in REQUIRED_FILES: + if not (ROOT / relative).is_file(): + errors.append(f"缺少文件:{relative}") + + if "ip_camera.env" not in (ROOT / ".gitignore").read_text(encoding="utf-8"): + errors.append(".gitignore 未忽略 ip_camera.env") + + try: + raw = json.loads(CONFIG.read_text(encoding="utf-8")) + actual = {item["page"]: item["path"] for item in raw.get("mappings", [])} + if actual != EXPECTED_MAPPINGS: + errors.append("wiki-docs.json 的核心映射与项目约定不一致") + except (OSError, ValueError, KeyError, TypeError) as exc: + errors.append(f"无法读取 wiki-docs.json:{exc}") + + agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8") if (ROOT / "AGENTS.md").is_file() else "" + for phrase in ("Gitea 单元工单", "docs/maintainer/", "ip_camera.env", "upstream"): + if phrase not in agents: + errors.append(f"AGENTS.md 缺少项目规则:{phrase}") + + if strict: + for path in EXPECTED_MAPPINGS.values(): + target = ROOT / path + if not target.is_file(): + errors.append(f"缺少 Wiki 镜像:{path}") + continue + try: + metadata, _ = parse_mirror(target.read_text(encoding="utf-8")) + except WikiDocsError as exc: + errors.append(f"Wiki 镜像无效 {path}:{exc}") + continue + if not metadata.get("wiki_revision"): + errors.append(f"Wiki 镜像缺少 revision:{path}") + + return errors + + +def run_check(args: argparse.Namespace) -> int: + errors = check(args.strict) + if errors: + for error in errors: + print(f"错误:{error}") + return 1 + print("MediaMTX DevHarness 检查通过") + return 0 + + +def run_sync(args: argparse.Namespace) -> int: + try: + config = load_config(Path(args.config)) + for message in sync_all(config, WikiClient(config), check=args.check): + print(message) + except WikiDocsError as exc: + print(f"错误:{exc}", file=sys.stderr) + return 1 + if args.verify: + errors = check(True) + if errors: + for error in errors: + print(f"错误:{error}") + return 1 + try: + for message in sync_all(config, WikiClient(config), check=True): + print(message) + except WikiDocsError as exc: + print(f"错误:{exc}", file=sys.stderr) + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="MediaMTX 定制版 DevHarness") + sub = parser.add_subparsers(dest="command", required=True) + check_parser = sub.add_parser("check", help="检查项目 Harness 结构") + check_parser.add_argument("--strict", action="store_true") + check_parser.set_defaults(handler=run_check) + sync_parser = sub.add_parser("sync", help="从 Gitea Wiki 单向同步维护文档") + sync_parser.add_argument("--config", default=str(CONFIG)) + sync_parser.add_argument("--check", action="store_true") + sync_parser.add_argument("--verify", action="store_true") + sync_parser.set_defaults(handler=run_sync) + args = parser.parse_args() + return args.handler(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/wiki_docs.py b/dev_scripts/wiki_docs.py new file mode 100644 index 00000000..6b1008cd --- /dev/null +++ b/dev_scripts/wiki_docs.py @@ -0,0 +1,422 @@ +"""Gitea Wiki 到本地 docs 镜像的共享实现。""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONFIG = ROOT / "wiki-docs.json" +MIRROR_START = "" +MIRROR_END = "" +HEADER_PATTERN = re.compile( + rf"\A{re.escape(MIRROR_START)}\n(?P.*?)\n" + rf"{re.escape(MIRROR_END)}\n\n(?P.*)\Z", + re.DOTALL, +) + + +class WikiDocsError(RuntimeError): + """可供命令行直接展示的 Wiki 文档错误。""" + + +@dataclass(frozen=True) +class Mapping: + page: str + path: str + + +@dataclass(frozen=True) +class Config: + path: Path + gitea_url: str + owner: str + repository: str + mappings: tuple[Mapping, ...] + + +@dataclass(frozen=True) +class WikiPage: + title: str + sub_url: str + text: str + revision: str + html_url: str + + +def _required_string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串") + return value.strip() + + +def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]: + """校验显式页面映射,确保只会写入 docs 下的 Markdown。""" + + if not isinstance(raw_mappings, list) or not raw_mappings: + raise WikiDocsError("配置字段 'mappings' 必须是非空数组") + + mappings: list[Mapping] = [] + pages: set[str] = set() + paths: set[str] = set() + for index, item in enumerate(raw_mappings, start=1): + if not isinstance(item, dict): + raise WikiDocsError(f"第 {index} 个映射必须是对象") + page = _required_string(item, "page") + path = _required_string(item, "path").replace("\\", "/") + pure_path = PurePosixPath(path) + if ( + pure_path.is_absolute() + or ".." in pure_path.parts + or not pure_path.parts + or pure_path.parts[0] != "docs" + or pure_path.suffix.lower() != ".md" + ): + raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}") + if page in pages: + raise WikiDocsError(f"Wiki 页面重复映射:{page}") + if path in paths: + raise WikiDocsError(f"本地路径重复映射:{path}") + pages.add(page) + paths.add(path) + mappings.append(Mapping(page=page, path=path)) + return tuple(mappings) + + +def load_config(path: Path = DEFAULT_CONFIG) -> Config: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1") + configured_url = _required_string(raw, "gitea_url") + gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/") + if gitea_url.endswith("/api/v1"): + gitea_url = gitea_url[: -len("/api/v1")] + return Config( + path=path, + gitea_url=gitea_url, + owner=_required_string(raw, "owner"), + repository=_required_string(raw, "repository"), + mappings=validate_mappings(raw.get("mappings")), + ) + + +class WikiClient: + """只使用标准库访问 Gitea Wiki API。""" + + def __init__(self, config: Config, token: str | None = None) -> None: + self.config = config + self.token = token if token is not None else os.environ.get("GITEA_TOKEN") + + def _request( + self, + method: str, + api_path: str, + *, + payload: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + ) -> Any: + url = f"{self.config.gitea_url}/api/v1{api_path}" + if query: + url = f"{url}?{urlencode(query)}" + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + data = None + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + body = response.read() + except HTTPError as exc: + if self.token and method == "GET" and exc.code in {401, 403, 404}: + # 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于 + # 另一个实例或已失效。只对只读请求安全降级为匿名访问。 + anonymous_headers = {"Accept": "application/json"} + anonymous_request = Request( + url, data=data, headers=anonymous_headers, method=method + ) + try: + with urlopen(anonymous_request, timeout=30) as response: + body = response.read() + except HTTPError as anonymous_exc: + detail = anonymous_exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 " + f"{anonymous_exc.code}: {detail}" + ) from anonymous_exc + except URLError as anonymous_exc: + raise WikiDocsError( + f"无法连接 Gitea:{anonymous_exc.reason}" + ) from anonymous_exc + else: + detail = exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}" + ) from exc + except URLError as exc: + raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc + + def list_pages(self) -> list[dict[str, Any]]: + pages: list[dict[str, Any]] = [] + page_number = 1 + while True: + batch = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/pages", + query={"page": page_number, "limit": 50}, + ) + if not isinstance(batch, list): + raise WikiDocsError("Gitea Wiki 页面列表格式无效") + pages.extend(item for item in batch if isinstance(item, dict)) + if len(batch) < 50: + return pages + page_number += 1 + + def get_page(self, page_name: str) -> WikiPage: + metadata = next( + ( + item + for item in self.list_pages() + if item.get("title") == page_name or item.get("sub_url") == page_name + ), + None, + ) + if metadata is None: + raise WikiDocsError( + f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像" + ) + return self.get_page_from_metadata(metadata, page_name) + + def get_page_from_metadata( + self, metadata: dict[str, Any], page_name: str | None = None + ) -> WikiPage: + """使用页面列表元数据读取正文,避免重复获取完整页面列表。""" + + resolved_name = page_name or _required_string(metadata, "title") + sub_url = _required_string(metadata, "sub_url") + page = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/page/" + f"{quote(sub_url, safe='%')}", + ) + if not isinstance(page, dict): + raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}") + encoded_content = page.get("content_base64") + if not isinstance(encoded_content, str): + raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}") + try: + text = base64.b64decode(encoded_content, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise WikiDocsError( + f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_name}" + ) from exc + last_commit = page.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}") + title = page.get("title") + resolved_title = title if isinstance(title, str) and title else resolved_name + html_url = ( + f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}" + ) + return WikiPage( + title=resolved_title, + sub_url=sub_url, + text=normalize_body(text), + revision=revision, + html_url=html_url, + ) + + def create_page(self, title: str, content: str, message: str) -> WikiPage: + if not self.token: + raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌") + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + self._request( + "POST", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/new", + payload={"title": title, "content_base64": encoded, "message": message}, + ) + return self.get_page(title) + + +def normalize_body(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n" + + +def parse_mirror(text: str) -> tuple[dict[str, str], str]: + match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n")) + if match is None: + raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头") + metadata: dict[str, str] = {} + for line in match.group("metadata").splitlines(): + key, separator, value = line.partition(": ") + if not separator or not key or not value: + raise WikiDocsError(f"无效的镜像元数据行:{line}") + metadata[key] = value + return metadata, normalize_body(match.group("body")) + + +def render_mirror(page: WikiPage, existing: str | None = None) -> str: + synchronized_at: str | None = None + if existing is not None: + try: + metadata, body = parse_mirror(existing) + except WikiDocsError: + pass + else: + if metadata.get("wiki_revision") == page.revision and body == page.text: + synchronized_at = metadata.get("synchronized_at") + if not synchronized_at: + synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace( + "+00:00", "Z" + ) + header = "\n".join( + ( + MIRROR_START, + "generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)", + f"wiki_page: {page.title}", + f"wiki_url: {page.html_url}", + f"wiki_revision: {page.revision}", + f"synchronized_at: {synchronized_at}", + MIRROR_END, + ) + ) + return f"{header}\n\n{page.text}" + + +def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]: + return dirty_paths([mapping.path for mapping in config.mappings], root) + + +def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]: + """返回指定路径中已有、修改或未跟踪的工作区条目。""" + + if not paths: + return [] + result = subprocess.run( + ["git", "status", "--porcelain", "--", *paths], + cwd=root, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def _write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + os.replace(temporary_name, path) + except BaseException: + Path(temporary_name).unlink(missing_ok=True) + raise + + +def write_mirror(path: Path, page: WikiPage) -> bool: + """写入一份 Wiki 镜像;内容无变化时返回 False。""" + + existing = path.read_text(encoding="utf-8") if path.is_file() else None + rendered = render_mirror(page, existing) + if existing == rendered: + return False + _write_atomic(path, rendered) + return True + + +def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]: + if not path.is_file(): + return [f"缺少镜像:{mapping.path}"] + try: + metadata, body = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + return [f"镜像无效 {mapping.path}: {exc}"] + expected = { + "wiki_page": page.title, + "wiki_url": page.html_url, + "wiki_revision": page.revision, + } + errors = [ + f"{mapping.path} 的 {key} 不一致" + for key, value in expected.items() + if metadata.get(key) != value + ] + if not metadata.get("synchronized_at"): + errors.append(f"{mapping.path} 缺少 synchronized_at") + if body != page.text: + errors.append(f"{mapping.path} 的正文与 Wiki 不一致") + return errors + + +def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]: + """检查或写入所有显式映射;绝不处理映射外的文件。""" + + if not check: + dirty = dirty_mirror_paths(config) + if dirty: + details = "\n".join(dirty) + raise WikiDocsError( + "已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details + ) + + messages: list[str] = [] + for mapping in config.mappings: + page = client.get_page(mapping.page) + target = ROOT / PurePosixPath(mapping.path) + if check: + errors = check_mirror(mapping, page, target) + if errors: + raise WikiDocsError("\n".join(errors)) + messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}") + continue + existing = target.read_text(encoding="utf-8") if target.is_file() else None + rendered = render_mirror(page, existing) + if existing != rendered: + _write_atomic(target, rendered) + messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}") + else: + messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}") + return messages + + +def append_mapping(config: Config, mapping: Mapping) -> None: + raw = json.loads(config.path.read_text(encoding="utf-8")) + mappings = validate_mappings(raw.get("mappings")) + if any(item.page == mapping.page or item.path == mapping.path for item in mappings): + raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}") + raw["mappings"].append({"page": mapping.page, "path": mapping.path}) + rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n" + _write_atomic(config.path, rendered) diff --git a/docs/maintainer/1-project-profile.md b/docs/maintainer/1-project-profile.md new file mode 100644 index 00000000..c3e116a6 --- /dev/null +++ b/docs/maintainer/1-project-profile.md @@ -0,0 +1,57 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Project-Profile +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Project-Profile.- +wiki_revision: 0ed4c5e12ce813f4b246e6014622aee36551ed3a +synchronized_at: 2026-08-28T02:10:10Z + + +# 项目档案 + +## 基本信息 + +| 项目 | 内容 | +|---|---| +| 项目名称 | OPC MediaMTX 定制版 | +| 一句话目标 | 在保持 MediaMTX 单一可执行文件和上游兼容性的前提下,增加简单、轻量、原生的管理能力 | +| 主要使用者 | 项目负责人、维护者、受控局域网管理员 | +| Gitea 仓库 | `OPC/mediamtx` | +| 默认分支 | `main` | +| 主要维护者 | `ila` | +| 支持环境 | Windows / PowerShell;MediaMTX 原有支持平台按上游为准 | + +## 建设基线 + +| 项目 | 内容 | +|---|---| +| 上游项目 | `bluenviron/mediamtx` | +| 上游地址 | https://github.com/bluenviron/mediamtx | +| 当前上游提交 | `2328e51a219729e0afc875a0fbd68c34db197bbf` | +| 对应版本 | `v1.20.1-17-g2328e51a` | +| 上游许可证 | MIT | +| 保留能力 | 全部媒体协议、配置、API、测试、官方文档和发布结构 | +| 定制范围 | 原生管理页面、必要的 API/静态资源路由、定制维护文档 | +| 上游策略 | `origin` 指向 OPC Gitea;`upstream` 指向官方 GitHub;定期获取上游并对定制范围做兼容验证 | + +## DevHarness 基线 + +| 项目 | 内容 | +|---|---| +| 来源 | `https://git.ilapage.cn/OPC/dev_harness` | +| 当前基线 | `4bbacf4d7fb265984396bb5589c544105043fa0b` | +| 接入日期 | 2026-08-28 | +| 适配说明 | 采用 Gitea 工单、Wiki、Agent 规则和最小同步检查;不覆盖 MediaMTX 官方 docs 和 CI | + +## 交付单元 + +| 单元 | 职责 | 构建与测试 | 发布 | +|---|---|---|---| +| MediaMTX 定制二进制 | 上游媒体服务及嵌入式管理页面 | Go、Makefile、上游测试 | 跟随定制仓库版本 | +| 定制维护文档 | 记录定制需求、边界、运行和排错 | DevHarness 同步检查 | 随 Git 版本 | + +## 安全边界 + +- `ip_camera.env`、摄像头 URL、用户名、密码、Token、证书私钥和生产数据不得提交。 +- Control API 默认不直接暴露公网;管理页面必须沿用 `api` 权限。 +- 不执行未经负责人确认的发布、删除数据或破坏性 Git 操作。 +- 上游文件的已有改动和删除必须保留,不能在无关任务中顺手恢复或提交。 diff --git a/docs/maintainer/2-development-workflow.md b/docs/maintainer/2-development-workflow.md new file mode 100644 index 00000000..ee60fc45 --- /dev/null +++ b/docs/maintainer/2-development-workflow.md @@ -0,0 +1,52 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Development-Workflow +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Development-Workflow.- +wiki_revision: 68377a272a565ae9377322ead7b8c73ebb7d354d +synchronized_at: 2026-08-28T02:10:12Z + + +# 开发工作流 + +## 事实来源 + +- Gitea 单元工单:一次任务的需求、方案、实施、测试和验收。 +- Gitea Wiki:定制版长期有效事实。 +- `docs/maintainer/`:Wiki 的只读镜像。 +- Git:代码及版本绑定资料。 +- MediaMTX `docs/`:上游产品文档,不作为 DevHarness 镜像重写。 + +## 工作闭环 + +1. 只读检查代码、当前工单、工作区和相关 Wiki。 +2. 写明目标、非目标、方案、风险、回退、验证和文档影响。 +3. 用户确认方案后建立一个可独立验证和回退的单元工单。 +4. 检查前置依赖和现有工作区改动,仅修改工单范围。 +5. 执行与风险相称的格式、单元、集成和构建验证。 +6. 把最终差异、测试、未验证项、提交哈希和文档影响集中回写工单。 +7. 保持工单“待验收”;只有用户明确验收后关闭。 +8. 只有长期事实变化时更新 Wiki,再同步本地镜像。 + +## 上游边界 + +- 不覆盖 MediaMTX 官方 `docs/`、`.github/`、发布流程和协议实现。 +- 上游文件发生冲突时,先区分上游变化、现有用户改动和当前工单改动。 +- 自定义代码应集中、命名清楚、测试独立,降低后续合并成本。 +- 合并上游前记录目标提交并运行受影响测试。 + +## UI 设计门禁 + +新增页面或重大交互必须先提供可审阅原型,确认页面、状态、权限、错误处理和验收范围后再实现。简单文案调整可以使用最小截图或文字证据。 + +## 安全门禁 + +凭据、权限、认证、外网暴露和删除操作属于高风险。涉及这些内容时必须停止实施,先让负责人确认安全边界。 + +## 快捷指令 + +- `只分析`:只读检查并给方案。 +- `建工单`:创建已确认方案的单元工单,不改代码。 +- `执行工单 #N`:实施、测试、提交并回写证据,停在待验收。 +- `检查工单 #N`:只读核对范围和证据。 +- `同步文档`:从 Wiki 单向更新 `docs/maintainer/`。 +- `#N 验收通过`:记录验收并关闭工单。 diff --git a/docs/maintainer/3-architecture-and-code-map.md b/docs/maintainer/3-architecture-and-code-map.md new file mode 100644 index 00000000..d502a286 --- /dev/null +++ b/docs/maintainer/3-architecture-and-code-map.md @@ -0,0 +1,51 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Architecture-and-Code-Map +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Architecture-and-Code-Map.- +wiki_revision: 998205ee17d1cdea34d6e68681f7cff8ac34b941 +synchronized_at: 2026-08-28T02:10:15Z + + +# 架构与代码地图 + +## 上游入口 + +- `main.go`:程序入口。 +- `internal/core/`:服务装配、配置重载和生命周期。 +- `internal/api/`:Control API,当前版本使用 `/v3` 路由。 +- `internal/conf/`:配置结构、默认值与校验。 +- `internal/servers/webrtc/`:WebRTC/WHEP 服务及内置原生播放页面。 +- `internal/servers/hls/`:HLS 服务。 +- `mediamtx.yml`:完整配置参考。 +- `api/openapi.yaml`:Control API 契约。 +- `internal/**/*_test.go`:单元和集成测试。 + +## 定制管理页面目标结构 + +```text +MediaMTX API HTTP 服务 :9997 +├─ /admin/ 嵌入式原生 HTML/CSS/JS +└─ /v3/... 现有 Control API + +MediaMTX WebRTC :8889/{path} 低延迟预览 +MediaMTX HLS :8888/{path} 兼容预览 +``` + +管理页面只负责调用既有 Control API、展示状态和嵌入播放器,不代理 RTSP 媒体数据,不引入 Node、数据库或前端框架。 + +## 主要 API + +- `GET /v3/config/paths/list` +- `POST /v3/config/paths/add/{name}` +- `PATCH /v3/config/paths/patch/{name}` +- `DELETE /v3/config/paths/delete/{name}` +- `GET /v3/paths/list` + +API 契约以当前仓库 `api/openapi.yaml` 为唯一代码级事实来源。 + +## 修改边界 + +- UI 静态资源与路由集中在 API 包的独立目录。 +- 不改变 RTSP 拉流、转封装、协议协商等媒体核心行为。 +- 管理路由必须复用现有 API 认证边界。 +- 上游内置播放器优先复用,避免维护第二套媒体播放实现。 diff --git a/docs/maintainer/4-product-requirements-overview.md b/docs/maintainer/4-product-requirements-overview.md new file mode 100644 index 00000000..9d2b1194 --- /dev/null +++ b/docs/maintainer/4-product-requirements-overview.md @@ -0,0 +1,38 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Product-Requirements-Overview +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Product-Requirements-Overview.- +wiki_revision: 20e3454b75cdd865307d8a07f9a26f5da98970de +synchronized_at: 2026-08-28T02:10:28Z + + +# 产品需求总览 + +## 当前方向 + +| 状态 | 需求 | 入口 | +|---|---|---| +| 进行中 | 增量接入 DevHarness | [工单 #1](https://git.ilapage.cn/OPC/mediamtx/issues/1) | +| 待规划 | 原生轻量 RTSP 管理页面 | 后续建立独立设计与实现工单 | + +## 管理页面目标 + +- 使用原生 HTML、CSS 和 JavaScript。 +- 随 MediaMTX 二进制嵌入,不要求 Node、Nginx、数据库或单独 Web 服务。 +- 增加、编辑、删除静态 RTSP 源。 +- 展示路径在线/离线状态。 +- 提供 WebRTC 与 HLS 预览入口。 +- 可以复制 RTSP、HLS、WebRTC 输出地址。 +- 保持界面小而直接,不扩展为完整 VMS。 + +## 非目标 + +- 不做视频转码编辑器。 +- 不做录像检索、磁盘管理、告警中心或多租户系统。 +- 不复制 MediaMTX 所有全局配置为大型后台。 +- 不允许匿名用户修改路径配置。 +- 不承诺浏览器支持摄像头输出的所有编码。 + +## 验收入口 + +具体页面、状态、权限和异常处理以未来对应工单及已确认原型为准;本页只维护长期需求索引,不替代单元工单。 diff --git a/docs/maintainer/5-local-development-and-verification.md b/docs/maintainer/5-local-development-and-verification.md new file mode 100644 index 00000000..f027cf65 --- /dev/null +++ b/docs/maintainer/5-local-development-and-verification.md @@ -0,0 +1,53 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Local-Development-and-Verification +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Local-Development-and-Verification.- +wiki_revision: 9291a287f01fdf1258a74e4461ca747679518cce +synchronized_at: 2026-08-28T02:10:40Z + + +# 本地开发与验证 + +## 环境 + +- 工作目录:`D:\github\mediamtx` +- 主要终端:PowerShell +- 语言:Go(版本以 `go.mod` 和上游 CI 为准) +- DevHarness:Python 3 标准库 +- 上游远端:`upstream=https://github.com/bluenviron/mediamtx.git` +- 定制远端:`origin=https://git.ilapage.cn/OPC/mediamtx.git` + +## 常用命令 + +```powershell +git status --short --branch +go test ./internal/api ./internal/core +go test ./... +go build ./... +python dev_scripts/harness.py check --strict +python dev_scripts/harness.py sync --check +python -m unittest discover -s tests -v +``` + +预期: + +- Git 状态只包含当前工单和已知用户改动。 +- 受影响 Go 测试通过;全量测试按风险执行。 +- Go 构建成功。 +- Harness 结构、镜像和单元测试通过。 + +## 运行 + +```powershell +go run . mediamtx.yml +``` + +启用管理 API 时使用不含真实凭据的测试配置。Control API 默认端口为 `:9997`,WebRTC 为 `:8889`,HLS 为 `:8888`。 + +## 凭据 + +`ip_camera.env` 只允许保存在本地并被 Git 忽略。测试、日志、截图、工单和 Wiki 使用虚构地址与账号。 + +## 未验证内容 + +公网 NAT、真实摄像头、浏览器编码兼容和生产认证必须单独记录实际验证环境,不能由单元测试结果代替。 diff --git a/docs/maintainer/6-common-changes.md b/docs/maintainer/6-common-changes.md new file mode 100644 index 00000000..21ee414f --- /dev/null +++ b/docs/maintainer/6-common-changes.md @@ -0,0 +1,34 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Common-Changes +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Common-Changes.- +wiki_revision: 17db8cd186ac676dd3c58c114751fc4f19483cfe +synchronized_at: 2026-08-28T02:10:42Z + + +# 常见修改 + +## 增加管理页字段 + +1. 先核对 `api/openapi.yaml` 和 `internal/conf/` 中的真实字段。 +2. 更新已确认的页面状态和验证范围。 +3. 修改原生表单及请求体。 +4. 增加前端输入校验和 Go 路由/响应测试。 +5. 验证新增、读取、编辑、删除及错误提示。 + +## 调整 Control API + +API 属于中风险变更。必须先建工单并确认兼容性,不得只改前端假定接口。实现后更新 OpenAPI、处理器测试和相关长期文档。 + +## 更新上游 + +1. 记录目标上游完整提交。 +2. 获取 `upstream`。 +3. 检查定制文件和 API 契约变化。 +4. 合并时保留用户未提交改动。 +5. 运行受影响测试和构建。 +6. 更新项目档案中的上游基线并回写工单证据。 + +## 修改文档 + +定制版长期事实先改 Gitea Wiki,回读 revision 后运行同步;不要直接编辑 `docs/maintainer/`。MediaMTX 官方功能文档继续按上游方式修改,不通过定制 Wiki 覆盖。 diff --git a/docs/maintainer/7-troubleshooting.md b/docs/maintainer/7-troubleshooting.md new file mode 100644 index 00000000..85d6235e --- /dev/null +++ b/docs/maintainer/7-troubleshooting.md @@ -0,0 +1,42 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Troubleshooting +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Troubleshooting +wiki_revision: 90ace07a771579bcccc8717b95a35496388ec6f0 +synchronized_at: 2026-08-28T02:10:45Z + + +# 故障排查 + +## 管理页无法访问 + +- 确认 `api: true`。 +- 确认 `apiAddress` 的监听地址和端口。 +- 查看启动日志是否显示 API listener。 +- 确认请求路径为实现约定的 `/admin/`。 + +## 返回 401 或 403 + +- 检查 `authInternalUsers` 是否为管理员配置 `action: api`。 +- 默认配置只允许本机匿名用户使用 API;从局域网访问需要明确配置认证。 +- 不要为了排错永久开放匿名写权限。 + +## 添加 RTSP 后离线 + +- 先用 VLC/FFmpeg 直接验证上游 RTSP URL。 +- 检查用户名、密码中的特殊字符是否已 URL 编码。 +- 尝试 `rtspTransport: tcp`。 +- 查看 MediaMTX 日志中的具体路径和源错误,不在工单粘贴含凭据的完整 URL。 + +## 页面能打开但视频不播放 + +- WebRTC 检查 `:8889` 和媒体 UDP `:8189`。 +- HLS 检查 `:8888/{path}`。 +- 检查浏览器是否支持源编码;H.265 和带 B 帧 H.264 可能存在兼容问题。 +- 用 VLC 读取 `rtsp://host:8554/{path}` 区分源问题和浏览器问题。 + +## Wiki 同步失败 + +- 确认 `GITEA_TOKEN` 只通过环境提供。 +- 确认 `wiki-docs.json` 指向 `OPC/mediamtx`。 +- 本地镜像存在未提交改动时先确认来源,工具应停止而不是覆盖。 diff --git a/docs/maintainer/README.md b/docs/maintainer/README.md new file mode 100644 index 00000000..6be75917 --- /dev/null +++ b/docs/maintainer/README.md @@ -0,0 +1,38 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Home +wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Home +wiki_revision: 60cbf7e43d39cc7322cac927b2e20a88ffe60e1a +synchronized_at: 2026-08-28T02:10:07Z + + +# MediaMTX 定制版维护入口 + +本 Wiki 只记录 OPC/mediamtx 定制版的长期开发事实。MediaMTX 的协议、配置和用户文档继续以仓库内官方 `docs/` 与上游网站为准,不在这里复制。 + +## 项目目标 + +在 MediaMTX v1.20.1 系列上维护轻量定制能力,当前重点是把原生 HTML/CSS/JavaScript 管理页面嵌入 MediaMTX,用于增删 RTSP 源、查看状态和预览视频,同时保持单一可执行文件。 + +## 建议阅读顺序 + +1. [Project-Profile](Project-Profile.-) +2. [Product-Requirements-Overview](Product-Requirements-Overview.-) +3. [Architecture-and-Code-Map](Architecture-and-Code-Map.-) +4. [Development-Workflow](Development-Workflow.-) +5. [Local-Development-and-Verification](Local-Development-and-Verification.-) +6. [Common-Changes](Common-Changes.-) +7. [Troubleshooting](Troubleshooting.-) + +## 事实来源边界 + +- 单次需求、方案、实施、测试和验收:Gitea 工单。 +- 定制版长期事实:本 Wiki。 +- Wiki 本地只读镜像:`docs/maintainer/`。 +- MediaMTX 上游产品文档:仓库原有 `docs/`。 +- 代码与版本绑定资料:Git。 +- 摄像头账号、密码、令牌和生产数据:不得进入以上任何位置。 + +## 当前任务 + +- [#1 增量接入 DevHarness 管理 MediaMTX 定制开发](https://git.ilapage.cn/OPC/mediamtx/issues/1) diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 00000000..9627d2bf --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "dev_scripts")) + +import harness # noqa: E402 +from wiki_docs import load_config # noqa: E402 + + +class HarnessTest(unittest.TestCase): + def test_non_strict_structure(self) -> None: + self.assertEqual([], harness.check(False)) + + def test_mapping_paths_are_isolated(self) -> None: + config = load_config(ROOT / "wiki-docs.json") + self.assertTrue(config.mappings) + self.assertTrue(all(item.path.startswith("docs/maintainer/") for item in config.mappings)) + + def test_target_repository(self) -> None: + raw = json.loads((ROOT / "wiki-docs.json").read_text(encoding="utf-8")) + self.assertEqual("OPC", raw["owner"]) + self.assertEqual("mediamtx", raw["repository"]) + + def test_sensitive_camera_file_is_ignored(self) -> None: + ignored = (ROOT / ".gitignore").read_text(encoding="utf-8") + self.assertIn("/ip_camera.env", ignored) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json new file mode 100644 index 00000000..c47da5ed --- /dev/null +++ b/wiki-docs.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "gitea_url": "https://git.ilapage.cn", + "owner": "OPC", + "repository": "mediamtx", + "mappings": [ + {"page": "Home", "path": "docs/maintainer/README.md"}, + {"page": "Project-Profile", "path": "docs/maintainer/1-project-profile.md"}, + {"page": "Development-Workflow", "path": "docs/maintainer/2-development-workflow.md"}, + {"page": "Architecture-and-Code-Map", "path": "docs/maintainer/3-architecture-and-code-map.md"}, + {"page": "Product-Requirements-Overview", "path": "docs/maintainer/4-product-requirements-overview.md"}, + {"page": "Local-Development-and-Verification", "path": "docs/maintainer/5-local-development-and-verification.md"}, + {"page": "Common-Changes", "path": "docs/maintainer/6-common-changes.md"}, + {"page": "Troubleshooting", "path": "docs/maintainer/7-troubleshooting.md"} + ] +}