Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de6933c411 | |||
| b3aff9f2c4 | |||
| 84a84d9af0 | |||
| 2649854386 | |||
| d21170b41d | |||
| 969dcabe99 |
+18
-2
@@ -27,7 +27,7 @@
|
||||
|
||||
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
|
||||
|
||||
主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||
|
||||
## 管理 API
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
|
||||
本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。
|
||||
|
||||
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测、多用户细分权限目前没有实现,不作为已具备的能力展示。
|
||||
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。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` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
|
||||
|
||||
HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。“高清 · 主码流兼容”使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 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 部署
|
||||
|
||||
@@ -30,7 +32,7 @@ sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh
|
||||
|
||||
安装脚本从系统软件源安装 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,6 +49,8 @@ python3 app.py
|
||||
mediamtx ./data/mediamtx.yml
|
||||
```
|
||||
|
||||
回归测试:`python3 -m unittest -v test_app` 和 `node --test test_live_player.js test_kinship.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 是单管理员模型。
|
||||
|
||||
## 数据与恢复
|
||||
@@ -65,3 +69,18 @@ mediamtx ./data/mediamtx.yml
|
||||
[系统架构与对象关系](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,必须同时恢复升级前数据库副本,因为旧版本使用旧账户模型。人物和实际账号数据仅在受限数据目录中,不包含在公开源码内。
|
||||
|
||||
@@ -26,6 +26,9 @@ import urllib.parse as url
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import onvif
|
||||
import playback
|
||||
import identity
|
||||
import genealogy
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
os.umask(0o077)
|
||||
@@ -65,6 +68,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()
|
||||
@@ -79,7 +86,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):
|
||||
@@ -308,6 +316,15 @@ 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)
|
||||
|
||||
@@ -400,9 +417,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}
|
||||
@@ -459,15 +476,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 格式')
|
||||
@@ -498,31 +522,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 ''
|
||||
@@ -537,11 +559,17 @@ 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'):
|
||||
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/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':
|
||||
@@ -594,7 +622,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
|
||||
|
||||
def proxy(self, port, path):
|
||||
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'):
|
||||
@@ -615,6 +643,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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'):
|
||||
@@ -635,13 +665,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()})
|
||||
@@ -649,8 +711,14 @@ 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/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)
|
||||
@@ -658,6 +726,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('每次查询最多两天')
|
||||
@@ -669,11 +738,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
suffix = path[len('/media/live/'):]
|
||||
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'))
|
||||
@@ -681,13 +752,18 @@ 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',
|
||||
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
||||
if path not in files:
|
||||
raise Problem('页面不存在', 404)
|
||||
@@ -713,12 +789,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()
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
@@ -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', 'unknown')
|
||||
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
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
"""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('请选择有效空间')
|
||||
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,
|
||||
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')):
|
||||
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 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()
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
"""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_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,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();
|
||||
});
|
||||
+61
-37
@@ -4,12 +4,29 @@ 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(){
|
||||
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 +36,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(!isAdmin()&&!['live','playback','family'].includes(page))return;
|
||||
if (activePage !== page) stopPlayers();
|
||||
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 +57,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,34 +71,32 @@ function render() {
|
||||
drawTimeline();
|
||||
}
|
||||
if (activePage === 'storage') renderStorage();
|
||||
applyPermissions();
|
||||
}
|
||||
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="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('') : (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('浏览器无法解码此码流,请在清晰度中选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。');
|
||||
if (window.Hls?.isSupported()) {
|
||||
const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingTimeOut:60000,levelLoadingTimeOut:60000,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2});
|
||||
hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){
|
||||
const codecError = /codec|bufferAddCodec|manifestIncompatible/i.test(data.details || '');
|
||||
error(codecError ? '浏览器不支持当前视频编码,请选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。' : '画面加载失败('+(data.details || data.type)+')。点击刷新状态可重试。');
|
||||
console.warn('Video playback failed', c.id, data.type, data.details);
|
||||
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>' : '');
|
||||
@@ -84,7 +104,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;
|
||||
@@ -92,8 +113,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());}
|
||||
@@ -103,35 +124,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,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]],['出生日期',p.birthDate],['逝世日期',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});$('#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')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;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="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="/app.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="/"><span class="brand-icon">◉</span><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="compat">流畅 · 兼容画面</option><option value="hd">高清 · 主码流兼容</option><option value="sub">原始 · 子码流</option><option value="main">原始 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">流畅画面最高 768×432;高清画面从主码流转换,最高 1920×1080、15 帧/秒。原始画面需要浏览器支持摄像头编码。</p><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 id="live-grid" class="live-grid"></div></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 流畅画面(最高 768×432、10 帧/秒),也可选择从主码流转换的 1080p 高清画面。原始码流与本机录像保留设备编码。</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="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="unknown">待确认</option><option value="alive">在世</option><option value="deceased">已故</option></select></label><label>出生日期(公历)<input name="birthDate" type="date"></label><label>逝世日期(公历)<input name="deathDate" type="date"></label><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);
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user