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
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:
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Epic
|
||||
about: 长期目标与 MVP 索引
|
||||
title: "[Epic] "
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
## 非目标
|
||||
|
||||
## 主要风险
|
||||
|
||||
## MVP
|
||||
|
||||
- [ ] #编号
|
||||
|
||||
## 完成条件
|
||||
|
||||
Epic 只维护目标、风险和子项索引,不复制单元任务证据。
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: MVP
|
||||
about: 可独立验收的阶段性交付
|
||||
title: "[MVP] "
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
## 非目标
|
||||
|
||||
## 单元任务
|
||||
|
||||
- [ ] #编号
|
||||
|
||||
## 集成验收
|
||||
|
||||
## 风险与回退
|
||||
|
||||
MVP 只维护阶段目标和子任务索引,不复制单元任务全文。
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: 单元任务
|
||||
about: 一个可独立验证和回退的实现任务
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## 原始需求
|
||||
|
||||
- 来源与时间:
|
||||
- 关键原话或脱敏摘要:
|
||||
|
||||
## 目标
|
||||
|
||||
## 非目标
|
||||
|
||||
## 已确认方案
|
||||
|
||||
## 前置工单
|
||||
|
||||
无;如有请列出编号、是否允许并行及原因。
|
||||
|
||||
## 影响范围
|
||||
|
||||
## 风险与安全边界
|
||||
|
||||
## 回退方式
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 实现符合确认范围
|
||||
- [ ] 受影响测试通过
|
||||
- [ ] 未验证内容已记录
|
||||
- [ ] 文档影响已处理
|
||||
|
||||
## 验证方法
|
||||
|
||||
## 设计证据
|
||||
|
||||
原型、技术设计或无需原型的原因;记录版本、确认人、时间和覆盖范围。
|
||||
|
||||
## 文档影响
|
||||
|
||||
选择长期 Wiki 页面,或说明“无长期文档影响”及原因。
|
||||
|
||||
## 状态
|
||||
|
||||
待确认
|
||||
@@ -9,3 +9,7 @@
|
||||
/auto.key
|
||||
/internal/core/auto.crt
|
||||
/internal/core/auto.key
|
||||
/ip_camera.env
|
||||
/__pycache__/
|
||||
/dev_scripts/__pycache__/
|
||||
/tests/__pycache__/
|
||||
|
||||
@@ -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 并同步,或明确记录无影响。
|
||||
- 最终证据已回写工单;用户尚未验收时工单保持开启。
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude Code 入口
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
共同规则只修改 `AGENTS.md`。本文件仅作为 Claude Code 的规则入口,不复制第二份规则。
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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 操作。
|
||||
- 上游文件的已有改动和删除必须保留,不能在无关任务中顺手恢复或提交。
|
||||
@@ -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、真实摄像头、浏览器编码兼容和生产认证必须单独记录实际验证环境,不能由单元测试结果代替。
|
||||
@@ -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 覆盖。
|
||||
@@ -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`。
|
||||
- 本地镜像存在未提交改动时先确认来源,工具应停止而不是覆盖。
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user