Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52b5a6b389 | |||
| de6933c411 | |||
| b3aff9f2c4 | |||
| 84a84d9af0 | |||
| 2649854386 | |||
| d21170b41d | |||
| 969dcabe99 | |||
| d7058e8af7 | |||
| 138f774d49 | |||
| c8e9901b90 | |||
| a887a76095 | |||
| 7962a7e50a | |||
| 0674f260a4 |
@@ -0,0 +1,74 @@
|
||||
# 赵府智家系统架构
|
||||
|
||||
## 部署与职责
|
||||
|
||||
系统运行于自己的 Ubuntu 小电脑,提供局域网和 Tailscale 网页入口。第一阶段是视觉模块。管理程序、SQLite 和 MediaMTX 在同一主机运行,使用独立服务用户;网页只访问管理程序,设备凭据与媒体管理接口留在服务端。
|
||||
|
||||
摄像头与录像机继续在其原有网络工作。实时画面可直接从摄像头取流,也可由录像机提供;读取哪个位置的历史录像必须显式标明。录像机原有录像回放需要设备型号对应的检索、回放适配器。
|
||||
|
||||
## 对象关系
|
||||
|
||||
```text
|
||||
空间档案(家庭 / 公司 / 其他)
|
||||
├─ 地区:省、市、区县、街道
|
||||
├─ 地址:小区或园区、楼栋、单元、楼层、门牌
|
||||
├─ 所属:家庭或单位
|
||||
├─ 摄像头对象(稳定 ID、品牌、型号、序列号、安装位置)
|
||||
│ ├─ 镜头通道 1 → 主码流 / 子码流
|
||||
│ └─ 镜头通道 2 → 主码流 / 子码流
|
||||
└─ 录像机对象(地址、接入驱动、账号、空间引用)
|
||||
|
||||
每个镜头通道分别关联:
|
||||
实时来源:直接摄像头 / 录像机
|
||||
录像来源:本机 / 录像机(可独立于实时来源)
|
||||
```
|
||||
|
||||
## 接入层
|
||||
|
||||
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
|
||||
|
||||
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||
|
||||
## 管理 API
|
||||
|
||||
所有数据接口(除会话状态与健康检查)需要管理员 Cookie 会话。写接口接受 JSON,检查同源请求。
|
||||
|
||||
| 接口 | 用途 |
|
||||
| --- | --- |
|
||||
| GET /api/session | 初始化、登录状态 |
|
||||
| POST /api/setup、/api/login、/api/logout | 管理员会话 |
|
||||
| GET /api/state | 对象、镜头实际码流状态、存储状态 |
|
||||
| POST /api/sites | 保存结构化空间档案 |
|
||||
| POST /api/assets | 保存实体摄像头对象 |
|
||||
| POST /api/recorders | 保存录像机与连接凭据 |
|
||||
| POST /api/cameras | 保存镜头通道(早期接口名保留) |
|
||||
| POST /api/onvif | 只读设备身份、视频源与码流配置 |
|
||||
| POST /api/discover | 有界局域网 RTSP 探测 |
|
||||
| POST /api/storage | 本机录像容量与保留策略 |
|
||||
| GET /api/recordings | 本机录像时段 |
|
||||
| GET /media/live/... | 登录鉴权后的 HLS 代理 |
|
||||
| GET /media/playback | 本机录像回放、下载代理 |
|
||||
|
||||
## 当前交付与后续
|
||||
|
||||
本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。
|
||||
|
||||
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。
|
||||
|
||||
配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。
|
||||
|
||||
|
||||
## 身份与家谱
|
||||
|
||||
`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。家谱目前是同一部署的一份共享家庭档案,按账号授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。
|
||||
|
||||
| API | 权限 |
|
||||
| --- | --- |
|
||||
| GET/POST /api/users | 管理员 |
|
||||
| GET /api/family | 家谱查看或编辑 |
|
||||
| POST /api/family/person | 家谱编辑 |
|
||||
| POST /api/family/link | 家谱编辑 |
|
||||
| GET /api/state | 按空间过滤,个人账号不返回设备连接凭据与配置 |
|
||||
| GET /media/live/*、/media/playback、/api/recordings | 登录并具备对应空间权限 |
|
||||
|
||||
人物与关系修订在同一数据库事务中保存;修改关系时迭代检查亲子图,拒绝循环。前端 `kinship.js` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 标识
|
||||
|
||||
资产:`web/zhao-icon.png`。使用内置 imagegen 生成,作为网页品牌和 favicon;无需外部图片服务。
|
||||
|
||||
“赵”字的甲骨文风格设计。香港中文大学[汉语多功能字库的「趙」条目](https://humanum.arts.cuhk.edu.hk/Lexis/lexi-mf/search.php?word=%E8%B6%99)收录金文、小篆等字例,其甲骨栏目未列出字例。这里的标识是现代风格化创作,不宣称为已考证甲骨文字形。
|
||||
|
||||
生成提示词:
|
||||
|
||||
Use case: logo-brand. Create one polished app icon for 赵府智家, a private Chinese family home monitoring and genealogy application. Square app icon, dark deep teal #10191d rounded-square ground with generous mint #74d5bb single-color linework. The only subject is a single ancient-Chinese-style character emblem for the surname 趙 (modern simplified 赵), inspired by oracle bone carved strokes and the ancient components 走 and 肖. It is a contemporary oracle-bone-style design, NOT a claimed archaeological facsimile. Keep the ancient walking-person/foot component on the left and 肖-derived component on the right recognizable as one cohesive character. Thick clean slightly tapering carved strokes, balanced quiet confident composition, very legible as a small favicon, no photographic texture, no gradients, no glow, no shadows, no extra symbols, no English or captions, no surrounding words, no multiple options or mockup. Character occupies 72 percent of square, centered. Flat professional icon asset ready for website navigation and browser tab.
|
||||
@@ -10,7 +10,7 @@
|
||||
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
|
||||
- **录像来源**:明确区分录像机原有录像与小电脑新保存的录像。
|
||||
|
||||
## v0.1.0 功能与边界
|
||||
## 当前功能与边界
|
||||
|
||||
源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、ONVIF 设备信息与独立镜头媒体配置读取、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
|
||||
|
||||
HLS 会有数秒延迟。此版本不转码,推荐 H.264;H.265 和不同音频编码的浏览器兼容性取决于浏览器及设备。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
|
||||
HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
|
||||
|
||||
实时预览按镜头独立恢复连接:普通状态轮询只更新标签,不重建播放器;断流后按 2–30 秒退避重连,画面长时间不前进时也会自动恢复。用户主动暂停会保留暂停状态,离开页面会清理播放器和重试任务。
|
||||
|
||||
## Ubuntu x86_64 部署
|
||||
|
||||
@@ -28,9 +30,9 @@ HLS 会有数秒延迟。此版本不转码,推荐 H.264;H.265 和不同音
|
||||
sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh
|
||||
```
|
||||
|
||||
安装脚本校验并安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://<Tailscale-IP>:8790/`。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。
|
||||
安装脚本从系统软件源安装 FFmpeg;检测到 Intel renderD128 时安装 Intel 媒体驱动,授予服务用户 render 组权限,并校验安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://<Tailscale-IP>:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。
|
||||
|
||||
首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 10 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
|
||||
首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 8 位管理密码(初始用户名为 admin)。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
|
||||
|
||||
```sh
|
||||
systemctl status zhaovision zhaovision-media
|
||||
@@ -47,7 +49,9 @@ python3 app.py
|
||||
mediamtx ./data/mediamtx.yml
|
||||
```
|
||||
|
||||
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`,HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。
|
||||
回归测试:`python3 -m unittest -v test_app test_ptz` 和 `node --test test_live_player.js test_kinship.js test_calendar.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
|
||||
|
||||
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。
|
||||
|
||||
## 数据与恢复
|
||||
|
||||
@@ -58,7 +62,34 @@ mediamtx ./data/mediamtx.yml
|
||||
## 第三方软件
|
||||
|
||||
- [MediaMTX](https://github.com/bluenviron/mediamtx) v1.21.1,MIT,部署时独立安装。
|
||||
- [FFmpeg](https://ffmpeg.org/),通过系统软件源安装为独立进程,用于按需兼容预览。
|
||||
- [hls.js](https://github.com/video-dev/hls.js) v1.7.3,Apache-2.0,浏览器构建与许可证位于 `web/vendor/`。
|
||||
- 官方接口参考:[录像](https://mediamtx.org/docs/features/record)、[回放](https://mediamtx.org/docs/features/playback)、[配置](https://mediamtx.org/docs/references/configuration-file)。
|
||||
|
||||
本项目源码以 MIT 许可证发布。v0.1.0 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
|
||||
[系统架构与对象关系](ARCHITECTURE.md)。
|
||||
|
||||
本项目源码以 MIT 许可证发布。v0.1.x 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
|
||||
|
||||
## 账号与家谱(v0.1.11)
|
||||
|
||||
- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置、网络发现和审计接口仅管理员可用。媒体播放请求逐次检查空间权限。
|
||||
- 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。
|
||||
- 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。
|
||||
- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。
|
||||
- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。
|
||||
- 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。
|
||||
- 支持关系参照人物、搜索、祖先树、后裔树、配偶列表和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。
|
||||
- 并发编辑使用家谱修订号,旧表单提交会被拒绝,避免覆盖他人的更新。导出包含人物和关系,不含密码和设备凭据。当前导出用于保存副本,尚未提供 JSON 导入界面。
|
||||
|
||||
### 从 v0.1.10 升级
|
||||
|
||||
部署前用 SQLite backup API 保存 `/var/lib/zhaovision/vision.db`。首次启动 v0.1.11 会将原有单密码账户迁移为 `admin`,保留原密码散列,并把既有会话关联到管理员;没有固定出厂密码。新增 `users`、`people`、`family_links` 表和 `sessions.user_id` 列。以后可以直接升级;若回退到 v0.1.10,必须同时恢复升级前数据库副本,因为旧版本使用旧账户模型。人物和实际账号数据仅在受限数据目录中,不包含在公开源码内。
|
||||
|
||||
## 监控墙、云台与日期(v0.1.13)
|
||||
|
||||
- 监控墙支持自适应(每页最多六路)、1/2/4/6/9/16 分屏、翻页、拖动和前移/后移排列。布局和顺序按账号保存在当前浏览器;不跨设备同步。排列和分屏只改 CSS 与可见性,不重建播放器。全屏使用浏览器 Fullscreen API,Esc 退出。属于网页全屏模式,尚非可离线运行的安装包。
|
||||
- 当前监控页的通道保持连接,翻页隐藏的通道也继续播放,以便切回时不重复连接;大规模通道应评估转码和网络开销。浏览器处于前台并保持系统唤醒时可持续监看。
|
||||
- 云台读取 ONVIF PTZ 能力,仅对有匹配媒体配置、标准连续方向速度空间和一秒超时支持的直连摄像头提供四方向短距离微调。每次移动明确设置 PT1S,随后发 Stop;同一物理节点串行控制,独立停止按钮不排队。设备未声明的变焦、回到原点等功能不显示。协议参考:[ONVIF PTZ](https://www.onvif.org/ver20/ptz/wsdl/)。ONVIF 服务端口默认为 80。
|
||||
- 管理员具有云台权限;个人账号需同时具备空间访问和独立的云台控制权限。新账号默认不允许转动。两个镜头如果指向同一 PTZ 节点,控制的是同一物理云台,不能据镜头数量推断独立电机。
|
||||
- 人物默认在世;只在“不在世”时显示去世日期,旧人物的“待确认”状态保持原样。出生/去世各使用一个日期输入,选择历法,农历可勾选闰月;数据库统一保存公历日期,避免两份日期不一致。使用浏览器 ICU Chinese calendar,不联网。公历转农历范围 1901–2100,农历录入范围 1901–2099。更早祖先生卒日期可填公历并保留原始记载;不对古代历法作精确推断。
|
||||
- 站点与标签页图标为“赵”字的甲骨文风格原创设计,并非考古字形摹本。参考字库及生成说明见 [ICON.md](ICON.md)。
|
||||
|
||||
@@ -14,9 +14,11 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -24,6 +26,10 @@ import urllib.parse as url
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import onvif
|
||||
import playback
|
||||
import identity
|
||||
import genealogy
|
||||
import ptz
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
os.umask(0o077)
|
||||
@@ -63,6 +69,10 @@ class Problem(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
ACCOUNTS = identity.Accounts(sys.modules[__name__])
|
||||
FAMILY = genealogy.Family(sys.modules[__name__])
|
||||
|
||||
|
||||
def setting(key, default=None):
|
||||
with LOCK:
|
||||
row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
|
||||
@@ -77,7 +87,8 @@ def set_setting(key, value):
|
||||
|
||||
def objects(table):
|
||||
with LOCK:
|
||||
return [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
|
||||
rows = [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
|
||||
return sorted(rows, key=lambda row: (row.get('name', ''), row['id']))
|
||||
|
||||
|
||||
def get_object(table, key):
|
||||
@@ -266,6 +277,13 @@ def write_media_config():
|
||||
'record': bool(c['enabled'] and c['record'] and quality == 'main' and not STORAGE['paused']),
|
||||
'recordDeleteAfter': str(policy['retentionDays'] * 24) + 'h',
|
||||
}
|
||||
for quality in ('compat', 'hd'):
|
||||
paths[stream_name(c, quality)] = {
|
||||
'source': 'publisher', 'record': False,
|
||||
'runOnDemand': shlex.join([sys.executable, str(ROOT / 'preview.py'), str(DATA)]) if c['enabled'] else '',
|
||||
'runOnDemandRestart': True,
|
||||
'runOnDemandStartTimeout': '45s', 'runOnDemandCloseAfter': '15s',
|
||||
}
|
||||
config = {
|
||||
'logLevel': 'warn', 'logDestinations': ['stdout'],
|
||||
'api': True, 'apiAddress': '127.0.0.1:' + str(API_PORT),
|
||||
@@ -278,7 +296,8 @@ def write_media_config():
|
||||
'authMethod': 'internal',
|
||||
'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'),
|
||||
'ips': ['127.0.0.1', '::1'],
|
||||
'permissions': [{'action': a} for a in ('read', 'api', 'playback')]}],
|
||||
'permissions': [{'action': a} for a in ('read', 'api', 'playback')]
|
||||
+ [{'action': 'publish', 'path': '~^cam_[a-f0-9]{16}_(compat|hd)$'}]}],
|
||||
'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'),
|
||||
'recordSegmentDuration': '5m', 'recordPartDuration': '1s'},
|
||||
'paths': paths,
|
||||
@@ -298,10 +317,43 @@ def media_json(path, playback=False):
|
||||
req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if playback and path.startswith('/list?') and exc.code == 404:
|
||||
try:
|
||||
detail = json.loads(exc.read(4096))
|
||||
if detail.get('error') == 'no recording segments found':
|
||||
return []
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
|
||||
except Exception:
|
||||
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
|
||||
|
||||
|
||||
def media_config_watch():
|
||||
# Reconcile API configuration after atomic file replacements. Multiple rapid
|
||||
# writes can be coalesced by a filesystem watcher; the file remains authority.
|
||||
applied = None
|
||||
while True:
|
||||
try:
|
||||
raw = CONFIG.read_bytes()
|
||||
digest = hashlib.sha256(raw).digest()
|
||||
if digest != applied:
|
||||
desired = json.loads(raw)['paths']
|
||||
existing = {p['name'] for p in media_json('/v3/config/paths/list?itemsPerPage=200').get('items', [])}
|
||||
for name, body in desired.items():
|
||||
action, method = ('patch', 'PATCH') if name in existing else ('add', 'POST')
|
||||
req = urllib.request.Request('http://127.0.0.1:' + str(API_PORT) + '/v3/config/paths/' + action + '/' + name,
|
||||
data=json.dumps(body).encode(), method=method,
|
||||
headers={'Authorization': MEDIA_AUTH, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
response.read()
|
||||
applied = digest
|
||||
except Exception:
|
||||
pass # Retry on the next pass, including after a media process restart.
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def status():
|
||||
try:
|
||||
data = media_json('/v3/paths/list?itemsPerPage=200')
|
||||
@@ -366,9 +418,9 @@ def storage_watch():
|
||||
def discover(network):
|
||||
def probe(ip):
|
||||
try:
|
||||
with socket.create_connection((str(ip), 554), timeout=0.7) as s:
|
||||
s.settimeout(1)
|
||||
s.sendall(('OPTIONS rtsp://' + str(ip) + '/ RTSP/1.0\r\nCSeq: 1\r\n\r\n').encode())
|
||||
with socket.create_connection((str(ip), 554), timeout=1.5) as s:
|
||||
s.settimeout(3)
|
||||
s.sendall(('OPTIONS rtsp://' + str(ip) + ':554/ RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: ZhaoVision\r\n\r\n').encode())
|
||||
response = s.recv(1024).decode(errors='replace').split('\r\n')[0]
|
||||
if response.startswith('RTSP/'):
|
||||
return {'host': str(ip), 'port': 554, 'response': response, 'authenticated': False}
|
||||
@@ -425,15 +477,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def authenticated(self):
|
||||
token = self.token()
|
||||
if not re.fullmatch('[a-f0-9]{64}', token):
|
||||
return False
|
||||
return None
|
||||
with LOCK:
|
||||
return bool(DB.execute('SELECT 1 FROM sessions WHERE hash=? AND expires>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone())
|
||||
row = DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone()
|
||||
user = get_object('users', row['user_id']) if row else None
|
||||
return user if user and not user['disabled'] else None
|
||||
|
||||
def require_auth(self):
|
||||
if not self.authenticated():
|
||||
self.user = self.authenticated()
|
||||
if not self.user:
|
||||
raise Problem('请先登录', 401)
|
||||
|
||||
def require_admin(self):
|
||||
if self.user['role'] != 'admin':
|
||||
raise Problem('此操作需要管理员权限', 403)
|
||||
|
||||
def body(self):
|
||||
if self.headers.get_content_type() != 'application/json':
|
||||
raise Problem('请求需要 JSON 格式')
|
||||
@@ -464,31 +523,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
ATTEMPTS[ip] = (count + 1, expiry)
|
||||
if count >= 12:
|
||||
raise Problem('尝试次数过多,请 15 分钟后重试', 429)
|
||||
account = setting('account')
|
||||
accounts = objects('users')
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or not 10 <= len(password) <= 128:
|
||||
raise Problem('管理密码需要 10 至 128 个字符')
|
||||
if setup:
|
||||
if account:
|
||||
if accounts:
|
||||
raise Problem('管理员已经初始化', 409)
|
||||
code = data.get('code', '')
|
||||
expected = (DATA / 'setup-code.txt').read_text().strip()
|
||||
if not isinstance(code, str) or not hmac.compare_digest(code, expected):
|
||||
raise Problem('初始化码不正确', 403)
|
||||
salt = secrets.token_hex(16)
|
||||
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
|
||||
set_setting('account', {'salt': salt, 'hash': derived})
|
||||
account = ACCOUNTS.save({'username': 'admin', 'name': '管理员', 'role': 'admin',
|
||||
'password': password, 'familyAccess': 'edit'}, None)
|
||||
(DATA / 'setup-code.txt').unlink(missing_ok=True)
|
||||
audit('初始化管理员')
|
||||
else:
|
||||
if not account:
|
||||
if not accounts:
|
||||
raise Problem('请先初始化管理员', 409)
|
||||
derived = hashlib.scrypt(password.encode(), salt=account['salt'].encode(), n=16384, r=8, p=1).hex()
|
||||
if not hmac.compare_digest(derived, account['hash']):
|
||||
raise Problem('密码不正确', 403)
|
||||
account = ACCOUNTS.verify(data.get('username', ''), password)
|
||||
if not account:
|
||||
raise Problem('用户名或密码不正确', 403)
|
||||
token = secrets.token_hex(32)
|
||||
DB.execute('DELETE FROM sessions WHERE expires<?', (now,))
|
||||
DB.execute('INSERT INTO sessions VALUES (?,?)', (hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7))
|
||||
DB.execute('INSERT INTO sessions (hash,expires,user_id) VALUES (?,?,?)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7, account['id']))
|
||||
DB.commit()
|
||||
ATTEMPTS.pop(ip, None)
|
||||
secure = '; Secure' if os.environ.get('VISION_SECURE_COOKIE') == '1' else ''
|
||||
@@ -503,11 +560,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.login(data, path == '/api/setup')
|
||||
return
|
||||
self.require_auth()
|
||||
if path not in ('/api/logout', '/api/family/person', '/api/family/link', '/api/ptz'):
|
||||
self.require_admin()
|
||||
if path == '/api/logout':
|
||||
with LOCK:
|
||||
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
|
||||
DB.commit()
|
||||
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
||||
elif path == '/api/ptz':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
|
||||
try:
|
||||
result = ptz.CONTROLLER.command(c, data.get('action'))
|
||||
except ValueError as e:
|
||||
raise Problem(str(e), 400)
|
||||
except Exception:
|
||||
raise Problem('云台通信未成功,请检查设备连接;设备端的一秒超时保护会停止本次转动', 502)
|
||||
audit('云台 ' + data['action'], c['name'])
|
||||
self.answer(result)
|
||||
elif path == '/api/users':
|
||||
self.answer(ACCOUNTS.save(data, self.user))
|
||||
elif path in ('/api/family/person', '/api/family/link'):
|
||||
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
|
||||
elif path == '/api/sites':
|
||||
self.answer(save_site(data))
|
||||
elif path == '/api/cameras':
|
||||
@@ -560,8 +633,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
|
||||
|
||||
def proxy(self, port, path):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=25)
|
||||
def proxy(self, port, path, download=False):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=55)
|
||||
headers = {'Authorization': MEDIA_AUTH}
|
||||
if self.headers.get('Range'):
|
||||
headers['Range'] = self.headers['Range']
|
||||
@@ -571,7 +644,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
r = conn.getresponse()
|
||||
if r.status >= 400:
|
||||
raise Problem('暂时没有可播放画面,请确认设备网络、账号、码流路径和录像时间', 503)
|
||||
location = None
|
||||
if 300 <= r.status < 400:
|
||||
# MediaMTX starts an HLS session with a relative redirect. The
|
||||
# browser must keep that session query on subsequent playlists.
|
||||
target = url.urlsplit(url.urljoin(path, r.getheader('Location', '')))
|
||||
if (port != HLS_PORT or target.scheme or target.netloc
|
||||
or not re.fullmatch(r'/cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', target.path)):
|
||||
raise Problem('视频服务返回了无效的播放跳转', 502)
|
||||
location = '/media/live' + target.path + ('?' + target.query if target.query else '')
|
||||
self.send_response(r.status)
|
||||
if download:
|
||||
self.send_header('Content-Disposition', 'attachment; filename="recording.mp4"')
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
|
||||
if r.getheader(k):
|
||||
self.send_header(k, r.getheader(k))
|
||||
@@ -590,13 +676,45 @@ class Handler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def compatible_playback(self, path, duration):
|
||||
started = False
|
||||
old_timeout = self.connection.gettimeout()
|
||||
try:
|
||||
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration) as output:
|
||||
first = output.read1(65536)
|
||||
if not first:
|
||||
raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503)
|
||||
self.connection.settimeout(15)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'video/mp4')
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Accept-Ranges', 'none')
|
||||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
started = True
|
||||
self.close_connection = True
|
||||
self.wfile.write(first)
|
||||
while chunk := output.read1(65536):
|
||||
self.wfile.write(chunk)
|
||||
except playback.PlaybackError as exc:
|
||||
if not started:
|
||||
raise Problem(str(exc), 503)
|
||||
except OSError:
|
||||
if not started:
|
||||
raise Problem('录像转换连接失败', 503)
|
||||
finally:
|
||||
self.connection.settimeout(old_timeout)
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
parsed = url.urlsplit(self.path)
|
||||
path = parsed.path
|
||||
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
|
||||
if path == '/api/session':
|
||||
self.answer({'authenticated': self.authenticated(), 'setupRequired': not bool(setting('account'))})
|
||||
user = self.authenticated()
|
||||
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
|
||||
'setupRequired': not bool(objects('users'))})
|
||||
return
|
||||
if path == '/healthz':
|
||||
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
|
||||
@@ -604,8 +722,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path.startswith(('/api/', '/media/')):
|
||||
self.require_auth()
|
||||
if path == '/api/state':
|
||||
self.answer(status())
|
||||
self.answer(ACCOUNTS.state(self.user, status()))
|
||||
elif path == '/api/users':
|
||||
self.require_admin()
|
||||
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
|
||||
elif path == '/api/ptz':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
|
||||
try:
|
||||
result = ptz.CONTROLLER.capabilities(c)
|
||||
except ValueError as e:
|
||||
raise Problem(str(e), 400)
|
||||
except Exception:
|
||||
raise Problem('未能读取云台能力,请确认摄像头的 ONVIF 服务和连接', 502)
|
||||
self.answer(result)
|
||||
elif path == '/api/family':
|
||||
self.answer(FAMILY.snapshot(self.user))
|
||||
elif path == '/api/audit':
|
||||
self.require_admin()
|
||||
with LOCK:
|
||||
rows = [dict(r) for r in DB.execute('SELECT * FROM audit ORDER BY id DESC LIMIT 50')]
|
||||
self.answer(rows)
|
||||
@@ -613,6 +746,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
c = get_object('cameras', query.get('camera'))
|
||||
if not c:
|
||||
raise Problem('请选择摄像头')
|
||||
ACCOUNTS.camera(self.user, c)
|
||||
start, end = parse_time(query.get('start')), parse_time(query.get('end'))
|
||||
if not 0 < (end - start).total_seconds() <= 172800:
|
||||
raise Problem('每次查询最多两天')
|
||||
@@ -622,13 +756,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.answer({'items': items})
|
||||
elif path.startswith('/media/live/'):
|
||||
suffix = path[len('/media/live/'):]
|
||||
if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub)/[a-zA-Z0-9_.-]+', suffix):
|
||||
if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', suffix):
|
||||
raise Problem('视频路径不正确')
|
||||
ACCOUNTS.camera(self.user, get_object('cameras', suffix[4:20]))
|
||||
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
|
||||
elif path == '/media/playback':
|
||||
c = get_object('cameras', query.get('camera'))
|
||||
if not c:
|
||||
raise Problem('摄像头不存在', 404)
|
||||
ACCOUNTS.camera(self.user, c)
|
||||
start = parse_time(query.get('start'))
|
||||
try:
|
||||
duration = float(query.get('duration', '300'))
|
||||
@@ -636,13 +772,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise Problem('单次回放最多一小时')
|
||||
download = query.get('download') == '1'
|
||||
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'duration': duration,
|
||||
'format': 'mp4' if query.get('download') == '1' else 'fmp4'})
|
||||
self.proxy(PLAYBACK_PORT, '/get?' + q)
|
||||
'format': 'mp4' if download else 'fmp4'})
|
||||
if download:
|
||||
self.proxy(PLAYBACK_PORT, '/get?' + q, download=True)
|
||||
else:
|
||||
self.compatible_playback('/get?' + q, duration)
|
||||
else:
|
||||
raise Problem('接口不存在', 404)
|
||||
else:
|
||||
files = {'/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css',
|
||||
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
|
||||
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
|
||||
'/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js',
|
||||
'/zhao-icon.png': 'zhao-icon.png',
|
||||
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
||||
if path not in files:
|
||||
raise Problem('页面不存在', 404)
|
||||
@@ -668,12 +811,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def initialize():
|
||||
global MEDIA_AUTH
|
||||
FAMILY.initialize()
|
||||
ACCOUNTS.initialize()
|
||||
if not setting('storage'):
|
||||
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
|
||||
if not setting('mediaSecret'):
|
||||
set_setting('mediaSecret', secrets.token_hex(32))
|
||||
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
|
||||
if not setting('account') and not (DATA / 'setup-code.txt').exists():
|
||||
if not objects('users') and not (DATA / 'setup-code.txt').exists():
|
||||
(DATA / 'setup-code.txt').write_text(secrets.token_hex(12), encoding='utf8')
|
||||
os.chmod(DATA / 'setup-code.txt', 0o600)
|
||||
write_media_config()
|
||||
@@ -682,9 +827,15 @@ def initialize():
|
||||
if __name__ == '__main__':
|
||||
initialize()
|
||||
threading.Thread(target=storage_watch, daemon=True).start()
|
||||
bind = os.environ.get('VISION_BIND', '127.0.0.1')
|
||||
threading.Thread(target=media_config_watch, daemon=True).start()
|
||||
bindings = [h.strip() for h in os.environ.get('VISION_BIND', '127.0.0.1').split(',') if h.strip()]
|
||||
port = int(os.environ.get('VISION_PORT', '8790'))
|
||||
server = ThreadingHTTPServer((bind, port), Handler)
|
||||
server.daemon_threads = True
|
||||
print('ZhaoVision listening on ' + bind + ':' + str(port), flush=True)
|
||||
server.serve_forever()
|
||||
servers = []
|
||||
for bind in dict.fromkeys(bindings):
|
||||
server = ThreadingHTTPServer((bind, port), Handler)
|
||||
server.daemon_threads = True
|
||||
servers.append(server)
|
||||
print('ZhaoVision listening on ' + bind + ':' + str(port), flush=True)
|
||||
for server in servers[1:]:
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
servers[0].serve_forever()
|
||||
|
||||
+17
-3
@@ -1,19 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Run from a committed, published release checkout on an x86_64 Ubuntu host.
|
||||
# VISION_BIND is the host's Tailscale IPv4; defaults to loopback.
|
||||
# VISION_BIND is a comma-separated list of explicit private/Tailscale IPv4s.
|
||||
# MEDIA_ARCHIVE optionally points to an already downloaded pinned release archive.
|
||||
[[ $(id -u) == 0 ]] || { echo 'Run as root'; exit 1; }
|
||||
[[ $(uname -m) == x86_64 ]] || { echo 'This installer targets x86_64'; exit 1; }
|
||||
command -v python3 >/dev/null
|
||||
if ! command -v ffmpeg >/dev/null; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg
|
||||
fi
|
||||
if [[ -f /sys/class/drm/renderD128/device/vendor ]] && [[ $(cat /sys/class/drm/renderD128/device/vendor) == 0x8086 ]]; then
|
||||
if ! dpkg-query -W -f='${Status}' intel-media-va-driver-non-free 2>/dev/null | grep -q 'install ok installed'; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends intel-media-va-driver-non-free
|
||||
fi
|
||||
fi
|
||||
release_dir=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
bind_address=${VISION_BIND:-127.0.0.1}
|
||||
python3 - "$bind_address" <<'PY'
|
||||
import ipaddress,sys
|
||||
ip=ipaddress.ip_address(sys.argv[1])
|
||||
assert ip.version==4 and (ip.is_loopback or ip in ipaddress.ip_network('100.64.0.0/10')), 'Use a Tailscale or loopback address'
|
||||
for value in sys.argv[1].split(','):
|
||||
ip=ipaddress.ip_address(value.strip())
|
||||
assert ip.version==4 and not ip.is_unspecified and not ip.is_multicast and (ip.is_private or ip in ipaddress.ip_network('100.64.0.0/10')), 'Use explicit private or Tailscale addresses'
|
||||
PY
|
||||
getent passwd zhaovision >/dev/null || useradd --system --home-dir /var/lib/zhaovision --shell /usr/sbin/nologin zhaovision
|
||||
if getent group render >/dev/null; then
|
||||
usermod -a -G render zhaovision
|
||||
fi
|
||||
install -d -o zhaovision -g zhaovision -m 700 /var/lib/zhaovision /var/lib/zhaovision/recordings
|
||||
install -d -m 755 /opt/zhaovision /usr/local/lib/zhaovision
|
||||
archive=${MEDIA_ARCHIVE:-/tmp/mediamtx_v1.21.1_linux_amd64.tar.gz}
|
||||
|
||||
@@ -9,16 +9,19 @@ User=zhaovision
|
||||
Group=zhaovision
|
||||
WorkingDirectory=/var/lib/zhaovision
|
||||
Environment=TZ=UTC
|
||||
EnvironmentFile=/etc/zhaovision.env
|
||||
ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml
|
||||
Restart=always
|
||||
RestartSec=4
|
||||
TimeoutStopSec=15s
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/zhaovision
|
||||
MemoryMax=1200M
|
||||
MemoryHigh=1500M
|
||||
MemoryMax=1800M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -21,7 +21,8 @@ PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/zhaovision
|
||||
MemoryMax=512M
|
||||
MemoryMax=900M
|
||||
TimeoutStopSec=15s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
"""A private family register, independent from login accounts and camera roles."""
|
||||
import datetime as dt
|
||||
import json
|
||||
import secrets
|
||||
|
||||
|
||||
class Family:
|
||||
def __init__(self, app):
|
||||
self.a = app
|
||||
|
||||
def initialize(self):
|
||||
with self.a.LOCK:
|
||||
self.a.DB.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
||||
''')
|
||||
self.a.DB.commit()
|
||||
|
||||
def authorize(self, user, edit=False):
|
||||
if user['role'] != 'admin' and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
|
||||
raise self.a.Problem('没有家谱' + ('编辑' if edit else '查看') + '权限', 403)
|
||||
|
||||
def snapshot(self, user):
|
||||
self.authorize(user)
|
||||
with self.a.LOCK:
|
||||
return {'revision': self.a.setting('familyRevision', 0), 'people': self.a.objects('people'),
|
||||
'links': self.a.objects('family_links'),
|
||||
'canEdit': user['role'] == 'admin' or user.get('familyAccess') == 'edit',
|
||||
'selfId': user.get('personId', '')}
|
||||
|
||||
def save(self, kind, data, user):
|
||||
self.authorize(user, True)
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
revision = a.setting('familyRevision', 0)
|
||||
if type(data.get('revision')) is not int or data['revision'] != revision:
|
||||
raise a.Problem('家谱已被更新,请重新载入后再编辑', 409)
|
||||
table = 'people' if kind == 'person' else 'family_links'
|
||||
old = a.get_object(table, data.get('id'))
|
||||
if data.get('id') and not old:
|
||||
raise a.Problem('家谱记录不存在', 404)
|
||||
item = self.person(data, old) if kind == 'person' else self.link(data, old)
|
||||
item.update(updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
|
||||
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
|
||||
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
|
||||
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
|
||||
a.DB.commit()
|
||||
a.audit('更新家谱人物' if kind == 'person' else '更新家谱关系', user['username'])
|
||||
return {'item': item, 'revision': revision + 1}
|
||||
|
||||
def person(self, data, old):
|
||||
a = self.a
|
||||
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
||||
for key, maximum in [('name', 80), ('alias', 120), ('birthDate', 10), ('deathDate', 10),
|
||||
('birthplace', 160), ('biography', 2000), ('note', 500)]:
|
||||
value = data.get(key, '')
|
||||
if key in ('biography', 'note'):
|
||||
if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
|
||||
raise a.Problem('生平或备注格式不正确')
|
||||
item[key] = value.strip()
|
||||
else:
|
||||
item[key] = a.clean_text(value, maximum, key == 'name')
|
||||
for key in ('birthDate', 'deathDate'):
|
||||
if item[key]:
|
||||
try:
|
||||
if dt.date.fromisoformat(item[key]).isoformat() != item[key]:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise a.Problem('日期请使用公历 YYYY-MM-DD;不确定可留空,在备注记录')
|
||||
if item['birthDate'] and item['deathDate'] and item['birthDate'] > item['deathDate']:
|
||||
raise a.Problem('逝世日期不能早于出生日期')
|
||||
item['gender'] = data.get('gender', 'unknown')
|
||||
item['lifeStatus'] = data.get('lifeStatus', 'alive')
|
||||
if item['gender'] not in ('male', 'female', 'unknown') or item['lifeStatus'] not in ('alive', 'deceased', 'unknown'):
|
||||
raise a.Problem('人物属性不正确')
|
||||
if item['deathDate'] and item['lifeStatus'] != 'deceased':
|
||||
raise a.Problem('填写逝世日期时,请选择已故')
|
||||
item['archived'] = data.get('archived', False)
|
||||
if not isinstance(item['archived'], bool):
|
||||
raise a.Problem('归档状态不正确')
|
||||
return item
|
||||
|
||||
def link(self, data, old):
|
||||
a = self.a
|
||||
left, right = data.get('fromId'), data.get('toId')
|
||||
if not isinstance(left, str) or not isinstance(right, str) or not a.get_object('people', left) or not a.get_object('people', right):
|
||||
raise a.Problem('请先录入关系双方的人物')
|
||||
if left == right:
|
||||
raise a.Problem('不能把人物与自己建立亲属关系')
|
||||
kind = data.get('kind')
|
||||
if kind not in ('parent', 'spouse'):
|
||||
raise a.Problem('关系类型不正确')
|
||||
lineage = data.get('lineage', 'unspecified')
|
||||
if lineage not in ('biological', 'adoptive', 'step', 'unspecified'):
|
||||
raise a.Problem('亲子关系属性不正确')
|
||||
active = data.get('active', True)
|
||||
if not isinstance(active, bool):
|
||||
raise a.Problem('关系状态不正确')
|
||||
item = dict(id=old['id'] if old else secrets.token_hex(8), fromId=left, toId=right, kind=kind,
|
||||
lineage=lineage if kind == 'parent' else 'unspecified', active=active,
|
||||
note=a.clean_text(data.get('note', ''), 300))
|
||||
links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']]
|
||||
if active:
|
||||
for e in links:
|
||||
same = (e['fromId'], e['toId']) == (left, right)
|
||||
if kind == 'spouse':
|
||||
same = {e['fromId'], e['toId']} == {left, right}
|
||||
if e['kind'] == kind and same:
|
||||
raise a.Problem('这条关系已经存在', 409)
|
||||
graph = {}
|
||||
for e in links + [item]:
|
||||
if e['kind'] == 'parent':
|
||||
graph.setdefault(e['fromId'], []).append(e['toId'])
|
||||
# Iterative walk supports deep ancestry without Python recursion limits.
|
||||
def reaches(start, target):
|
||||
pending, seen = [start], set()
|
||||
while pending:
|
||||
p = pending.pop()
|
||||
if p in seen:
|
||||
continue
|
||||
seen.add(p)
|
||||
for child in graph.get(p, []):
|
||||
if child == target:
|
||||
return True
|
||||
pending.append(child)
|
||||
return False
|
||||
if kind == 'parent' and reaches(right, left):
|
||||
raise a.Problem('这条关系会形成祖先与后代循环,请检查方向')
|
||||
for e in links + [item]:
|
||||
if e['kind'] == 'spouse' and (reaches(e['fromId'], e['toId']) or reaches(e['toId'], e['fromId'])):
|
||||
raise a.Problem('配偶不能同时是直系祖先或后代,请检查已有关系')
|
||||
return item
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"""Local accounts. Passwords never appear in public account representations."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
|
||||
|
||||
class Accounts:
|
||||
def __init__(self, app):
|
||||
self.a = app
|
||||
|
||||
def initialize(self):
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
a.DB.execute('CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
|
||||
if 'user_id' not in [r['name'] for r in a.DB.execute('PRAGMA table_info(sessions)')]:
|
||||
a.DB.execute('ALTER TABLE sessions ADD COLUMN user_id TEXT')
|
||||
legacy = a.setting('account')
|
||||
if legacy and not a.objects('users'):
|
||||
user = dict(id=secrets.token_hex(8), username='admin', name='管理员', role='admin',
|
||||
disabled=False, siteIds=[], familyAccess='edit', personId='',
|
||||
relatedToId='', relationship='', **legacy)
|
||||
a.DB.execute('INSERT INTO users VALUES (?,?)', (user['id'], json.dumps(user)))
|
||||
a.DB.execute('UPDATE sessions SET user_id=? WHERE user_id IS NULL', (user['id'],))
|
||||
a.DB.execute("DELETE FROM settings WHERE key='account'")
|
||||
a.DB.commit()
|
||||
|
||||
@staticmethod
|
||||
def public(user):
|
||||
return {k: v for k, v in user.items() if k not in ('salt', 'hash')} if user else None
|
||||
|
||||
def password(self, password):
|
||||
if not isinstance(password, str) or not 8 <= len(password) <= 128:
|
||||
raise self.a.Problem('密码需要 8 至 128 个字符')
|
||||
salt = secrets.token_hex(16)
|
||||
return {'salt': salt, 'hash': hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()}
|
||||
|
||||
def verify(self, username, password):
|
||||
if not isinstance(username, str) or not isinstance(password, str) or len(password) > 128:
|
||||
return None
|
||||
user = next((u for u in self.a.objects('users') if u['username'] == username.strip().lower()), None)
|
||||
# Also perform derivation for nonexistent accounts.
|
||||
salt = user['salt'] if user else '0' * 32
|
||||
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
|
||||
return user if user and not user['disabled'] and hmac.compare_digest(derived, user['hash']) else None
|
||||
|
||||
def save(self, data, actor):
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
old = a.get_object('users', data.get('id'))
|
||||
if data.get('id') and not old:
|
||||
raise a.Problem('账号不存在', 404)
|
||||
uid = old['id'] if old else secrets.token_hex(8)
|
||||
username = a.clean_text(data.get('username', ''), 32, True).lower()
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,31}', username):
|
||||
raise a.Problem('用户名使用 3 至 32 位英文字母、数字、点、下划线或短横线')
|
||||
if any(u['username'] == username and u['id'] != uid for u in a.objects('users')):
|
||||
raise a.Problem('用户名已存在', 409)
|
||||
role = data.get('role', 'member')
|
||||
access = data.get('familyAccess', 'none')
|
||||
if role not in ('admin', 'member') or access not in ('none', 'read', 'edit'):
|
||||
raise a.Problem('权限选项不正确')
|
||||
sites = data.get('siteIds', [])
|
||||
if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites):
|
||||
raise a.Problem('请选择有效空间')
|
||||
ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False)
|
||||
if not isinstance(ptz_control, bool):
|
||||
raise a.Problem('云台权限格式不正确')
|
||||
disabled = data.get('disabled', False)
|
||||
if not isinstance(disabled, bool):
|
||||
raise a.Problem('账号状态不正确')
|
||||
if old and old['role'] == 'admin' and (role != 'admin' or disabled):
|
||||
if not any(u['id'] != uid and u['role'] == 'admin' and not u['disabled'] for u in a.objects('users')):
|
||||
raise a.Problem('至少保留一个启用的管理员')
|
||||
if actor and actor['id'] == uid and (disabled or role != actor['role']):
|
||||
raise a.Problem('不能停用或降低当前登录账号的管理权限')
|
||||
related = a.clean_text(data.get('relatedToId', ''), 32)
|
||||
person = a.clean_text(data.get('personId', ''), 32)
|
||||
if related and related != uid and not a.get_object('users', related):
|
||||
raise a.Problem('关系参照账号不存在')
|
||||
if person:
|
||||
if not a.get_object('people', person):
|
||||
raise a.Problem('家谱人物不存在')
|
||||
if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')):
|
||||
raise a.Problem('这个人物已经关联其他账号')
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str):
|
||||
raise a.Problem('密码格式不正确')
|
||||
credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')}
|
||||
user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True),
|
||||
role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
|
||||
personId=person, relatedToId=related,
|
||||
relationship=a.clean_text(data.get('relationship', ''), 40), **credentials)
|
||||
# Revoke sessions when credentials or authorization change, not for label edits.
|
||||
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl')):
|
||||
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
|
||||
a.save_object('users', user)
|
||||
a.audit('更新账号' if old else '建立账号', username)
|
||||
return self.public(user)
|
||||
|
||||
def camera(self, user, camera):
|
||||
if not camera:
|
||||
raise self.a.Problem('摄像头不存在', 404)
|
||||
if user['role'] != 'admin' and camera.get('siteId') not in user['siteIds']:
|
||||
raise self.a.Problem('没有这个空间的访问权限', 403)
|
||||
return camera
|
||||
|
||||
def control(self, user, camera):
|
||||
self.camera(user, camera)
|
||||
if user['role'] != 'admin' and not user.get('ptzControl', False):
|
||||
raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403)
|
||||
if not camera.get('enabled'):
|
||||
raise self.a.Problem('摄像头已停用', 409)
|
||||
return camera
|
||||
|
||||
def state(self, user, state):
|
||||
state['user'] = self.public(user)
|
||||
if user['role'] == 'admin':
|
||||
return state
|
||||
permitted = set(user['siteIds'])
|
||||
state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
|
||||
state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted]
|
||||
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource')
|
||||
state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
|
||||
state['recorders'] = []
|
||||
state['scan'] = {}
|
||||
state['storage'] = {}
|
||||
return state
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"""Bounded, ephemeral H.264 playback; original recordings remain untouched."""
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
SLOTS = threading.BoundedSemaphore(2)
|
||||
|
||||
|
||||
class PlaybackError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def command(source, authorization, duration):
|
||||
executable = shutil.which('ffmpeg')
|
||||
if not executable:
|
||||
raise PlaybackError('回放转换需要服务器安装 FFmpeg')
|
||||
device = os.environ.get('VISION_VAAPI_DEVICE', '/dev/dri/renderD128')
|
||||
acceleration = []
|
||||
if os.access(device, os.R_OK | os.W_OK):
|
||||
acceleration = ['-hwaccel', 'vaapi', '-hwaccel_device', device,
|
||||
'-hwaccel_output_format', 'vaapi', '-extra_hw_frames', '8']
|
||||
video = ['-vf', 'fps=15,scale_vaapi=w=1920:h=1080:format=nv12:force_original_aspect_ratio=decrease:force_divisible_by=2',
|
||||
'-c:v', 'h264_vaapi', '-profile:v', 'main', '-level:v', '4.1',
|
||||
'-rc_mode', 'CQP', '-qp', '25', '-g', '30', '-bf', '0']
|
||||
else:
|
||||
video = ['-vf', 'fps=10,scale=768:432:force_original_aspect_ratio=decrease:force_divisible_by=2',
|
||||
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
|
||||
'-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
|
||||
'-crf', '26', '-g', '20', '-bf', '0']
|
||||
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
|
||||
'-threads', '1', '-filter_threads', '1'] + acceleration + [
|
||||
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
|
||||
'-readrate', '1', '-i', source, '-t', str(duration),
|
||||
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
|
||||
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
|
||||
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
|
||||
'-frag_duration', '1000000', '-f', 'mp4', 'pipe:1']
|
||||
|
||||
|
||||
@contextmanager
|
||||
def stream(source, authorization, duration):
|
||||
if not SLOTS.acquire(blocking=False):
|
||||
raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试')
|
||||
process = timer = None
|
||||
try:
|
||||
# No shell and no diagnostics containing authentication headers in logs.
|
||||
process = subprocess.Popen(command(source, authorization, duration),
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL)
|
||||
timer = threading.Timer(duration + 45, process.kill)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
yield process.stdout
|
||||
except OSError as exc:
|
||||
raise PlaybackError('录像转换服务暂不可用') from exc
|
||||
finally:
|
||||
if timer:
|
||||
timer.cancel()
|
||||
if process:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
SLOTS.release()
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
"""On-demand H.264/AAC previews; main-stream HD uses Intel VAAPI."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def main():
|
||||
name = os.environ.get('MTX_PATH', '')
|
||||
match = re.fullmatch(r'(cam_[a-f0-9]{16})_(compat|hd)', name)
|
||||
if not match:
|
||||
raise SystemExit('Invalid preview path')
|
||||
data = Path(sys.argv[1]).resolve()
|
||||
config = json.loads((data / 'mediamtx.yml').read_text(encoding='utf8'))
|
||||
hd = match[2] == 'hd'
|
||||
original = match[1] + ('_main' if hd else '_sub')
|
||||
if config['paths'].get(original, {}).get('source', 'publisher') == 'publisher':
|
||||
raise SystemExit('Camera is disabled')
|
||||
secret = next(u['pass'] for u in config['authInternalUsers'] if u['user'] == 'vision')
|
||||
base = 'rtsp://vision:' + quote(secret, safe='') + '@127.0.0.1:18554/'
|
||||
executable = shutil.which('ffmpeg')
|
||||
if not executable:
|
||||
raise SystemExit('Install FFmpeg to enable compatible preview')
|
||||
# HD reads the actual main stream. Decode, resize and encode on the GPU;
|
||||
# never silently start six high-resolution software decoders on a small CPU.
|
||||
acceleration = []
|
||||
if hd:
|
||||
device = os.environ.get('VISION_VAAPI_DEVICE', '/dev/dri/renderD128')
|
||||
if not os.access(device, os.R_OK | os.W_OK):
|
||||
raise SystemExit('HD preview requires an accessible VAAPI render device')
|
||||
acceleration = ['-hwaccel', 'vaapi', '-hwaccel_device', device,
|
||||
'-hwaccel_output_format', 'vaapi', '-extra_hw_frames', '8']
|
||||
video = ['-vf', 'fps=15,scale_vaapi=w=1920:h=1080:format=nv12:force_original_aspect_ratio=decrease:force_divisible_by=2',
|
||||
'-c:v', 'h264_vaapi', '-profile:v', 'main', '-level:v', '4.1',
|
||||
# Jasper Lake can expose CQP only when HuC bitrate control is
|
||||
# unavailable; fixed quality does not require a firmware change.
|
||||
'-rc_mode', 'CQP', '-qp', '25', '-g', '30', '-bf', '0']
|
||||
else:
|
||||
video = ['-vf', 'fps=10,scale=768:432:force_original_aspect_ratio=decrease:force_divisible_by=2',
|
||||
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
|
||||
'-tune', 'zerolatency', '-profile:v', 'baseline', '-level:v', '3.1',
|
||||
'-pix_fmt', 'yuv420p', '-crf', '26', '-maxrate', '1100k',
|
||||
'-bufsize', '2200k', '-g', '20', '-keyint_min', '20', '-sc_threshold', '0']
|
||||
command = [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
|
||||
'-threads', '1', '-filter_threads', '1'] + acceleration + [
|
||||
'-rtsp_transport', 'tcp', '-timeout', '15000000', '-i', base + original,
|
||||
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
|
||||
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '48k',
|
||||
'-f', 'rtsp', '-rtsp_transport', 'tcp', base + name]
|
||||
process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, text=True, errors='replace')
|
||||
|
||||
def stop(_signum, _frame):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
try:
|
||||
for line in process.stderr:
|
||||
# Never write connection credentials from FFmpeg diagnostics to logs.
|
||||
clean = re.sub(r'rtsp://[^\s]+', '[local stream]', line.replace(secret, '[redacted]'))
|
||||
print(name + ': ' + clean.rstrip(), file=sys.stderr, flush=True)
|
||||
return process.wait()
|
||||
finally:
|
||||
stop(None, None)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement."""
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse as url
|
||||
from xml.sax.saxutils import escape
|
||||
import onvif
|
||||
|
||||
PTZ = 'http://www.onvif.org/ver20/ptz/wsdl'
|
||||
SPACE = 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace'
|
||||
T = '{' + onvif.SCHEMA + '}'
|
||||
DIRECTIONS = {'left': (-0.25, 0), 'right': (0.25, 0), 'up': (0, 0.25), 'down': (0, -0.25)}
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self):
|
||||
self.guard = threading.RLock()
|
||||
self.cache = {}
|
||||
self.locks = {}
|
||||
|
||||
def call(self, c, endpoint, namespace, operation, body=''):
|
||||
port, path = endpoint
|
||||
return onvif.request(c['host'], port, path, namespace, operation, body, c['username'], c['password'])
|
||||
|
||||
@staticmethod
|
||||
def endpoint(c, text):
|
||||
parsed = url.urlsplit(text)
|
||||
if parsed.scheme != 'http' or parsed.hostname != c['host'] or parsed.username or parsed.password:
|
||||
raise ValueError('设备返回了不匹配的云台服务地址')
|
||||
return parsed.port or 80, parsed.path + ('?' + parsed.query if parsed.query else '')
|
||||
|
||||
def discover(self, c):
|
||||
if c.get('sourceKind', 'direct') != 'direct':
|
||||
raise ValueError('请使用摄像头直连通道控制云台')
|
||||
key = (c['host'], c.get('onvifPort', 80), c['username'], hashlib.sha256(c['password'].encode()).hexdigest(), c['mainPath'])
|
||||
with self.guard:
|
||||
old = self.cache.get(key)
|
||||
if old and old[0] > time.monotonic():
|
||||
return old[1]
|
||||
caps = self.call(c, (c.get('onvifPort', 80), '/onvif/device_service'), onvif.DEVICE, 'GetCapabilities', '<m:Category>All</m:Category>')
|
||||
def service(name):
|
||||
value = caps.find('.//' + T + name + '/' + T + 'XAddr')
|
||||
if value is None or not value.text:
|
||||
raise ValueError('设备没有提供云台服务')
|
||||
return self.endpoint(c, value.text)
|
||||
endpoint, media = service('PTZ'), service('Media')
|
||||
profiles = self.call(c, media, onvif.MEDIA, 'GetProfiles')
|
||||
available = [p for p in profiles.findall('.//{' + onvif.MEDIA + '}Profiles') if p.find(T + 'PTZConfiguration') is not None]
|
||||
wanted = url.parse_qs(url.urlsplit(c['mainPath']).query).get('profile', [''])[0]
|
||||
selected = next((p for p in available if p.get('token') == wanted), None)
|
||||
if selected is None:
|
||||
for p in available[:32]:
|
||||
body = '<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(p.get('token', '')) + '</m:ProfileToken>'
|
||||
root = self.call(c, media, onvif.MEDIA, 'GetStreamUri', body)
|
||||
uri = root.find('.//' + T + 'Uri')
|
||||
if uri is not None and url.urlsplit(uri.text).path == url.urlsplit(c['mainPath']).path:
|
||||
selected = p
|
||||
break
|
||||
if selected is None:
|
||||
raise ValueError('这个镜头没有匹配的云台配置')
|
||||
conf = selected.find(T + 'PTZConfiguration')
|
||||
options = self.call(c, endpoint, PTZ, 'GetConfigurationOptions', '<m:ConfigurationToken>' + escape(conf.get('token')) + '</m:ConfigurationToken>')
|
||||
spaces = options.findall('.//' + T + 'ContinuousPanTiltVelocitySpace')
|
||||
space = next((s for s in spaces if s.findtext(T + 'URI') == SPACE), None)
|
||||
if space is None:
|
||||
raise ValueError('设备不支持通用方向微调')
|
||||
for axis in ('XRange', 'YRange'):
|
||||
if float(space.findtext(T + axis + '/' + T + 'Min')) > -0.25 or float(space.findtext(T + axis + '/' + T + 'Max')) < 0.25:
|
||||
raise ValueError('设备云台速度范围不兼容')
|
||||
# Do not issue movement unless the device advertises a one-second watchdog.
|
||||
minimum = options.findtext('.//' + T + 'PTZTimeout/' + T + 'Min')
|
||||
maximum = options.findtext('.//' + T + 'PTZTimeout/' + T + 'Max')
|
||||
def seconds(value):
|
||||
import re
|
||||
m = re.fullmatch(r'PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?', value or '')
|
||||
return sum(float(v or 0) * scale for v, scale in zip(m.groups(), (3600, 60, 1))) if m else -1
|
||||
if not 0 <= seconds(minimum) <= 1 <= seconds(maximum):
|
||||
raise ValueError('设备不支持一秒自动停止保护')
|
||||
result = {'endpoint': endpoint, 'profile': selected.get('token'), 'node': conf.findtext(T + 'NodeToken'), 'supported': True}
|
||||
with self.guard:
|
||||
if len(self.cache) > 128:
|
||||
self.cache.clear()
|
||||
self.cache[key] = (time.monotonic() + 300, result)
|
||||
return result
|
||||
|
||||
def capabilities(self, c):
|
||||
self.discover(c)
|
||||
return {'supported': True, 'directions': list(DIRECTIONS), 'mode': 'nudge',
|
||||
'note': '点击一次短距离转动并自动停止。同一摄像头的镜头可能共用云台。'}
|
||||
|
||||
def command(self, c, action):
|
||||
if action not in (*DIRECTIONS, 'stop'):
|
||||
raise ValueError('请选择上、下、左、右或停止')
|
||||
info = self.discover(c)
|
||||
body = '<m:ProfileToken>' + escape(info['profile']) + '</m:ProfileToken>'
|
||||
def stop():
|
||||
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt>')
|
||||
if action == 'stop':
|
||||
stop()
|
||||
return {'ok': True, 'stopped': True}
|
||||
# Lock by physical host/node, including commands sent through another lens/account.
|
||||
key = (c['host'], info['endpoint'][0], info['node'])
|
||||
with self.guard:
|
||||
lock = self.locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ValueError('云台正在执行操作,请稍后再点')
|
||||
try:
|
||||
x, y = DIRECTIONS[action]
|
||||
try:
|
||||
self.call(c, info['endpoint'], PTZ, 'ContinuousMove', body +
|
||||
f'<m:Velocity><tt:PanTilt x="{x}" y="{y}" space="{SPACE}"/></m:Velocity><m:Timeout>PT1S</m:Timeout>')
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
# Also stop after an ambiguous network failure. Device timeout is a second guard.
|
||||
stop()
|
||||
return {'ok': True, 'stopped': True}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
CONTROLLER = Controller()
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
"""Isolated regression tests: python -m unittest -v test_app."""
|
||||
import hashlib
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
_data = tempfile.TemporaryDirectory()
|
||||
os.environ['VISION_DATA'] = _data.name
|
||||
os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
|
||||
import app
|
||||
import playback
|
||||
|
||||
|
||||
class WebTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
app.initialize()
|
||||
cls.admin = app.ACCOUNTS.save({'username': 'admin', 'password': 'test-password', 'role': 'admin'}, None)
|
||||
cls.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)',
|
||||
(hashlib.sha256(b'f' * 64).hexdigest(), time.time() + 3600, cls.admin['id']))
|
||||
app.DB.commit()
|
||||
app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'siteId': 'test-site', 'password': 'private'})
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join()
|
||||
app.DB.close()
|
||||
_data.cleanup()
|
||||
|
||||
def request(self, path, authenticated=True, data=None, origin=None):
|
||||
c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
|
||||
headers = {'Cookie': 'vision=' + 'f' * 64} if authenticated else {}
|
||||
if origin:
|
||||
headers['Origin'] = origin
|
||||
if data is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
c.request('GET' if data is None else 'POST', path,
|
||||
None if data is None else json.dumps(data), headers)
|
||||
r = c.getresponse()
|
||||
result = r.status, dict(r.getheaders()), r.read()
|
||||
c.close()
|
||||
return result
|
||||
|
||||
def test_media_and_api_require_login(self):
|
||||
for path in ['/api/state', '/api/recordings', '/media/playback',
|
||||
'/media/live/cam_' + 'a' * 16 + '_hd/index.m3u8']:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.request(path, False)[0], 401)
|
||||
|
||||
def test_static_security_headers(self):
|
||||
code, headers, _ = self.request('/', False)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(headers['X-Frame-Options'], 'DENY')
|
||||
self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
|
||||
|
||||
def test_live_quality_has_only_main_and_sub(self):
|
||||
code, _, body = self.request('/', False)
|
||||
self.assertEqual(code, 200)
|
||||
select = re.search(r'<select id="quality">(.*?)</select>', body.decode()).group(1)
|
||||
self.assertEqual(re.findall(r'<option value="(.*?)">(.*?)</option>', select),
|
||||
[('hd', '主码流'), ('compat', '子码流')])
|
||||
|
||||
def test_origin_rejected(self):
|
||||
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
|
||||
|
||||
def test_invalid_ranges_and_paths(self):
|
||||
base = '/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration='
|
||||
for duration in ['nan', 'inf', '-1', '3601']:
|
||||
self.assertEqual(self.request(base + duration)[0], 400)
|
||||
self.assertEqual(self.request('/media/live/../../vision.db')[0], 400)
|
||||
self.assertEqual(self.request('/vision.db', False)[0], 404)
|
||||
|
||||
def test_compatible_playback_dispatch(self):
|
||||
calls = []
|
||||
def compatible(handler, path, duration):
|
||||
calls.append((path, duration))
|
||||
handler.answer({'ok': True})
|
||||
with patch.object(app.Handler, 'compatible_playback', compatible):
|
||||
code, _, _ = self.request('/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(calls[0][1], 10)
|
||||
self.assertIn('format=fmp4', calls[0][0])
|
||||
|
||||
def test_original_download_dispatch(self):
|
||||
calls = []
|
||||
def proxy(handler, port, path, download=False):
|
||||
calls.append((path, download))
|
||||
handler.answer({'ok': True})
|
||||
with patch.object(app.Handler, 'proxy', proxy):
|
||||
code, _, _ = self.request('/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10&download=1')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn('format=mp4', calls[0][0])
|
||||
self.assertTrue(calls[0][1])
|
||||
|
||||
def test_stable_object_order(self):
|
||||
app.save_object('sites', {'id': 'b', 'name': 'Second'})
|
||||
app.save_object('sites', {'id': 'a', 'name': 'First'})
|
||||
first = app.objects('sites')
|
||||
app.save_object('sites', first[0])
|
||||
self.assertEqual(app.objects('sites'), first)
|
||||
|
||||
def test_password_redacted(self):
|
||||
self.assertNotIn('password', app.public_camera(app.get_object('cameras', 'a' * 16)))
|
||||
|
||||
def test_accounts_and_authorization(self):
|
||||
member = app.ACCOUNTS.save({'username': 'member', 'password': 'password8', 'familyAccess': 'read'}, self.admin)
|
||||
self.assertNotIn('hash', member)
|
||||
self.assertNotIn('salt', member)
|
||||
self.assertIsNotNone(app.ACCOUNTS.verify('member', 'password8'))
|
||||
self.assertIsNone(app.ACCOUNTS.verify('member', 'incorrect'))
|
||||
code, _, body = self.request('/api/users')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertNotIn(b'"hash"', body)
|
||||
token='e'*64
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)',(hashlib.sha256(token.encode()).hexdigest(),time.time()+3600,member['id']))
|
||||
app.DB.commit()
|
||||
def request(path,data=None):
|
||||
c=http.client.HTTPConnection(*self.server.server_address,timeout=3)
|
||||
c.request('POST' if data is not None else 'GET',path,None if data is None else json.dumps(data),
|
||||
{'Cookie':'vision='+token,'Content-Type':'application/json'})
|
||||
r=c.getresponse();status=r.status;r.read();c.close();return status
|
||||
for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8',
|
||||
'/api/recordings?camera='+'a'*16]:
|
||||
self.assertEqual(request(path),403)
|
||||
for path in ['/api/storage','/api/users','/api/family/person']:
|
||||
self.assertEqual(request(path,{}),403)
|
||||
self.assertEqual(request('/api/family'),200)
|
||||
app.ACCOUNTS.save(dict(member,disabled=True),self.admin)
|
||||
self.assertEqual(request('/api/family'),401)
|
||||
|
||||
def test_account_constraints(self):
|
||||
with self.assertRaises(app.Problem):
|
||||
app.ACCOUNTS.save(dict(self.admin,disabled=True),self.admin)
|
||||
with self.assertRaises(app.Problem):
|
||||
app.ACCOUNTS.save({'username':'admin','password':'password8'},self.admin)
|
||||
with self.assertRaises(app.Problem):
|
||||
app.ACCOUNTS.save({'username':'short','password':'123'},self.admin)
|
||||
|
||||
def test_family_graph_validation_and_revision(self):
|
||||
def save(kind,data):
|
||||
return app.FAMILY.save(kind,dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
|
||||
p=save('person',{'name':'祖辈','biography':'第一行\n第二行'})
|
||||
q=save('person',{'name':'子辈'})
|
||||
r=save('person',{'name':'孙辈'})
|
||||
first=save('link',{'fromId':p['id'],'toId':q['id'],'kind':'parent'})
|
||||
save('link',{'fromId':q['id'],'toId':r['id'],'kind':'parent'})
|
||||
for value in [{'fromId':r['id'],'toId':p['id'],'kind':'parent'},
|
||||
{'fromId':p['id'],'toId':p['id'],'kind':'parent'},
|
||||
{'fromId':p['id'],'toId':r['id'],'kind':'spouse'},
|
||||
{'fromId':p['id'],'toId':q['id'],'kind':'parent'}]:
|
||||
with self.assertRaises(app.Problem):save('link',value)
|
||||
with self.assertRaises(app.Problem):
|
||||
app.FAMILY.save('person',{'name':'过期编辑','revision':0},self.admin)
|
||||
with self.assertRaises(app.Problem):
|
||||
save('person',{'name':'错误日期','birthDate':'2026-02-30'})
|
||||
with self.assertRaises(app.Problem):
|
||||
save('person',{'name':'错误日期','birthDate':'2020-01-01','deathDate':'1900-01-01','lifeStatus':'deceased'})
|
||||
save('link',dict(first,active=False))
|
||||
self.assertFalse(app.get_object('family_links',first['id'])['active'])
|
||||
save('link',dict(first,active=True))
|
||||
save('person',dict(p,archived=True))
|
||||
self.assertTrue(app.get_object('people',p['id'])['archived'])
|
||||
save('person',dict(p,archived=False))
|
||||
|
||||
def test_legacy_migration_preserves_password_and_sessions(self):
|
||||
# Exercise migration in a separate database without touching the HTTP fixture.
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
db=sqlite3.connect(Path(folder)/'migration.db');db.row_factory=sqlite3.Row
|
||||
db.executescript('CREATE TABLE settings(key TEXT PRIMARY KEY,value TEXT NOT NULL);CREATE TABLE sessions(hash TEXT PRIMARY KEY,expires REAL NOT NULL);')
|
||||
credentials=app.ACCOUNTS.password('old-password')
|
||||
db.execute('INSERT INTO settings VALUES (?,?)',('account',json.dumps(credentials)))
|
||||
db.execute('INSERT INTO sessions VALUES (?,?)',('legacy',time.time()+60));db.commit()
|
||||
with patch.object(app,'DB',db):
|
||||
app.ACCOUNTS.initialize();app.ACCOUNTS.initialize()
|
||||
self.assertEqual(len(app.objects('users')),1)
|
||||
self.assertIsNotNone(app.ACCOUNTS.verify('admin','old-password'))
|
||||
self.assertEqual(db.execute('SELECT user_id FROM sessions').fetchone()[0],app.objects('users')[0]['id'])
|
||||
self.assertIsNone(app.setting('account'))
|
||||
db.close()
|
||||
|
||||
def test_playback_has_bound_and_cleanup(self):
|
||||
playback.SLOTS.acquire()
|
||||
playback.SLOTS.acquire()
|
||||
try:
|
||||
with self.assertRaises(playback.PlaybackError):
|
||||
with playback.stream('unused', 'private', 10):
|
||||
self.fail('No third conversion allowed')
|
||||
finally:
|
||||
playback.SLOTS.release()
|
||||
playback.SLOTS.release()
|
||||
with patch('playback.shutil.which', return_value=None):
|
||||
with self.assertRaises(playback.PlaybackError):
|
||||
with playback.stream('unused', 'private', 10):
|
||||
pass
|
||||
self.assertTrue(playback.SLOTS.acquire(blocking=False))
|
||||
playback.SLOTS.release()
|
||||
|
||||
def test_vaapi_uses_supported_cqp(self):
|
||||
with patch('playback.shutil.which', return_value='ffmpeg'), patch('playback.os.access', return_value=True):
|
||||
command = playback.command('http://127.0.0.1/source', 'private', 10)
|
||||
self.assertEqual(command[command.index('-rc_mode') + 1], 'CQP')
|
||||
self.assertIn('h264_vaapi', command)
|
||||
self.assertIn('pipe:1', command)
|
||||
|
||||
def test_ptz_requires_both_space_and_control_permission(self):
|
||||
camera = dict(id='e'*16, name='Test', siteId='permitted', enabled=True)
|
||||
viewer = dict(role='member', siteIds=['permitted'], ptzControl=False)
|
||||
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
|
||||
viewer['ptzControl'] = True
|
||||
self.assertEqual(app.ACCOUNTS.control(viewer, camera), camera)
|
||||
viewer['siteIds'] = []
|
||||
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
|
||||
self.assertEqual(self.request('/api/ptz?camera='+'e'*16,False)[0],401)
|
||||
|
||||
def test_family_defaults_alive_and_rejects_death_for_alive(self):
|
||||
p=app.FAMILY.person({'name':'Example'},None)
|
||||
self.assertEqual(p['lifeStatus'],'alive')
|
||||
with self.assertRaises(app.Problem):app.FAMILY.person({'name':'Example','deathDate':'2020-01-01'},None)
|
||||
self.assertEqual(app.FAMILY.person({'name':'Example','lifeStatus':'deceased','deathDate':'2020-01-01'},None)['deathDate'],'2020-01-01')
|
||||
|
||||
def test_empty_recording_day_is_not_service_failure(self):
|
||||
error = app.urllib.error.HTTPError('http://localhost/list', 404, 'Not Found', {},
|
||||
io.BytesIO(b'{"error":"no recording segments found"}'))
|
||||
with patch('app.urllib.request.urlopen', side_effect=error):
|
||||
self.assertEqual(app.media_json('/list?path=example', playback=True), [])
|
||||
|
||||
def test_real_media_service_error_is_preserved(self):
|
||||
error = app.urllib.error.HTTPError('http://localhost/list', 500, 'Error', {}, io.BytesIO(b'{}'))
|
||||
with patch('app.urllib.request.urlopen', side_effect=error):
|
||||
with self.assertRaises(app.Problem):
|
||||
app.media_json('/list?path=example', playback=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),cal=require('./web/calendar.js');
|
||||
test('Spring Festival and leap month are bidirectional',()=>{
|
||||
assert.deepEqual(cal.lunar('2024-02-10'),{year:2024,month:1,day:1,leap:false});
|
||||
assert.equal(cal.solar('2024-01-01'),'2024-02-10');
|
||||
assert.deepEqual(cal.lunar('2023-03-22'),{year:2023,month:2,day:1,leap:true});
|
||||
assert.equal(cal.solar('2023-02-01',true),'2023-03-22');
|
||||
assert.equal(cal.solar('2023-02-01',false),'2023-02-20');
|
||||
});
|
||||
test('all days across a leap year round trip',()=>{
|
||||
for(let n=0;n<366;n++){const s=new Date(Date.UTC(2024,0,1+n,12)).toISOString().slice(0,10),l=cal.lunar(s);assert.equal(cal.solar(cal.iso(l),l.leap),s);}
|
||||
});
|
||||
test('invalid dates, nonexistent leap months and range are explicit',()=>{
|
||||
for(const s of ['2026-02-30','2026-13-01','bad'])assert.throws(()=>cal.solarDate(s));
|
||||
assert.throws(()=>cal.solar('2024-02-01',true));assert.throws(()=>cal.solar('2024-12-31'));assert.throws(()=>cal.lunar('1800-01-01'));
|
||||
assert.equal(cal.describe('1800-01-01'),'1800-01-01(公历)');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),K=require('./web/kinship.js');
|
||||
const people=Array.from({length:10},(_,i)=>({id:String(i),gender:i===9?'female':'male',birthDate:''}));
|
||||
const links=Array.from({length:8},(_,i)=>({id:String(i),fromId:String(i),toId:String(i+1),kind:'parent',active:true,lineage:'biological'}));
|
||||
test('descendants are derived across generations',()=>{assert.equal(K.describe(people,links,'0','1'),'儿子');assert.equal(K.describe(people,links,'0','2'),'孙子');assert.equal(K.describe(people,links,'0','3'),'曾孙');assert.equal(K.describe(people,links,'0','4'),'玄孙');assert.equal(K.describe(people,links,'0','8'),'第 8 代后裔');});
|
||||
test('ancestors, self and disconnected people',()=>{assert.equal(K.describe(people,links,'4','0'),'高祖辈');assert.equal(K.describe(people,links,'0','0'),'本人');assert.equal(K.describe(people,links,'0','9'),'关系待补充');});
|
||||
test('siblings do not invent age order',()=>{const siblings=[...links,{fromId:'0',toId:'9',kind:'parent',active:true}];assert.equal(K.describe(people,siblings,'1','9'),'姐妹(长幼待确认)');const dated=people.map(p=>({...p,birthDate:p.id==='1'?'2000-01-01':p.id==='9'?'2002-01-01':''}));assert.equal(K.describe(dated,siblings,'1','9'),'妹妹');});
|
||||
test('inactive and adoptive relationships remain explicit',()=>{assert.equal(K.describe(people,links.map(e=>({...e,active:false})),'0','1'),'关系待补充');assert.match(K.describe(people,links.map(e=>({...e,lineage:'adoptive'})),'0','1'),/收养/);});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const LivePlayer = require('./web/live-player.js');
|
||||
|
||||
function fixture() {
|
||||
let now = 100, serial = 0;
|
||||
const timeouts = new Map(), intervals = new Map();
|
||||
const clock = {
|
||||
setTimeout(fn, ms) {const id=++serial;timeouts.set(id,{fn,ms});return id;},
|
||||
clearTimeout(id) {timeouts.delete(id);},
|
||||
setInterval(fn, ms) {const id=++serial;intervals.set(id,{fn,ms});return id;},
|
||||
clearInterval(id) {intervals.delete(id);}
|
||||
};
|
||||
const listeners = new Map(), indicators = {'.video-empty':{hidden:false},'.video-error':{hidden:true}};
|
||||
const video = {
|
||||
currentTime:0,readyState:0,videoWidth:0,paused:false,ended:false,error:null,
|
||||
addEventListener(e,f) {listeners.set(e,f);},removeEventListener(e) {listeners.delete(e);},
|
||||
emit(e) {listeners.get(e)?.();},
|
||||
play() {this.paused=false;this.emit('play');return Promise.resolve();},
|
||||
pause() {this.paused=true;this.emit('pause');},removeAttribute() {},load() {},
|
||||
canPlayType() {return '';}
|
||||
};
|
||||
const instances=[];
|
||||
class Hls {
|
||||
static Events={ERROR:'error',MANIFEST_PARSED:'manifest'};
|
||||
static isSupported() {return true;}
|
||||
constructor() {this.events={};instances.push(this);}
|
||||
on(e,f) {this.events[e]=f;}
|
||||
loadSource() {} attachMedia() {} destroy() {this.destroyed=true;}
|
||||
fail() {this.events.error(null,{fatal:true});}
|
||||
}
|
||||
const player=new LivePlayer(video,{querySelector:s=>indicators[s]},'/example',{Hls,clock,now:()=>now});
|
||||
return {player,video,instances,timeouts,intervals,indicators,
|
||||
advance(ms) {now+=ms;player.watch();},
|
||||
playing() {video.readyState=4;video.videoWidth=1920;video.emit('playing');},
|
||||
retry() {const [id,t]=timeouts.entries().next().value;timeouts.delete(id);now+=t.ms;t.fn();}
|
||||
};
|
||||
}
|
||||
|
||||
test('readiness, recording status and labels do not change playback identity',()=>{
|
||||
const camera={id:'one',enabled:true,ready:true,name:'A',recordingActive:false};
|
||||
const original=LivePlayer.key([camera],'hd');
|
||||
assert.equal(LivePlayer.key([{...camera,ready:false,name:'B',recordingActive:true}],'hd'),original);
|
||||
assert.notEqual(LivePlayer.key([camera],'compat'),original);
|
||||
});
|
||||
test('fatal error reconnects only its own player with bounded backoff',()=>{
|
||||
const a=fixture(),b=fixture();
|
||||
for(let i=0;i<8;i++) {
|
||||
a.instances.at(-1).fail();
|
||||
assert.equal(a.timeouts.size,1);
|
||||
assert.ok([...a.timeouts.values()][0].ms<=30000);
|
||||
a.retry();
|
||||
}
|
||||
assert.equal(b.instances.length,1);
|
||||
a.player.destroy();b.player.destroy();
|
||||
});
|
||||
test('duplicate errors schedule only one retry',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.video.emit('error');
|
||||
assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||
});
|
||||
test('continuous playback does not restart during repeated health checks',()=>{
|
||||
const f=fixture();f.playing();
|
||||
for(let i=0;i<72;i++) {f.video.currentTime+=5;f.advance(5000);}
|
||||
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);f.player.destroy();
|
||||
});
|
||||
test('stalled playback automatically reconnects',()=>{
|
||||
const f=fixture();f.playing();f.advance(30000);
|
||||
assert.equal(f.timeouts.size,1);f.retry();assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
test('startup has enough time to wait for a source',()=>{
|
||||
const f=fixture();f.advance(60000);assert.equal(f.timeouts.size,0);
|
||||
f.advance(6000);assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||
});
|
||||
test('intentional pause is preserved and resume can recover a failed connection',()=>{
|
||||
const f=fixture();f.playing();f.video.pause();f.instances[0].fail();f.advance(90000);
|
||||
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);
|
||||
f.video.play();assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
test('destroy cancels timers and releases the old player',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.player.destroy();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.intervals.size,0);assert.equal(f.instances[0].destroyed,true);
|
||||
});
|
||||
test('recovery before timeout cancels pending retry',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.playing();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.indicators['.video-error'].hidden,true);f.player.destroy();
|
||||
});
|
||||
test('error from an old HLS instance cannot restart its replacement',()=>{
|
||||
const f=fixture();const old=f.instances[0];old.fail();f.retry();old.fail();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree.ElementTree import fromstring
|
||||
import ptz
|
||||
|
||||
|
||||
class PtzTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.controller = ptz.Controller()
|
||||
self.camera = dict(host='192.168.0.2', username='admin', password='example', mainPath='/stream1')
|
||||
self.info = dict(endpoint=(80, '/onvif/PTZ'), profile='profile1', node='shared')
|
||||
|
||||
def test_nudge_has_device_deadline_and_explicit_stop(self):
|
||||
with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call') as call, patch('ptz.time.sleep'):
|
||||
self.assertTrue(self.controller.command(self.camera, 'left')['stopped'])
|
||||
self.assertEqual([c.args[3] for c in call.call_args_list], ['ContinuousMove', 'Stop'])
|
||||
self.assertIn('<m:Timeout>PT1S</m:Timeout>', call.call_args_list[0].args[4])
|
||||
self.assertIn('x="-0.25"', call.call_args_list[0].args[4])
|
||||
|
||||
def test_failed_move_still_stops(self):
|
||||
with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call', side_effect=[OSError(), None]) as call:
|
||||
with self.assertRaises(OSError):
|
||||
self.controller.command(self.camera, 'up')
|
||||
self.assertEqual(call.call_args_list[-1].args[3], 'Stop')
|
||||
self.assertFalse(self.controller.locks[('192.168.0.2', 80, 'shared')].locked())
|
||||
|
||||
def test_shared_node_rejects_overlapping_moves_but_allows_stop(self):
|
||||
lock=threading.Lock();lock.acquire();self.controller.locks[('192.168.0.2',80,'shared')]=lock
|
||||
with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call') as call:
|
||||
with self.assertRaises(ValueError):self.controller.command(self.camera,'right')
|
||||
self.controller.command(self.camera,'stop')
|
||||
self.assertEqual(call.call_args.args[3],'Stop')
|
||||
lock.release()
|
||||
|
||||
def test_arbitrary_commands_and_foreign_services_rejected(self):
|
||||
for action in ['zoom','home','SetPreset',None]:
|
||||
with self.assertRaises(ValueError):self.controller.command(self.camera,action)
|
||||
for endpoint in ['http://example.org/onvif/PTZ','http://admin:secret@192.168.0.2/onvif/PTZ','https://192.168.0.2/onvif/PTZ']:
|
||||
with self.assertRaises(ValueError):self.controller.endpoint(self.camera,endpoint)
|
||||
|
||||
def test_discovery_and_short_timeout_requirement(self):
|
||||
def xml(text):return fromstring('<root xmlns:tt="'+ptz.onvif.SCHEMA+'" xmlns:m="'+ptz.onvif.MEDIA+'">'+text+'</root>')
|
||||
caps=xml('<tt:PTZ><tt:XAddr>http://192.168.0.2/onvif/PTZ</tt:XAddr></tt:PTZ><tt:Media><tt:XAddr>http://192.168.0.2/onvif/Media</tt:XAddr></tt:Media>')
|
||||
profiles=xml('<m:Profiles token="profile1"><tt:PTZConfiguration token="config"><tt:NodeToken>shared</tt:NodeToken></tt:PTZConfiguration></m:Profiles>')
|
||||
template='<tt:ContinuousPanTiltVelocitySpace><tt:URI>'+ptz.SPACE+'</tt:URI><tt:XRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:XRange><tt:YRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:YRange></tt:ContinuousPanTiltVelocitySpace><tt:PTZTimeout><tt:Min>{minimum}</tt:Min><tt:Max>PT600S</tt:Max></tt:PTZTimeout>'
|
||||
self.camera['mainPath']='/stream1?profile=profile1'
|
||||
with patch.object(self.controller,'call',side_effect=[caps,profiles,xml(template.format(minimum='PT1S'))]):
|
||||
self.assertTrue(self.controller.capabilities(self.camera)['supported'])
|
||||
self.controller.cache.clear()
|
||||
with patch.object(self.controller,'call',side_effect=[caps,profiles,xml(template.format(minimum='PT5S'))]):
|
||||
with self.assertRaises(ValueError):self.controller.discover(self.camera)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
+64
-33
@@ -4,12 +4,30 @@ const $$ = (s) => [...document.querySelectorAll(s)];
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
|
||||
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
|
||||
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer;
|
||||
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
|
||||
let currentUser = null;
|
||||
const isAdmin = () => currentUser?.role === 'admin';
|
||||
function applyPermissions(){
|
||||
const admin=isAdmin();
|
||||
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera]').forEach(el=>el.hidden=!admin);
|
||||
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
|
||||
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
|
||||
}
|
||||
function clearPrivateView(){
|
||||
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
|
||||
currentUser=null;stopPlayers();resetPlayback();
|
||||
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
|
||||
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list'])$('#'+id).replaceChildren();
|
||||
if(typeof clearFamilyState==='function')clearFamilyState();
|
||||
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
|
||||
$$('dialog form').forEach(f=>f.reset());
|
||||
activePage='live';showPage('live');applyPermissions();
|
||||
}
|
||||
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
|
||||
async function api(path, data) {
|
||||
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
|
||||
let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); }
|
||||
if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; stopPlayers(); auth(); } throw Error(result.error || '操作未完成'); }
|
||||
if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; clearPrivateView(); auth(); } throw Error(result.error || '操作未完成'); }
|
||||
return result;
|
||||
}
|
||||
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||
@@ -19,14 +37,19 @@ function filtered() { return state.cameras.filter(c => !$('#site-filter').value
|
||||
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
|
||||
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
|
||||
function showPage(page) {
|
||||
if (activePage !== page) stopPlayers();
|
||||
if(!isAdmin()&&!['live','playback','family'].includes(page))return;
|
||||
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
|
||||
activePage = page;
|
||||
$('.metrics').hidden=['family','users'].includes(page);
|
||||
$('.filter-row').hidden=['family','users'].includes(page);
|
||||
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
|
||||
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
|
||||
if (page !== 'playback') $('#play-video').pause();
|
||||
if (page !== 'playback') resetPlayback();
|
||||
render();
|
||||
if (page === 'storage') loadAudit();
|
||||
if (page === 'family') loadFamily();
|
||||
if (page === 'users') loadUsers();
|
||||
}
|
||||
function render() {
|
||||
$('#metric-all').textContent = state.assets.length;
|
||||
@@ -35,7 +58,7 @@ function render() {
|
||||
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
|
||||
$('#service-status').textContent = state.mediaOnline ? '视频服务已连接' : '视频服务未连接';
|
||||
$('#service-status').classList.toggle('off', !state.mediaOnline);
|
||||
$('#version').textContent = 'v' + state.version + ' / 数据存于本机';
|
||||
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
|
||||
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
|
||||
$('#banner').textContent = warning; $('#banner').hidden = !warning;
|
||||
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind) + ' · ' + s.name)}</option>`).join(''));
|
||||
@@ -49,29 +72,33 @@ function render() {
|
||||
drawTimeline();
|
||||
}
|
||||
if (activePage === 'storage') renderStorage();
|
||||
applyPermissions();
|
||||
if(activePage==='live'&&typeof wallSync==='function')wallSync();
|
||||
}
|
||||
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
|
||||
function renderLive() {
|
||||
const cameras = filtered(), quality = $('#quality').value;
|
||||
const key = JSON.stringify(cameras.map(c=>[c.id,c.name,c.siteId,c.point,c.ready,c.enabled,c.record,c.recordingActive])) + quality;
|
||||
if (key === liveKey) return;
|
||||
const key = ZhaoLivePlayer.key(cameras, quality);
|
||||
if (key === liveKey) { updateLiveStatus(cameras); return; }
|
||||
stopPlayers(); liveKey = key;
|
||||
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button></div></article>`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>');
|
||||
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="true" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
|
||||
for (const c of cameras.filter(c=>c.enabled)) {
|
||||
const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement;
|
||||
const src = `/media/live/cam_${c.id}_${quality}/index.m3u8`;
|
||||
const error = text => {const el=panel.querySelector('.video-error');el.textContent=text;el.hidden=false;};
|
||||
video.onplaying=()=>{panel.querySelector('.video-empty').hidden=true;panel.querySelector('.video-error').hidden=true;};
|
||||
video.onerror=()=>error('浏览器无法解码此码流。可尝试切换清晰度,或将摄像头设为 H.264。');
|
||||
if (window.Hls?.isSupported()) {
|
||||
const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2});
|
||||
hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){error('画面暂未接通,请检查设备账号、路径、网络或编码。点击刷新状态可重试。');hls.destroy();}});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED,()=>video.play().catch(()=>{}));
|
||||
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {video.src=src;video.play().catch(()=>{});}
|
||||
else error('当前浏览器不支持 HLS 播放,请使用较新的浏览器。');
|
||||
const video = $(`video[data-camera="${c.id}"]`);
|
||||
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
|
||||
}
|
||||
}
|
||||
function updateLiveStatus(cameras) {
|
||||
for (const c of cameras) {
|
||||
const card=$(`video[data-camera="${c.id}"]`)?.closest('.camera-card');
|
||||
if (!card) continue;
|
||||
card.querySelector('.camera-meta strong').textContent=c.name;
|
||||
card.querySelector('.camera-meta p').textContent=siteName(c.siteId)+' · '+(c.point || '安装位置待填写');
|
||||
const badge=card.querySelector('.badge');
|
||||
badge.textContent=c.recordingActive?'● 正在录像':c.ready?'码流已接通':c.enabled?'等待接通':'已停用';
|
||||
badge.classList.toggle('off',!c.ready);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCameras() {
|
||||
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
|
||||
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
|
||||
@@ -79,7 +106,8 @@ function renderCameras() {
|
||||
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
|
||||
}
|
||||
function renderSites() {
|
||||
$('#site-list').innerHTML=state.sites.length?state.sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.cameras.filter(c=>c.siteId===s.id).length} 台</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
|
||||
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
|
||||
}
|
||||
function renderStorage() {
|
||||
const s=state.storage;
|
||||
@@ -87,8 +115,8 @@ function renderStorage() {
|
||||
$('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
|
||||
}
|
||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){toast(e.message);} }
|
||||
async function refresh() {if(!loggedIn)return;try{state=await api('/api/state');render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-title').textContent=setup?'建立你的视觉空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置你自己的管理密码。':'输入管理密码,进入视觉工作空间。';if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}}
|
||||
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId)return;state=next;currentUser=state.user;render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}}
|
||||
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
|
||||
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
|
||||
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
||||
@@ -98,35 +126,38 @@ handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enable
|
||||
handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit);
|
||||
$('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
|
||||
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();$('#play-video').pause();await auth();};
|
||||
$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();};
|
||||
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();};
|
||||
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
|
||||
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
|
||||
function resetPlayback() {const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';drawTimeline();$('#recording-list').innerHTML='';}
|
||||
function resetPlayback() {playbackGeneration++;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';}
|
||||
async function queryRecordings() {
|
||||
resetPlayback();const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
|
||||
resetPlayback();const generation=playbackGeneration;const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
|
||||
const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;}
|
||||
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
|
||||
$('#play-info').textContent='正在查找录像…';
|
||||
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){$('#play-info').textContent=e.message;}
|
||||
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));if(generation!==playbackGeneration)return;spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){if(generation===playbackGeneration)$('#play-info').textContent=e.message;}
|
||||
}
|
||||
function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}}
|
||||
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 当前片段最多一小时';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
|
||||
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 兼容回放,最多一小时;使用下方时间轴定位';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
|
||||
$('#query-recordings').onclick=queryRecordings;$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
|
||||
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
|
||||
$('#timeline').onclick=e=>{if(!queryStart)return;const rect=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-rect.left)/rect.width));};
|
||||
$('#play-video').onerror=()=>{$('#play-info').textContent='回放失败:可能没有完整录像,或浏览器不支持录像编码;可下载片段后播放。';};
|
||||
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
|
||||
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};
|
||||
const today=new Date();$('#play-date').value=[today.getFullYear(),String(today.getMonth()+1).padStart(2,'0'),String(today.getDate()).padStart(2,'0')].join('-');
|
||||
window.addEventListener('resize',drawTimeline);window.addEventListener('beforeunload',stopPlayers);
|
||||
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
|
||||
setInterval(refresh,12000);auth();
|
||||
|
||||
function renderAssets(){
|
||||
$('#asset-list').innerHTML=state.assets.length?state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value).map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
|
||||
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
|
||||
$('#asset-list').innerHTML=assets.length?assets.map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
|
||||
}
|
||||
function renderRecorders(){
|
||||
$('#recorder-list').innerHTML=state.recorders.length?state.recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
|
||||
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
|
||||
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
|
||||
}
|
||||
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
|
||||
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Chinese calendar conversion supplied by the browser's ICU calendar, offline. */
|
||||
(function(root){
|
||||
'use strict';
|
||||
const fmt=new Intl.DateTimeFormat('en-u-ca-chinese',{year:'numeric',month:'numeric',day:'numeric',timeZone:'Asia/Shanghai'});
|
||||
const cache=new Map();
|
||||
function solarDate(value){
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error('日期请填写 YYYY-MM-DD');
|
||||
const d=new Date(value+'T12:00:00Z');
|
||||
if(!Number.isFinite(+d)||d.toISOString().slice(0,10)!==value)throw Error('日期不存在');
|
||||
return d;
|
||||
}
|
||||
function lunar(value){
|
||||
const d=solarDate(value),year=d.getUTCFullYear();
|
||||
if(year<1901||year>2100)throw Error('自动换算支持 1901—2100 年;更早日期可填写公历并在备注保留原记载');
|
||||
if(fmt.resolvedOptions().calendar!=='chinese')throw Error('当前浏览器不支持农历换算');
|
||||
const parts=Object.fromEntries(fmt.formatToParts(d).map(p=>[p.type,p.value]));
|
||||
return {year:Number(parts.relatedYear),month:parseInt(parts.month,10),day:Number(parts.day),leap:parts.month.includes('bis')};
|
||||
}
|
||||
function solar(value,leap=false){
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error('农历请填写 YYYY-MM-DD,例如 2023-02-01');
|
||||
const [year,month,day]=value.split('-').map(Number);
|
||||
if(year<1901||year>2099)throw Error('农历输入支持 1901—2099 年');
|
||||
if(month<1||month>12||day<1||day>30)throw Error('农历日期不存在');
|
||||
if(!cache.has(year)){
|
||||
const days=new Map(),start=Date.UTC(year,0,1,12),end=Date.UTC(year+1,2,1,12);
|
||||
for(let t=start;t<end;t+=86400000){const iso=new Date(t).toISOString().slice(0,10),l=lunar(iso);if(l.year===year)days.set(`${l.month}-${l.day}-${l.leap}`,iso);}
|
||||
if(cache.size>8)cache.clear();cache.set(year,days);
|
||||
}
|
||||
const result=cache.get(year).get(`${month}-${day}-${!!leap}`);
|
||||
if(!result)throw Error('该农历日期或闰月不存在');
|
||||
return result;
|
||||
}
|
||||
const pad=n=>String(n).padStart(2,'0');
|
||||
function iso(l){return `${l.year}-${pad(l.month)}-${pad(l.day)}`;}
|
||||
function describe(value){if(!value)return '';try{const l=lunar(value);return `${value} · 农历 ${l.year}年${l.leap?'闰':''}${l.month}月${l.day}日`;}catch{return value+'(公历)';}}
|
||||
root.ZhaoCalendar={solarDate,lunar,solar,iso,describe};
|
||||
if(typeof module!=='undefined')module.exports=root.ZhaoCalendar;
|
||||
})(typeof window==='undefined'?globalThis:window);
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
function dateHint(field,strict=false){
|
||||
const input=field.querySelector('[data-date-value]'),hint=field.querySelector('[data-date-hint]'),hidden=field.querySelector('input[type=hidden]');
|
||||
const mode=field.querySelector('[data-calendar]').value,value=input.value.trim(),leap=field.querySelector('[data-date-leap]').checked;
|
||||
field.querySelector('[data-leap-label]').hidden=mode!=='lunar';input.setCustomValidity('');
|
||||
if(!value){hidden.value='';hint.textContent='日期不详可留空';return;}
|
||||
try{
|
||||
const solar=mode==='solar'?(ZhaoCalendar.solarDate(value),value):ZhaoCalendar.solar(value,leap);
|
||||
hidden.value=solar;
|
||||
if(mode==='lunar')hint.textContent='公历 '+solar;
|
||||
else{try{const l=ZhaoCalendar.lunar(solar);hint.textContent=`农历 ${l.year}年${l.leap?'闰':''}${l.month}月${l.day}日`;}catch(e){hint.textContent=e.message;}}
|
||||
}catch(e){hidden.value='';input.setCustomValidity(e.message);hint.textContent=e.message;if(strict)throw e;}
|
||||
}
|
||||
function syncLifeStatus(){
|
||||
const deceased=$('#person-form').elements.lifeStatus.value==='deceased',field=$('[data-date-field="deathDate"]');field.hidden=!deceased;
|
||||
field.querySelectorAll('input,select').forEach(e=>e.disabled=!deceased);
|
||||
if(!deceased)field.querySelector('[data-date-value]').setCustomValidity('');
|
||||
}
|
||||
function initDateFields(){
|
||||
for(const field of $$('[data-date-field]')){
|
||||
field.querySelector('[data-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false;
|
||||
field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field);
|
||||
}syncLifeStatus();
|
||||
}
|
||||
function syncDateFields(strict=false){
|
||||
syncLifeStatus();for(const field of $$('[data-date-field]'))if(!field.hidden)dateHint(field,strict);
|
||||
if($('[data-date-field="deathDate"]').hidden)$('#person-form').elements.deathDate.value='';
|
||||
}
|
||||
for(const field of $$('[data-date-field]')){
|
||||
field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field));
|
||||
field.querySelector('[data-date-leap]').addEventListener('change',()=>dateHint(field));
|
||||
field.querySelector('[data-calendar]').addEventListener('change',()=>{
|
||||
const hidden=field.querySelector('input[type=hidden]'),input=field.querySelector('[data-date-value]'),mode=field.querySelector('[data-calendar]');
|
||||
if(hidden.value){try{if(mode.value==='solar')input.value=hidden.value;else{const l=ZhaoCalendar.lunar(hidden.value);input.value=ZhaoCalendar.iso(l);field.querySelector('[data-date-leap]').checked=l.leap;}}catch(e){mode.value='solar';toast(e.message);}}
|
||||
dateHint(field);
|
||||
});
|
||||
}
|
||||
$('#person-form').elements.lifeStatus.addEventListener('change',syncLifeStatus);
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
pageInfo.family=['家谱','记下亲人的故事,让世代之间有迹可循。'];
|
||||
pageInfo.users=['账号与关系','每个人有自己的账号,权限与亲属关系分别设置。'];
|
||||
let family={people:[],links:[],revision:0,canEdit:false}, users=[], focusPerson='',familyGeneration=0;
|
||||
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
|
||||
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
|
||||
function clearFamilyState(){familyGeneration++;family={people:[],links:[],revision:0,canEdit:false};users=[];focusPerson='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;}
|
||||
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
||||
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
||||
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
|
||||
async function loadFamily(){
|
||||
const generation=++familyGeneration;
|
||||
try{const result=await api('/api/family');if(!loggedIn||generation!==familyGeneration)return;family=result;
|
||||
if(!family.people.some(p=>p.id===focusPerson))focusPerson=family.selfId||family.people.find(p=>!p.archived)?.id||'';
|
||||
setOptions($('#family-reference'),peopleOptions(false));if(!$('#family-reference').value&&family.selfId)$('#family-reference').value=family.selfId;
|
||||
if(!$('#family-reference').dataset.initialized&&family.selfId){$('#family-reference').value=family.selfId;$('#family-reference').dataset.initialized='true';}
|
||||
$('#family-error').textContent='';renderFamily();
|
||||
}catch(e){$('#family-error').textContent=e.message;}
|
||||
}
|
||||
function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
|
||||
function renderFamily(){
|
||||
const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase();
|
||||
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));
|
||||
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系';
|
||||
$('#new-person').hidden=!family.canEdit;
|
||||
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''}" data-person="${p.id}"><strong>${esc(p.name)}</strong><span>${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty('从一个人物开始','可先录入自己,再逐步补齐父母、祖先和后辈。');
|
||||
const p=family.people.find(p=>p.id===focusPerson);
|
||||
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">选择或录入一位人物,查看关系与家谱。</p>';$('#family-tree').replaceChildren();return;}
|
||||
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
|
||||
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
|
||||
renderFamilyTree();
|
||||
}
|
||||
function renderFamilyTree(){
|
||||
const root=family.people.find(p=>p.id===focusPerson);if(!root)return;
|
||||
const depth=Number($('#family-depth').value),edges=family.links.filter(e=>e.active),people=new Map(family.people.map(p=>[p.id,p]));let count=0;
|
||||
function branch(id,d,direction,path){
|
||||
if(++count>300)return '<li>更多分支请切换人物查看</li>';
|
||||
const p=people.get(id);if(!p)return '';
|
||||
const node=`<button class="quiet" data-person="${id}">${esc(p.name)}${p.archived?' · 已归档':''}</button>`;
|
||||
if(path.has(id))return `<li>${node}(重复分支)</li>`;
|
||||
const seen=new Set([...path,id]);
|
||||
const next=edges.filter(e=>e.kind==='parent'&&(direction==='up'?e.toId:e.fromId)===id).map(e=>direction==='up'?e.fromId:e.toId);
|
||||
return `<li>${node}${d>0&&next.length?'<ul>'+next.map(child=>branch(child,d-1,direction,seen)).join('')+'</ul>':next.length?' <small>还有 '+next.length+' 位,点击此人物继续</small>':''}</li>`;
|
||||
}
|
||||
const spouseIds=edges.filter(e=>e.kind==='spouse'&&(e.fromId===root.id||e.toId===root.id)).map(e=>e.fromId===root.id?e.toId:e.fromId);
|
||||
$('#family-tree').innerHTML=`<div><h3>上溯祖先</h3><ul>${branch(root.id,depth,'up',new Set())}</ul></div><div><h3>下续后裔</h3><ul>${branch(root.id,depth,'down',new Set())}</ul></div><div class="family-spouses"><h3>配偶</h3>${spouseIds.map(id=>`<button class="quiet" data-person="${id}">${esc(personName(id))}</button>`).join(' ')||'<p class="description">尚未记录</p>'}</div>`;
|
||||
}
|
||||
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();}
|
||||
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
|
||||
for(const [name,path]of [['person','person'],['relation','link']]){
|
||||
$('#'+name+'-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{const d=formValues(f);d.revision=Number(d.revision);if(name==='person'){syncDateFields(true);Object.assign(d,formValues(f));d.revision=Number(d.revision);d.archived=f.elements.archived.checked;}else d.active=f.elements.active.checked;
|
||||
const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();await loadFamily();toast('家谱已保存');
|
||||
}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}
|
||||
});
|
||||
}
|
||||
async function loadUsers(){
|
||||
if(!isAdmin())return;const id=currentUser.id;
|
||||
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
|
||||
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱权限</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${u.role==='admin'?'管理员':'个人账号'}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role==='admin'?'管理':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role==='admin'?'全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
|
||||
}catch(e){toast(e.message);}
|
||||
}
|
||||
function openUser(id){
|
||||
if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{};
|
||||
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join('');
|
||||
fillForm(f,u);f.elements.password.required=!u.id;
|
||||
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(s.name)}</label>`).join('');$('#user-dialog').showModal();
|
||||
}
|
||||
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
|
||||
$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily;
|
||||
$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;$('#family-depth').onchange=renderFamilyTree;
|
||||
$('#family-export').onclick=async()=>{try{const data=await api('/api/family');const blob=new Blob([JSON.stringify({format:'zhaofu-family',version:1,exportedAt:new Date().toISOString(),people:data.people,links:data.links},null,2)],{type:'application/json'});const link=document.createElement('a'),href=URL.createObjectURL(blob);link.href=href;link.download='zhaofu-family-'+new Date().toISOString().slice(0,10)+'.json';link.click();setTimeout(()=>URL.revokeObjectURL(href),60000);}catch(e){toast(e.message);}};
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.person){focusPerson=b.dataset.person;renderFamily();}if(b.dataset.editPerson)openPerson(b.dataset.editPerson);if(b.id==='add-relation')openRelation();if(b.dataset.editRelation)openRelation(b.dataset.editRelation);if(b.dataset.editUser)openUser(b.dataset.editUser);});
|
||||
+25
-10
@@ -2,48 +2,63 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css">
|
||||
<script defer src="/vendor/hls.min.js"></script><script defer src="/app.js"></script>
|
||||
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#10191d"><link rel="stylesheet" href="/style.css">
|
||||
<script defer src="/vendor/hls.min.js"></script><script defer src="/live-player.js"></script><script defer src="/kinship.js"></script><script defer src="/calendar.js"></script><script defer src="/app.js"></script><script defer src="/monitor.js"></script><script defer src="/date-fields.js"></script><script defer src="/family.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/"><span class="brand-icon">◉</span><span>赵府智家<small>VISION / 本地视觉系统</small></span></a>
|
||||
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>HOME / 本地家庭空间</small></span></a>
|
||||
<div class="nav-label">工作空间</div>
|
||||
<nav aria-label="主导航">
|
||||
<button data-page="live" class="active">◫ <span>实时画面</span></button>
|
||||
<button data-page="playback">◷ <span>录像回放</span></button>
|
||||
<button data-page="family">♧ <span>家谱</span></button>
|
||||
<button data-page="users" data-admin>♙ <span>账号与关系</span></button>
|
||||
<button data-page="objects">▣ <span>摄像头对象</span></button><button data-page="recorders">▤ <span>录像机</span></button><button data-page="cameras">◫ <span>镜头通道</span></button>
|
||||
<button data-page="sites">⌂ <span>空间档案</span></button>
|
||||
<button data-page="storage">▤ <span>录像与存储</span></button>
|
||||
</nav>
|
||||
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
|
||||
<button id="logout" class="quiet">退出登录</button>
|
||||
<p id="current-user" class="description"></p><button id="logout" class="quiet">退出登录</button>
|
||||
</aside>
|
||||
<main>
|
||||
<header><div><div class="eyebrow">ZHAOFU HOME / VISION</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
|
||||
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="sub">流畅 · 子码流</option><option value="main">清晰 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><div id="live-grid" class="live-grid"></div></section>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
|
||||
<section id="page-playback" class="page" hidden>
|
||||
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
|
||||
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
|
||||
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
|
||||
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载当前片段</a></div></div>
|
||||
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
|
||||
<div id="recording-list" class="recording-list"></div>
|
||||
</section>
|
||||
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>CHANNELS</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
|
||||
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览优先使用子码流。画面由摄像头编码输出,浏览器兼容性取决于其编码,建议使用 H.264。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
<section id="page-family" class="page" hidden>
|
||||
<div class="section-head"><h2>家族的来处与延续 <small>FAMILY REGISTER</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
|
||||
<p class="description">一个账号可以录入全家人物。先录人物,再连接父母、子女与配偶;祖先不需要登录账号。日期使用公历,不确定的信息可以留空。</p>
|
||||
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
|
||||
<div id="family-error" class="form-error" role="status"></div>
|
||||
<div class="family-layout"><div id="family-people" class="family-people"></div><div id="family-detail" class="panel"></div></div>
|
||||
<div class="panel"><div class="section-head"><h3>以选中人物为中心的家谱</h3><label>展开代数<select id="family-depth"><option value="2">2 代</option><option value="4" selected>4 代</option><option value="8">8 代</option></select></label></div><p class="description">点击树中的姓名查看人物。配偶单独列出;同一个祖先可出现在不同分支。复杂旁系关系以关系路径表示,长幼按已填写的出生日期判断。</p><div id="family-tree" class="family-tree"></div></div>
|
||||
</section>
|
||||
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。</p><div id="user-list"></div></section>
|
||||
<footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||
</main>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
||||
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
||||
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
||||
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
|
||||
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>系统角色<select name="role"><option value="member">个人账号</option><option value="admin">管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">个人账号仅能观看已选空间的实时画面和回放;管理员可以管理全部空间。</p></fieldset><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
|
||||
<datalist id="relationship-choices"></datalist>
|
||||
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
|
||||
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
(function(root){
|
||||
function describe(people, links, reference, target) {
|
||||
const byId = new Map(people.map(p=>[p.id,p]));
|
||||
if (!byId.has(reference) || !byId.has(target)) return '尚未关联';
|
||||
if (reference === target) return '本人';
|
||||
const edges=links.filter(e=>e.active), graph=new Map();
|
||||
function add(a,b,kind,lineage){if(!graph.has(a))graph.set(a,[]);graph.get(a).push({id:b,kind,lineage});}
|
||||
for(const e of edges){add(e.fromId,e.toId,e.kind==='parent'?'child':'spouse',e.lineage);add(e.toId,e.fromId,e.kind==='parent'?'parent':'spouse',e.lineage);}
|
||||
const queue=[{id:reference,path:[]}],seen=new Set([reference]);let path;
|
||||
for(let i=0;i<queue.length;i++){
|
||||
const node=queue[i];
|
||||
for(const next of graph.get(node.id)||[]){if(seen.has(next.id))continue;seen.add(next.id);const steps=[...node.path,next];if(next.id===target){path=steps;break;}queue.push({id:next.id,path:steps});}
|
||||
if(path)break;
|
||||
}
|
||||
if(!path)return '关系待补充';
|
||||
const gender=byId.get(target).gender, word=(m,f,u)=>gender==='male'?m:gender==='female'?f:u;
|
||||
const type=path.map(e=>e.kind),n=path.length;
|
||||
let result;
|
||||
if(type.every(k=>k==='child')){
|
||||
result=n===1?word('儿子','女儿','子女'):n===2?word('孙子','孙女','孙辈'):n===3?word('曾孙','曾孙女','曾孙辈'):n===4?word('玄孙','玄孙女','玄孙辈'):`第 ${n} 代后裔`;
|
||||
if(n===2 && byId.get(path[0].id)?.gender==='female')result=word('外孙','外孙女','外孙辈');
|
||||
}else if(type.every(k=>k==='parent')){
|
||||
result=n===1?word('爸爸','妈妈','父母'):n===2?(byId.get(path[0].id)?.gender==='female'?word('姥爷','姥姥','外祖辈'):byId.get(path[0].id)?.gender==='male'?word('爷爷','奶奶','祖辈'):'祖父母辈'):n===3?'曾祖辈':n===4?'高祖辈':`第 ${n} 代祖先`;
|
||||
}else if(n===1 && type[0]==='spouse') result='配偶';
|
||||
else if(n===2 && type.join('/')==='parent/child'){
|
||||
const a=byId.get(reference).birthDate,b=byId.get(target).birthDate;
|
||||
result=a&&b&&a!==b?(b<a?word('哥哥','姐姐','年长的手足'):word('弟弟','妹妹','年幼的手足')):word('兄弟(长幼待确认)','姐妹(长幼待确认)','手足(长幼待确认)');
|
||||
}else{
|
||||
result=path.map(e=>e.kind==='spouse'?'配偶':e.kind==='parent'?(byId.get(e.id)?.gender==='male'?'父亲':byId.get(e.id)?.gender==='female'?'母亲':'父母'):(byId.get(e.id)?.gender==='male'?'儿子':byId.get(e.id)?.gender==='female'?'女儿':'子女')).join(' → ');
|
||||
}
|
||||
return result+(path.some(e=>e.lineage==='adoptive'||e.lineage==='step')?'(含收养 / 继亲关系)':'');
|
||||
}
|
||||
const api={describe}; if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.ZhaoKinship=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
(function (root) {
|
||||
class LivePlayer {
|
||||
static key(cameras, quality) {
|
||||
// Polling readiness, labels and recording status must not recreate video.
|
||||
return JSON.stringify(cameras.map(c => [c.id, !!c.enabled])) + quality;
|
||||
}
|
||||
constructor(video, panel, source, options = {}) {
|
||||
this.video = video; this.panel = panel; this.source = source;
|
||||
this.Hls = options.Hls || root.Hls;
|
||||
this.clock = options.clock || root;
|
||||
this.now = options.now || (() => Date.now());
|
||||
this.hls = null; this.timer = null; this.stopped = false;
|
||||
this.attempt = 0; this.played = false; this.userPaused = false;
|
||||
this.connecting = true; this.failed = false;
|
||||
this.lastTime = 0; this.lastProgress = this.now(); this.stableSince = 0;
|
||||
this.handlers = {
|
||||
playing: () => {
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.played = true; this.connecting = false; this.failed = false;
|
||||
this.userPaused = false; this.lastProgress = this.now();
|
||||
this.lastTime = video.currentTime; this.stableSince = this.now();
|
||||
panel.querySelector('.video-empty').hidden = true;
|
||||
panel.querySelector('.video-error').hidden = true;
|
||||
},
|
||||
pause: () => { if (!this.connecting && this.played && !video.ended && !video.error && video.readyState >= 2) this.userPaused = true; },
|
||||
play: () => { this.userPaused = false; this.lastProgress = this.now(); if (this.failed) this.retryIfNeeded(); },
|
||||
error: () => this.retry('画面暂时中断'),
|
||||
ended: () => this.retry('视频连接已结束')
|
||||
};
|
||||
for (const [event, handler] of Object.entries(this.handlers)) video.addEventListener(event, handler);
|
||||
this.watchdog = this.clock.setInterval(() => this.watch(), 5000);
|
||||
this.start();
|
||||
}
|
||||
message(text) {
|
||||
const el = this.panel.querySelector('.video-error'); el.textContent = text; el.hidden = false;
|
||||
}
|
||||
start() {
|
||||
if (this.stopped) return;
|
||||
this.connecting = true; this.failed = false;
|
||||
this.release();
|
||||
this.lastProgress = this.now(); this.lastTime = 0;
|
||||
const Hls = this.Hls;
|
||||
if (Hls?.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 20, backBufferLength: 5,
|
||||
liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 8,
|
||||
maxLiveSyncPlaybackRate: 1.2,
|
||||
manifestLoadingTimeOut: 60000, levelLoadingTimeOut: 15000,
|
||||
fragLoadingTimeOut: 15000, manifestLoadingMaxRetry: 2, levelLoadingMaxRetry: 2
|
||||
});
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (this.hls === hls && data.fatal) this.retry('画面暂时中断');
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (this.hls === hls && !this.stopped) this.video.play().catch(() => {});
|
||||
});
|
||||
hls.loadSource(this.source); hls.attachMedia(this.video);
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
this.video.src = this.source; this.video.play().catch(() => {});
|
||||
} else {
|
||||
this.userPaused = true;
|
||||
this.message('当前浏览器不支持视频播放,请使用较新的浏览器。');
|
||||
}
|
||||
}
|
||||
retry(reason) {
|
||||
if (this.stopped || this.timer !== null) return;
|
||||
this.failed = true;
|
||||
if (this.userPaused) return;
|
||||
const delay = Math.min(30000, 2000 * 2 ** Math.min(this.attempt++, 4));
|
||||
this.message(reason + ',正在自动重新连接…');
|
||||
this.timer = this.clock.setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!this.stopped) this.start();
|
||||
}, delay);
|
||||
}
|
||||
watch() {
|
||||
if (this.stopped || this.timer !== null || this.userPaused) return;
|
||||
const current = this.video.currentTime;
|
||||
if (this.video.readyState >= 2 && this.video.videoWidth > 0 && current > this.lastTime + 0.1) {
|
||||
this.lastTime = current; this.lastProgress = this.now();
|
||||
if (this.stableSince && this.now() - this.stableSince > 30000) this.attempt = 0;
|
||||
return;
|
||||
}
|
||||
const grace = this.connecting ? 65000 : 25000;
|
||||
if (this.now() - this.lastProgress > grace) this.retry('画面停顿');
|
||||
}
|
||||
retryIfNeeded() {
|
||||
if (!this.failed || this.stopped) return;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null; this.start();
|
||||
}
|
||||
release() {
|
||||
const old = this.hls; this.hls = null;
|
||||
if (old) old.destroy();
|
||||
this.video.pause(); this.video.removeAttribute('src'); this.video.load();
|
||||
}
|
||||
destroy() {
|
||||
this.stopped = true; this.connecting = true;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null; this.clock.clearInterval(this.watchdog);
|
||||
for (const [event, handler] of Object.entries(this.handlers)) this.video.removeEventListener(event, handler);
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = LivePlayer;
|
||||
else root.ZhaoLivePlayer = LivePlayer;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
let wallOwner='',wallOrder=[],wallPage=0,wallDragging='',ptzCamera='',ptzGeneration=0;
|
||||
const WALL_LAYOUTS={auto:[0,0],1:[1,1],2:[2,1],4:[2,2],6:[3,2],9:[3,3],16:[4,4]};
|
||||
function wallStorageKey(){return 'zhaofu-monitor-'+(currentUser?.id||'guest');}
|
||||
function wallSave(){try{localStorage.setItem(wallStorageKey(),JSON.stringify({order:wallOrder,layout:$('#wall-layout').value}));}catch{}}
|
||||
function wallSync(){
|
||||
if(wallOwner!==(currentUser?.id||'')){
|
||||
wallOwner=currentUser?.id||'';wallPage=0;let saved={};try{saved=JSON.parse(localStorage.getItem(wallStorageKey()))||{};}catch{}
|
||||
wallOrder=Array.isArray(saved.order)?saved.order.filter(x=>/^[a-f0-9]{16}$/.test(x)):[];
|
||||
$('#wall-layout').value=Object.hasOwn(WALL_LAYOUTS,saved.layout)?saved.layout:'auto';
|
||||
}
|
||||
const cameras=filtered(),ids=cameras.map(c=>c.id);
|
||||
const allIds=new Set(state.cameras.map(c=>c.id));wallOrder=wallOrder.filter(id=>allIds.has(id));
|
||||
for(const id of ids)if(!wallOrder.includes(id))wallOrder.push(id);
|
||||
const ordered=wallOrder.filter(id=>ids.includes(id)),layout=$('#wall-layout').value;
|
||||
const capacity=Number(layout)||6,pages=Math.max(1,Math.ceil(ordered.length/capacity));
|
||||
wallPage=Math.min(wallPage,pages-1);const visible=new Set(ordered.slice(wallPage*capacity,(wallPage+1)*capacity));
|
||||
$('#live-grid').dataset.layout=layout;
|
||||
for(const card of $$('#live-grid .camera-card')){
|
||||
const id=card.querySelector('video').dataset.camera;
|
||||
card.style.order=ordered.indexOf(id);card.hidden=!visible.has(id);
|
||||
card.querySelector('[data-wall-before]').disabled=ordered.indexOf(id)===0;
|
||||
card.querySelector('[data-wall-after]').disabled=ordered.indexOf(id)===ordered.length-1;
|
||||
}
|
||||
$('#wall-page').textContent=`${wallPage+1} / ${pages} 页 · ${ordered.length} 个画面`;
|
||||
$('#wall-prev').disabled=wallPage===0;$('#wall-next').disabled=wallPage===pages-1;
|
||||
$('#live-grid').classList.toggle('arranging',$('#wall-arrange').checked);
|
||||
$$('[data-ptz-camera]').forEach(b=>b.hidden=!(isAdmin()||currentUser?.ptzControl));
|
||||
}
|
||||
function wallMove(id,target){
|
||||
const from=wallOrder.indexOf(id),to=wallOrder.indexOf(target);if(from<0||to<0||from===to)return;
|
||||
wallOrder.splice(from,1);wallOrder.splice(to,0,id);wallSave();wallSync();
|
||||
}
|
||||
$('#wall-layout').onchange=()=>{wallPage=0;wallSave();wallSync();};
|
||||
$('#wall-arrange').onchange=wallSync;
|
||||
$('#wall-prev').onclick=()=>{wallPage--;wallSync();};$('#wall-next').onclick=()=>{wallPage++;wallSync();};
|
||||
$('#wall-fullscreen').onclick=async()=>{
|
||||
try{if(document.fullscreenElement)await document.exitFullscreen();else await $('#page-live').requestFullscreen();}
|
||||
catch{toast('浏览器未允许全屏,可按 F11 进入浏览器全屏');}
|
||||
};
|
||||
document.addEventListener('fullscreenchange',()=>{
|
||||
$('#wall-fullscreen').textContent=document.fullscreenElement?'退出全屏 · Esc':'全屏监控';
|
||||
document.body.classList.toggle('wall-fullscreen',document.fullscreenElement===$('#page-live'));
|
||||
});
|
||||
setInterval(()=>{$('#wall-clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
|
||||
document.addEventListener('dragstart',e=>{const h=e.target.closest('[data-wall-drag]');if(!h)return;wallDragging=h.dataset.wallDrag;e.dataTransfer.setData('text/plain',wallDragging);e.dataTransfer.effectAllowed='move';});
|
||||
$('#live-grid').addEventListener('dragover',e=>{if(wallDragging&&$('#wall-arrange').checked)e.preventDefault();});
|
||||
$('#live-grid').addEventListener('drop',e=>{const c=e.target.closest('.camera-card');if(!c||!wallDragging)return;e.preventDefault();wallMove(wallDragging,c.querySelector('video').dataset.camera);wallDragging='';});
|
||||
document.addEventListener('dragend',()=>wallDragging='');
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.wallBefore||b.dataset.wallAfter){const ids=wallOrder.filter(id=>filtered().some(c=>c.id===id)),id=b.dataset.wallBefore||b.dataset.wallAfter;wallMove(id,ids[ids.indexOf(id)+(b.dataset.wallBefore?-1:1)]);}
|
||||
if(b.dataset.ptzCamera)openPtz(b.dataset.ptzCamera);
|
||||
});
|
||||
async function openPtz(id){
|
||||
const generation=++ptzGeneration;ptzCamera=id;$('#ptz-panel').hidden=false;
|
||||
$('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台';
|
||||
$('#ptz-status').textContent='正在读取云台能力…';$$('[data-ptz-action]').forEach(b=>b.disabled=true);
|
||||
try{const caps=await api('/api/ptz?camera='+id);if(generation!==ptzGeneration)return;$('#ptz-status').textContent=caps.note;$$('[data-ptz-action]').forEach(b=>b.disabled=false);}
|
||||
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
|
||||
}
|
||||
async function ptzCommand(action){
|
||||
if(!ptzCamera)return;const id=ptzCamera,generation=ptzGeneration;
|
||||
$$('[data-ptz-action]:not([data-ptz-action="stop"])').forEach(b=>b.disabled=true);
|
||||
$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…';
|
||||
try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';}
|
||||
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
|
||||
finally{if(generation===ptzGeneration)$$('[data-ptz-action]').forEach(b=>b.disabled=false);}
|
||||
}
|
||||
function closePtz(){ptzGeneration++;ptzCamera='';$('#ptz-panel').hidden=true;}
|
||||
$('#ptz-close').onclick=closePtz;
|
||||
$$('[data-ptz-action]').forEach(b=>b.onclick=()=>ptzCommand(b.dataset.ptzAction));
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera)ptzCommand('stop');});
|
||||
window.addEventListener('pagehide',()=>{if(ptzCamera)fetch('/api/ptz',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:ptzCamera,action:'stop'})}).catch(()=>{});});
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1004 KiB |
Reference in New Issue
Block a user