chore: adopt DevHarness workflow (#1)
lint / go (push) Canceled after 0s
lint / go_mod (push) Canceled after 0s
lint / conf (push) Canceled after 0s
lint / docslinks (push) Canceled after 0s
lint / docsorder (push) Canceled after 0s
lint / apidocs (push) Canceled after 0s
lint / other (push) Canceled after 0s
test / test_64 (push) Canceled after 0s
test / test_32 (push) Canceled after 0s
test / test_e2e (push) Canceled after 0s

This commit is contained in:
QiuSW
2026-08-28 10:11:59 +08:00
parent 2328e51a21
commit ca667251d2
18 changed files with 1140 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
---
name: Epic
about: 长期目标与 MVP 索引
title: "[Epic] "
labels: ""
assignees: ""
---
## 目标
## 非目标
## 主要风险
## MVP
- [ ] #编号
## 完成条件
Epic 只维护目标、风险和子项索引,不复制单元任务证据。
+21
View File
@@ -0,0 +1,21 @@
---
name: MVP
about: 可独立验收的阶段性交付
title: "[MVP] "
labels: ""
assignees: ""
---
## 目标
## 非目标
## 单元任务
- [ ] #编号
## 集成验收
## 风险与回退
MVP 只维护阶段目标和子任务索引,不复制单元任务全文。
+49
View File
@@ -0,0 +1,49 @@
---
name: 单元任务
about: 一个可独立验证和回退的实现任务
title: ""
labels: ""
assignees: ""
---
## 原始需求
- 来源与时间:
- 关键原话或脱敏摘要:
## 目标
## 非目标
## 已确认方案
## 前置工单
无;如有请列出编号、是否允许并行及原因。
## 影响范围
## 风险与安全边界
## 回退方式
## 验收标准
- [ ] 实现符合确认范围
- [ ] 受影响测试通过
- [ ] 未验证内容已记录
- [ ] 文档影响已处理
## 验证方法
## 设计证据
原型、技术设计或无需原型的原因;记录版本、确认人、时间和覆盖范围。
## 文档影响
选择长期 Wiki 页面,或说明“无长期文档影响”及原因。
## 状态
待确认
+4
View File
@@ -9,3 +9,7 @@
/auto.key
/internal/core/auto.crt
/internal/core/auto.key
/ip_camera.env
/__pycache__/
/dev_scripts/__pycache__/
/tests/__pycache__/
+75
View File
@@ -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 并同步,或明确记录无影响。
- 最终证据已回写工单;用户尚未验收时工单保持开启。
+5
View File
@@ -0,0 +1,5 @@
# Claude Code 入口
@AGENTS.md
共同规则只修改 `AGENTS.md`。本文件仅作为 Claude Code 的规则入口,不复制第二份规则。
+126
View File
@@ -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())
+422
View File
@@ -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 = "<!-- gitea-wiki-mirror:start -->"
MIRROR_END = "<!-- gitea-wiki-mirror:end -->"
HEADER_PATTERN = re.compile(
rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n"
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\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)
+57
View File
@@ -0,0 +1,57 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 项目档案
## 基本信息
| 项目 | 内容 |
|---|---|
| 项目名称 | 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 操作。
- 上游文件的已有改动和删除必须保留,不能在无关任务中顺手恢复或提交。
+52
View File
@@ -0,0 +1,52 @@
<!-- gitea-wiki-mirror:start -->
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-wiki-mirror:end -->
# 开发工作流
## 事实来源
- 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 验收通过`:记录验收并关闭工单。
@@ -0,0 +1,51 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
## 上游入口
- `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 认证边界。
- 上游内置播放器优先复用,避免维护第二套媒体播放实现。
@@ -0,0 +1,38 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
## 当前方向
| 状态 | 需求 | 入口 |
|---|---|---|
| 进行中 | 增量接入 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 所有全局配置为大型后台。
- 不允许匿名用户修改路径配置。
- 不承诺浏览器支持摄像头输出的所有编码。
## 验收入口
具体页面、状态、权限和异常处理以未来对应工单及已确认原型为准;本页只维护长期需求索引,不替代单元工单。
@@ -0,0 +1,53 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
## 环境
- 工作目录:`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、真实摄像头、浏览器编码兼容和生产认证必须单独记录实际验证环境,不能由单元测试结果代替。
+34
View File
@@ -0,0 +1,34 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 常见修改
## 增加管理页字段
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 覆盖。
+42
View File
@@ -0,0 +1,42 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 故障排查
## 管理页无法访问
- 确认 `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`。
- 本地镜像存在未提交改动时先确认来源,工具应停止而不是覆盖。
+38
View File
@@ -0,0 +1,38 @@
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 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)
+36
View File
@@ -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()
+16
View File
@@ -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"}
]
}