2026-10-03 16:12:25 +08:00
|
|
|
"""Local video management. Python standard library + an external MediaMTX process."""
|
|
|
|
|
import base64
|
|
|
|
|
import concurrent.futures
|
|
|
|
|
import datetime as dt
|
|
|
|
|
import hashlib
|
2026-10-04 04:52:39 +08:00
|
|
|
import functools
|
|
|
|
|
import camera_settings
|
2026-10-03 16:12:25 +08:00
|
|
|
import hmac
|
|
|
|
|
import http.client
|
|
|
|
|
import http.cookies
|
|
|
|
|
import ipaddress
|
|
|
|
|
import json
|
|
|
|
|
import math
|
|
|
|
|
import mimetypes
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import re
|
|
|
|
|
import secrets
|
2026-10-03 16:49:48 +08:00
|
|
|
import shlex
|
2026-10-03 16:12:25 +08:00
|
|
|
import shutil
|
|
|
|
|
import socket
|
|
|
|
|
import sqlite3
|
2026-10-03 16:49:48 +08:00
|
|
|
import sys
|
2026-10-03 16:12:25 +08:00
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.parse as url
|
|
|
|
|
import urllib.request
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
2026-10-03 16:16:23 +08:00
|
|
|
import onvif
|
2026-10-03 17:10:06 +08:00
|
|
|
import playback
|
2026-10-03 17:41:52 +08:00
|
|
|
import identity
|
|
|
|
|
import genealogy
|
2026-10-04 21:30:30 +08:00
|
|
|
import geography
|
2026-10-04 10:43:17 +08:00
|
|
|
import households
|
2026-10-04 11:19:14 +08:00
|
|
|
import inventory
|
2026-10-04 16:21:40 +08:00
|
|
|
import heritage
|
2026-10-04 17:22:42 +08:00
|
|
|
import appearance
|
2026-10-04 21:30:30 +08:00
|
|
|
import cloud_images
|
2026-10-04 17:22:42 +08:00
|
|
|
import backups
|
2026-10-04 19:22:31 +08:00
|
|
|
import baidu
|
|
|
|
|
import journal
|
2026-10-03 18:29:18 +08:00
|
|
|
import ptz
|
2026-10-03 16:12:25 +08:00
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
|
os.umask(0o077)
|
|
|
|
|
DATA = Path(os.environ.get('VISION_DATA', ROOT / 'data')).resolve()
|
|
|
|
|
DATA.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
|
|
|
RECORDINGS = Path(os.environ.get('VISION_RECORDINGS', DATA / 'recordings')).resolve()
|
|
|
|
|
RECORDINGS.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
|
|
|
CONFIG = DATA / 'mediamtx.yml'
|
|
|
|
|
LOCK = threading.RLock()
|
|
|
|
|
DB = sqlite3.connect(DATA / 'vision.db', check_same_thread=False)
|
|
|
|
|
DB.row_factory = sqlite3.Row
|
|
|
|
|
DB.executescript('''
|
|
|
|
|
PRAGMA journal_mode=WAL;
|
|
|
|
|
PRAGMA foreign_keys=ON;
|
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS sites (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS cameras (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS assets (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS recorders (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, expires REAL NOT NULL);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS audit (id INTEGER PRIMARY KEY, at TEXT, action TEXT, name TEXT);
|
|
|
|
|
''')
|
|
|
|
|
DB.commit()
|
|
|
|
|
os.chmod(DATA / 'vision.db', 0o600)
|
|
|
|
|
STARTED = time.time()
|
|
|
|
|
STORAGE = {'freeGB': None, 'usedGB': None, 'paused': False, 'reason': ''}
|
|
|
|
|
RECENT_RECORDS = {}
|
|
|
|
|
SCAN = {'running': False, 'items': [], 'error': '', 'network': '', 'at': None}
|
|
|
|
|
ATTEMPTS = {}
|
|
|
|
|
API_PORT, HLS_PORT, PLAYBACK_PORT = 19997, 18888, 19996
|
|
|
|
|
MEDIA_AUTH = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Problem(Exception):
|
|
|
|
|
def __init__(self, message, status=400):
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
self.status = status
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 17:41:52 +08:00
|
|
|
ACCOUNTS = identity.Accounts(sys.modules[__name__])
|
|
|
|
|
FAMILY = genealogy.Family(sys.modules[__name__])
|
2026-10-04 10:43:17 +08:00
|
|
|
HOUSEHOLDS = households.Households(sys.modules[__name__])
|
2026-10-04 11:19:14 +08:00
|
|
|
INVENTORY = inventory.Inventory(sys.modules[__name__])
|
2026-10-04 16:21:40 +08:00
|
|
|
HERITAGE = heritage.Heritage(sys.modules[__name__])
|
2026-10-04 17:22:42 +08:00
|
|
|
APPEARANCE = appearance.Appearance(sys.modules[__name__])
|
|
|
|
|
BACKUPS = backups.Backups(sys.modules[__name__])
|
2026-10-04 19:22:31 +08:00
|
|
|
BAIDU = baidu.Baidu(sys.modules[__name__])
|
|
|
|
|
JOURNAL = journal.Journal(sys.modules[__name__])
|
2026-10-04 21:30:30 +08:00
|
|
|
CLOUD_IMAGES = cloud_images.CloudImages(sys.modules[__name__])
|
2026-10-03 17:41:52 +08:00
|
|
|
|
|
|
|
|
|
2026-10-03 16:12:25 +08:00
|
|
|
def setting(key, default=None):
|
|
|
|
|
with LOCK:
|
|
|
|
|
row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
|
|
|
|
|
return json.loads(row[0]) if row else default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_setting(key, value):
|
|
|
|
|
with LOCK:
|
|
|
|
|
DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (key, json.dumps(value)))
|
|
|
|
|
DB.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def objects(table):
|
|
|
|
|
with LOCK:
|
2026-10-03 17:10:06 +08:00
|
|
|
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']))
|
2026-10-03 16:12:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_object(table, key):
|
|
|
|
|
return next((o for o in objects(table) if o['id'] == key), None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def audit(action, name=''):
|
|
|
|
|
with LOCK:
|
|
|
|
|
DB.execute('INSERT INTO audit (at,action,name) VALUES (?,?,?)',
|
|
|
|
|
(dt.datetime.now(dt.timezone.utc).isoformat(), action, name))
|
|
|
|
|
DB.execute('DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT 500)')
|
|
|
|
|
DB.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_object(table, body):
|
|
|
|
|
with LOCK:
|
|
|
|
|
DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (body['id'], json.dumps(body)))
|
|
|
|
|
DB.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_text(value, limit=120, required=False):
|
|
|
|
|
if not isinstance(value, str) or len(value) > limit or any(ord(c) < 32 for c in value):
|
|
|
|
|
raise Problem('文本格式或长度不正确')
|
|
|
|
|
value = value.strip()
|
|
|
|
|
if required and not value:
|
|
|
|
|
raise Problem('请填写必填项')
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def integer(value, lo, hi):
|
|
|
|
|
if isinstance(value, bool) or not isinstance(value, int) or not lo <= value <= hi:
|
|
|
|
|
raise Problem('数值超出允许范围')
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def host_address(value):
|
|
|
|
|
try:
|
2026-10-04 04:52:39 +08:00
|
|
|
if not isinstance(value, str):
|
|
|
|
|
raise ValueError()
|
2026-10-03 16:12:25 +08:00
|
|
|
ip = ipaddress.ip_address(value)
|
|
|
|
|
if ip.version != 4 or ip.is_global or ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_reserved:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
return str(ip)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise Problem('请填写局域网或 Tailscale IPv4 地址')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'owner',
|
|
|
|
|
'building', 'unit', 'floor', 'door', 'address', 'note')
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 04:52:39 +08:00
|
|
|
def serialized_save(function):
|
|
|
|
|
@functools.wraps(function)
|
2026-10-04 10:43:17 +08:00
|
|
|
def save(body, *args, **kwargs):
|
2026-10-04 04:52:39 +08:00
|
|
|
with LOCK:
|
2026-10-04 10:43:17 +08:00
|
|
|
return function(body, *args, **kwargs)
|
2026-10-04 04:52:39 +08:00
|
|
|
return save
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def object_key(table, body):
|
|
|
|
|
key = body.get('id', '')
|
|
|
|
|
if key in ('', None):
|
|
|
|
|
return secrets.token_hex(8)
|
|
|
|
|
if not isinstance(key, str) or not re.fullmatch('[a-f0-9]{16}', key):
|
|
|
|
|
raise Problem('对象编号不正确')
|
|
|
|
|
if not get_object(table, key):
|
|
|
|
|
raise Problem('对象不存在,请重新载入', 404)
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@serialized_save
|
2026-10-04 10:43:17 +08:00
|
|
|
def save_site(body, actor=None):
|
2026-10-04 11:19:14 +08:00
|
|
|
family = HOUSEHOLDS.prepare(actor, 'sites', body, (('sites', 'parentId'),))
|
2026-10-04 04:52:39 +08:00
|
|
|
key = object_key('sites', body)
|
2026-10-04 11:19:14 +08:00
|
|
|
parent = clean_text(body.get('parentId', ''), 32)
|
|
|
|
|
ancestor, seen = parent, {key}
|
|
|
|
|
while ancestor:
|
|
|
|
|
if ancestor in seen:
|
|
|
|
|
raise Problem('区域不能放到自己或自己的下级区域中')
|
|
|
|
|
seen.add(ancestor)
|
|
|
|
|
ancestor = (get_object('sites', ancestor) or {}).get('parentId', '')
|
2026-10-03 16:12:25 +08:00
|
|
|
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
|
|
|
|
|
required=k == 'name') for k in SITE_FIELDS}
|
2026-10-04 11:19:14 +08:00
|
|
|
item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
|
|
|
|
|
category=clean_text(body.get('category', ''), 40))
|
2026-10-04 21:30:30 +08:00
|
|
|
geography.apply(item, body, get_object('sites', key), clean_text, Problem)
|
2026-10-04 11:19:14 +08:00
|
|
|
if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
|
|
|
|
|
raise Problem('请选择有效区域用途')
|
2026-10-03 16:12:25 +08:00
|
|
|
save_object('sites', item)
|
|
|
|
|
audit('保存空间', item['name'])
|
|
|
|
|
return item
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 04:52:39 +08:00
|
|
|
@serialized_save
|
2026-10-04 10:43:17 +08:00
|
|
|
def save_camera(body, actor=None):
|
|
|
|
|
family = HOUSEHOLDS.prepare(actor, 'cameras', body, (('assets', 'assetId'), ('recorders', 'recorderId'), ('recorders', 'archiveRecorderId')))
|
2026-10-04 04:52:39 +08:00
|
|
|
key = object_key('cameras', body)
|
2026-10-03 16:12:25 +08:00
|
|
|
old = get_object('cameras', key) or {}
|
|
|
|
|
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
|
|
|
|
|
for k in ('name', 'point', 'username', 'model')}
|
|
|
|
|
asset = get_object('assets', body.get('assetId'))
|
|
|
|
|
if not asset:
|
|
|
|
|
raise Problem('请先建立并选择摄像头对象')
|
|
|
|
|
siblings = [c for c in objects('cameras') if c.get('assetId') == asset['id'] and c['id'] != key]
|
|
|
|
|
if len(siblings) >= asset['lensCount']:
|
|
|
|
|
raise Problem('该摄像头的镜头通道数已达到档案中设置的镜头数')
|
|
|
|
|
site = asset['siteId']
|
|
|
|
|
source_kind = body.get('sourceKind', 'direct')
|
|
|
|
|
recorder = get_object('recorders', body.get('recorderId')) if source_kind == 'recorder' else None
|
|
|
|
|
if source_kind not in ('direct', 'recorder') or (source_kind == 'recorder' and not recorder):
|
|
|
|
|
raise Problem('请选择录像机或直接接入')
|
|
|
|
|
item.update(assetId=asset['id'], sourceKind=source_kind, recorderId=recorder['id'] if recorder else '',
|
|
|
|
|
channelNumber=clean_text(body.get('channelNumber', ''), 30),
|
|
|
|
|
archiveSource=body.get('archiveSource', 'recorder' if recorder else 'local'))
|
|
|
|
|
if item['archiveSource'] not in ('local', 'recorder'):
|
|
|
|
|
raise Problem('录像存储来源不正确')
|
2026-10-03 16:16:23 +08:00
|
|
|
archive_recorder = body.get('archiveRecorderId') or (recorder['id'] if recorder else '')
|
|
|
|
|
if archive_recorder and not get_object('recorders', archive_recorder):
|
|
|
|
|
raise Problem('关联的录像存储设备不存在')
|
|
|
|
|
item['archiveRecorderId'] = archive_recorder
|
2026-10-03 16:12:25 +08:00
|
|
|
item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')),
|
2026-10-04 10:43:17 +08:00
|
|
|
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family)
|
2026-10-05 18:23:25 +08:00
|
|
|
item['onvifPort'] = integer(body.get('onvifPort', old.get('onvifPort', 80)), 1, 65535)
|
2026-10-04 10:43:17 +08:00
|
|
|
HOUSEHOLDS.endpoint(actor, item['host'])
|
2026-10-03 16:12:25 +08:00
|
|
|
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
|
|
|
|
|
path = clean_text(body.get(k, default), 500, required=True)
|
|
|
|
|
if not path.startswith('/') or path.startswith('//') or '#' in path:
|
|
|
|
|
raise Problem('码流路径必须以一个 / 开头,不含 #')
|
|
|
|
|
item[k] = path
|
|
|
|
|
password = body.get('password', '')
|
|
|
|
|
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
|
|
|
|
|
raise Problem('设备密码格式不正确')
|
|
|
|
|
item['password'] = '' if recorder else password or old.get('password', '')
|
|
|
|
|
for k in ('enabled', 'record'):
|
|
|
|
|
if not isinstance(body.get(k, False), bool):
|
|
|
|
|
raise Problem('开关值不正确')
|
|
|
|
|
item[k] = body.get(k, False)
|
|
|
|
|
if not old and len(objects('cameras')) >= 32:
|
|
|
|
|
raise Problem('此版本最多配置 32 台设备')
|
|
|
|
|
save_object('cameras', item)
|
|
|
|
|
write_media_config()
|
|
|
|
|
audit('保存摄像头', item['name'])
|
|
|
|
|
return public_camera(item)
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 04:52:39 +08:00
|
|
|
@serialized_save
|
2026-10-04 10:43:17 +08:00
|
|
|
def save_asset(body, actor=None):
|
|
|
|
|
family = HOUSEHOLDS.prepare(actor, 'assets', body, (('sites', 'siteId'),))
|
2026-10-04 04:52:39 +08:00
|
|
|
key = object_key('assets', body)
|
2026-10-03 16:12:25 +08:00
|
|
|
site = body.get('siteId')
|
|
|
|
|
if not get_object('sites', site):
|
|
|
|
|
raise Problem('请选择所属空间')
|
|
|
|
|
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
|
|
|
|
|
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')}
|
2026-10-04 10:43:17 +08:00
|
|
|
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family)
|
2026-10-04 11:19:14 +08:00
|
|
|
item.update(INVENTORY.metadata(body, get_object('assets', key)))
|
2026-10-03 16:12:25 +08:00
|
|
|
attached = [c for c in objects('cameras') if c.get('assetId') == key]
|
|
|
|
|
if len(attached) > item['lensCount']:
|
|
|
|
|
raise Problem('镜头数不能小于已经建立的通道数')
|
2026-10-04 04:52:39 +08:00
|
|
|
with DB:
|
|
|
|
|
DB.execute('INSERT OR REPLACE INTO assets VALUES (?,?)', (key, json.dumps(item)))
|
2026-10-03 16:12:25 +08:00
|
|
|
for c in attached:
|
|
|
|
|
c['siteId'] = site
|
2026-10-04 04:52:39 +08:00
|
|
|
DB.execute('UPDATE cameras SET body=? WHERE id=?', (json.dumps(c), c['id']))
|
2026-10-03 16:12:25 +08:00
|
|
|
audit('保存摄像头对象', item['name'])
|
|
|
|
|
return item
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 04:52:39 +08:00
|
|
|
@serialized_save
|
2026-10-04 10:43:17 +08:00
|
|
|
def save_recorder(body, actor=None):
|
|
|
|
|
family = HOUSEHOLDS.prepare(actor, 'recorders', body, (('sites', 'siteId'),))
|
2026-10-04 04:52:39 +08:00
|
|
|
key = object_key('recorders', body)
|
2026-10-03 16:12:25 +08:00
|
|
|
old = get_object('recorders', key) or {}
|
|
|
|
|
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
|
|
|
|
|
for k in ('name', 'brand', 'model', 'username')}
|
|
|
|
|
site = body.get('siteId')
|
|
|
|
|
if not get_object('sites', site):
|
|
|
|
|
raise Problem('请选择录像机所属空间')
|
|
|
|
|
item.update(id=key, siteId=site, host=host_address(body.get('host', '')),
|
2026-10-04 10:43:17 +08:00
|
|
|
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'), familyId=family)
|
|
|
|
|
HOUSEHOLDS.endpoint(actor, item['host'])
|
2026-10-03 16:12:25 +08:00
|
|
|
if item['driver'] not in ('generic-rtsp', 'hikvision-rtsp', 'tplink-rtsp', 'dahua-rtsp'):
|
|
|
|
|
raise Problem('接入驱动不正确')
|
|
|
|
|
password = body.get('password', '')
|
|
|
|
|
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
|
|
|
|
|
raise Problem('密码格式不正确')
|
|
|
|
|
item['password'] = password or old.get('password', '')
|
2026-10-04 11:19:14 +08:00
|
|
|
item.update(INVENTORY.metadata(body, old))
|
2026-10-03 16:12:25 +08:00
|
|
|
save_object('recorders', item)
|
|
|
|
|
write_media_config()
|
|
|
|
|
audit('保存录像机', item['name'])
|
|
|
|
|
return public_camera(item)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def public_camera(c):
|
2026-10-04 04:52:39 +08:00
|
|
|
result = {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c.get('password'))}
|
|
|
|
|
if c.get('sourceKind') == 'recorder':
|
|
|
|
|
recorder = get_object('recorders', c.get('recorderId'))
|
|
|
|
|
if recorder:
|
|
|
|
|
result.update({k: recorder[k] for k in ('host', 'port', 'username')})
|
|
|
|
|
result['passwordConfigured'] = bool(recorder.get('password'))
|
|
|
|
|
return result
|
2026-10-03 16:12:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def stream_name(c, quality='main'):
|
|
|
|
|
return 'cam_' + c['id'] + '_' + quality
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def source(c, quality):
|
|
|
|
|
endpoint = get_object('recorders', c.get('recorderId')) if c.get('sourceKind') == 'recorder' else c
|
|
|
|
|
if not endpoint:
|
|
|
|
|
raise Problem('关联录像机不存在')
|
|
|
|
|
auth = (url.quote(endpoint['username'], safe='') + ':' + url.quote(endpoint['password'], safe='') + '@') if endpoint['username'] else ''
|
|
|
|
|
return 'rtsp://' + auth + endpoint['host'] + ':' + str(endpoint['port']) + c[quality + 'Path']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_media_config():
|
|
|
|
|
with LOCK:
|
|
|
|
|
paths = {}
|
|
|
|
|
policy = setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
|
|
|
|
|
for c in objects('cameras'):
|
|
|
|
|
for quality in ('main', 'sub'):
|
|
|
|
|
paths[stream_name(c, quality)] = {
|
|
|
|
|
'source': source(c, quality) if c['enabled'] else 'publisher',
|
|
|
|
|
'rtspTransport': 'tcp',
|
|
|
|
|
'sourceOnDemand': quality == 'sub' and c['enabled'],
|
|
|
|
|
'record': bool(c['enabled'] and c['record'] and quality == 'main' and not STORAGE['paused']),
|
|
|
|
|
'recordDeleteAfter': str(policy['retentionDays'] * 24) + 'h',
|
|
|
|
|
}
|
2026-10-03 16:58:04 +08:00
|
|
|
for quality in ('compat', 'hd'):
|
|
|
|
|
paths[stream_name(c, quality)] = {
|
|
|
|
|
'source': 'publisher', 'record': False,
|
|
|
|
|
'runOnDemand': shlex.join([sys.executable, str(ROOT / 'preview.py'), str(DATA)]) if c['enabled'] else '',
|
|
|
|
|
'runOnDemandRestart': True,
|
|
|
|
|
'runOnDemandStartTimeout': '45s', 'runOnDemandCloseAfter': '15s',
|
|
|
|
|
}
|
2026-10-03 16:12:25 +08:00
|
|
|
config = {
|
|
|
|
|
'logLevel': 'warn', 'logDestinations': ['stdout'],
|
|
|
|
|
'api': True, 'apiAddress': '127.0.0.1:' + str(API_PORT),
|
|
|
|
|
'rtsp': True, 'rtspAddress': '127.0.0.1:18554', 'rtspTransports': ['tcp'],
|
|
|
|
|
'rtmp': False, 'srt': False, 'webrtc': False, 'moq': False,
|
|
|
|
|
'hls': True, 'hlsAddress': '127.0.0.1:' + str(HLS_PORT), 'hlsVariant': 'fmp4',
|
|
|
|
|
'hlsSegmentDuration': '1s', 'hlsSegmentCount': 5, 'hlsAllowOrigins': [],
|
|
|
|
|
'playback': True, 'playbackAddress': '127.0.0.1:' + str(PLAYBACK_PORT),
|
|
|
|
|
'playbackAllowOrigins': [],
|
|
|
|
|
'authMethod': 'internal',
|
|
|
|
|
'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'),
|
|
|
|
|
'ips': ['127.0.0.1', '::1'],
|
2026-10-03 16:49:48 +08:00
|
|
|
'permissions': [{'action': a} for a in ('read', 'api', 'playback')]
|
2026-10-03 16:58:04 +08:00
|
|
|
+ [{'action': 'publish', 'path': '~^cam_[a-f0-9]{16}_(compat|hd)$'}]}],
|
2026-10-03 16:12:25 +08:00
|
|
|
'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'),
|
|
|
|
|
'recordSegmentDuration': '5m', 'recordPartDuration': '1s'},
|
|
|
|
|
'paths': paths,
|
|
|
|
|
}
|
|
|
|
|
raw = json.dumps(config, ensure_ascii=False, indent=2)
|
|
|
|
|
if CONFIG.exists() and CONFIG.read_text(encoding='utf8') == raw:
|
|
|
|
|
return
|
|
|
|
|
temp = DATA / 'mediamtx.tmp'
|
|
|
|
|
temp.write_text(raw, encoding='utf8')
|
|
|
|
|
os.chmod(temp, 0o600)
|
|
|
|
|
os.replace(temp, CONFIG)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def media_json(path, playback=False):
|
|
|
|
|
target = 'http://127.0.0.1:' + str(PLAYBACK_PORT if playback else API_PORT) + path
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH})
|
|
|
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
|
|
|
return json.load(r)
|
2026-10-03 17:15:09 +08:00
|
|
|
except urllib.error.HTTPError as exc:
|
2026-10-04 04:58:56 +08:00
|
|
|
with 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
|
2026-10-03 17:15:09 +08:00
|
|
|
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
|
2026-10-03 16:12:25 +08:00
|
|
|
except Exception:
|
|
|
|
|
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 16:26:28 +08:00
|
|
|
def media_config_watch():
|
|
|
|
|
# Reconcile API configuration after atomic file replacements. Multiple rapid
|
|
|
|
|
# writes can be coalesced by a filesystem watcher; the file remains authority.
|
|
|
|
|
applied = None
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
raw = CONFIG.read_bytes()
|
|
|
|
|
digest = hashlib.sha256(raw).digest()
|
|
|
|
|
if digest != applied:
|
|
|
|
|
desired = json.loads(raw)['paths']
|
|
|
|
|
existing = {p['name'] for p in media_json('/v3/config/paths/list?itemsPerPage=200').get('items', [])}
|
|
|
|
|
for name, body in desired.items():
|
|
|
|
|
action, method = ('patch', 'PATCH') if name in existing else ('add', 'POST')
|
|
|
|
|
req = urllib.request.Request('http://127.0.0.1:' + str(API_PORT) + '/v3/config/paths/' + action + '/' + name,
|
|
|
|
|
data=json.dumps(body).encode(), method=method,
|
|
|
|
|
headers={'Authorization': MEDIA_AUTH, 'Content-Type': 'application/json'})
|
|
|
|
|
with urllib.request.urlopen(req, timeout=5) as response:
|
|
|
|
|
response.read()
|
|
|
|
|
applied = digest
|
|
|
|
|
except Exception:
|
|
|
|
|
pass # Retry on the next pass, including after a media process restart.
|
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
|
|
|
|
|
2026-10-03 16:12:25 +08:00
|
|
|
def status():
|
|
|
|
|
try:
|
|
|
|
|
data = media_json('/v3/paths/list?itemsPerPage=200')
|
|
|
|
|
ready = {p['name']: {'ready': bool(p.get('ready')), 'tracks': p.get('tracks', []),
|
|
|
|
|
'bytesReceived': p.get('bytesReceived', 0)} for p in data.get('items', [])}
|
|
|
|
|
media = True
|
|
|
|
|
except Problem:
|
|
|
|
|
ready, media = {}, False
|
|
|
|
|
cameras = []
|
|
|
|
|
for c in objects('cameras'):
|
|
|
|
|
if c.get('sourceKind') == 'recorder':
|
|
|
|
|
endpoint = get_object('recorders', c.get('recorderId'))
|
|
|
|
|
if endpoint:
|
|
|
|
|
c = dict(c, host=endpoint['host'], port=endpoint['port'])
|
|
|
|
|
main = ready.get(stream_name(c), {})
|
|
|
|
|
cameras.append({**public_camera(c), 'ready': main.get('ready', False), 'tracks': main.get('tracks', []),
|
|
|
|
|
'recordingRequested': c['enabled'] and c['record'] and not STORAGE['paused'],
|
|
|
|
|
'recordingActive': bool(c['enabled'] and c['record'] and not STORAGE['paused'] and main.get('ready', False)
|
|
|
|
|
and time.time() - RECENT_RECORDS.get(stream_name(c), 0) < 45)})
|
|
|
|
|
with LOCK:
|
|
|
|
|
storage = dict(STORAGE)
|
|
|
|
|
scan = dict(SCAN)
|
|
|
|
|
return {'cameras': cameras, 'assets': objects('assets'), 'recorders': [public_camera(r) for r in objects('recorders')],
|
2026-10-04 11:19:14 +08:00
|
|
|
'sites': objects('sites'), 'devices': objects('devices'), 'mediaOnline': media,
|
2026-10-03 16:12:25 +08:00
|
|
|
'storage': {**setting('storage'), **storage}, 'scan': scan,
|
|
|
|
|
'version': (ROOT / 'VERSION').read_text().strip(), 'uptime': round(time.time() - STARTED)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def storage_watch():
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
usage = shutil.disk_usage(RECORDINGS)
|
|
|
|
|
used, recent = 0, {}
|
|
|
|
|
for p in RECORDINGS.rglob('*.mp4'):
|
|
|
|
|
try:
|
|
|
|
|
info = p.stat()
|
|
|
|
|
used += info.st_size
|
|
|
|
|
recent[p.parent.name] = max(recent.get(p.parent.name, 0), info.st_mtime)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
|
|
|
|
policy = setting('storage')
|
|
|
|
|
free_gb, used_gb = usage.free / 1e9, used / 1e9
|
|
|
|
|
reason = '录像空间已达到配额' if used_gb >= policy['maxGB'] else ('磁盘可用空间不足' if free_gb < policy['reserveGB'] else '')
|
|
|
|
|
with LOCK:
|
|
|
|
|
changed = STORAGE['paused'] != bool(reason)
|
|
|
|
|
STORAGE.update(freeGB=round(free_gb, 2), usedGB=round(used_gb, 2), paused=bool(reason), reason=reason)
|
|
|
|
|
RECENT_RECORDS.clear()
|
|
|
|
|
RECENT_RECORDS.update(recent)
|
|
|
|
|
if changed:
|
|
|
|
|
write_media_config()
|
|
|
|
|
audit('录像空间保护', reason or '空间恢复,继续按设备配置录像')
|
|
|
|
|
except Exception:
|
|
|
|
|
with LOCK:
|
|
|
|
|
STORAGE.update(paused=True, reason='存储状态读取失败')
|
|
|
|
|
try:
|
|
|
|
|
write_media_config()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
time.sleep(20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def discover(network):
|
|
|
|
|
def probe(ip):
|
|
|
|
|
try:
|
2026-10-03 17:15:09 +08:00
|
|
|
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())
|
2026-10-03 16:12:25 +08:00
|
|
|
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}
|
|
|
|
|
except OSError:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=24) as pool:
|
|
|
|
|
items = [p for p in pool.map(probe, network.hosts()) if p]
|
|
|
|
|
with LOCK:
|
|
|
|
|
SCAN.update(items=items, at=dt.datetime.now(dt.timezone.utc).isoformat())
|
|
|
|
|
except Exception:
|
|
|
|
|
with LOCK:
|
|
|
|
|
SCAN['error'] = '探测未完成,请检查主机网络'
|
|
|
|
|
finally:
|
|
|
|
|
with LOCK:
|
|
|
|
|
SCAN['running'] = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_time(text):
|
|
|
|
|
try:
|
|
|
|
|
value = dt.datetime.fromisoformat(text.replace('Z', '+00:00'))
|
|
|
|
|
if value.tzinfo is None:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
return value
|
|
|
|
|
except (ValueError, AttributeError):
|
|
|
|
|
raise Problem('时间必须包含时区')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
server_version = 'ZhaoVision'
|
|
|
|
|
|
|
|
|
|
def log_message(self, *_):
|
|
|
|
|
pass # Do not put camera credentials, cookies or playback queries into logs.
|
|
|
|
|
|
|
|
|
|
def answer(self, value, code=200, cookie=None):
|
|
|
|
|
data = json.dumps(value, ensure_ascii=False).encode()
|
|
|
|
|
self.send_response(code)
|
|
|
|
|
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
|
|
|
|
self.send_header('Content-Length', str(len(data)))
|
|
|
|
|
self.send_header('Cache-Control', 'no-store')
|
|
|
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
|
|
|
if cookie:
|
|
|
|
|
self.send_header('Set-Cookie', cookie)
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(data)
|
|
|
|
|
|
2026-10-04 17:22:42 +08:00
|
|
|
def image(self, raw, public=False):
|
|
|
|
|
self.send_response(200)
|
|
|
|
|
self.send_header('Content-Type', 'image/png')
|
|
|
|
|
self.send_header('Content-Length', str(len(raw)))
|
|
|
|
|
self.send_header('Cache-Control', 'no-cache' if public else 'no-store')
|
|
|
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(raw)
|
|
|
|
|
|
2026-10-03 16:12:25 +08:00
|
|
|
def token(self):
|
|
|
|
|
try:
|
|
|
|
|
c = http.cookies.SimpleCookie(self.headers.get('Cookie', ''))
|
|
|
|
|
return c['vision'].value if 'vision' in c else ''
|
|
|
|
|
except http.cookies.CookieError:
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
|
|
def authenticated(self):
|
|
|
|
|
token = self.token()
|
|
|
|
|
if not re.fullmatch('[a-f0-9]{64}', token):
|
2026-10-03 17:41:52 +08:00
|
|
|
return None
|
2026-10-03 16:12:25 +08:00
|
|
|
with LOCK:
|
2026-10-03 17:41:52 +08:00
|
|
|
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
|
2026-10-03 16:12:25 +08:00
|
|
|
|
|
|
|
|
def require_auth(self):
|
2026-10-03 17:41:52 +08:00
|
|
|
self.user = self.authenticated()
|
|
|
|
|
if not self.user:
|
2026-10-03 16:12:25 +08:00
|
|
|
raise Problem('请先登录', 401)
|
2026-10-04 10:43:17 +08:00
|
|
|
self.user = HOUSEHOLDS.context(self.user, self.headers.get('X-Household-Id'))
|
|
|
|
|
|
|
|
|
|
def require_manager(self):
|
|
|
|
|
if not HOUSEHOLDS.manager(self.user):
|
|
|
|
|
raise Problem('此操作需要家庭管理员权限', 403)
|
2026-10-03 16:12:25 +08:00
|
|
|
|
2026-10-03 17:41:52 +08:00
|
|
|
def require_admin(self):
|
|
|
|
|
if self.user['role'] != 'admin':
|
|
|
|
|
raise Problem('此操作需要管理员权限', 403)
|
|
|
|
|
|
2026-10-03 20:27:11 +08:00
|
|
|
def body(self, maximum=16384):
|
2026-10-03 16:12:25 +08:00
|
|
|
if self.headers.get_content_type() != 'application/json':
|
|
|
|
|
raise Problem('请求需要 JSON 格式')
|
2026-10-03 20:27:11 +08:00
|
|
|
try:
|
|
|
|
|
n = int(self.headers.get('Content-Length', '0'))
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise Problem('请求长度不正确')
|
|
|
|
|
if not 0 < n <= maximum:
|
2026-10-03 16:12:25 +08:00
|
|
|
raise Problem('请求长度不正确')
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(self.rfile.read(n))
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
raise ValueError()
|
|
|
|
|
return data
|
|
|
|
|
except (ValueError, UnicodeError):
|
|
|
|
|
raise Problem('请求内容无法解析')
|
|
|
|
|
|
|
|
|
|
def validate_origin(self):
|
|
|
|
|
origin = self.headers.get('Origin')
|
2026-10-04 04:52:39 +08:00
|
|
|
if origin:
|
|
|
|
|
try:
|
|
|
|
|
parsed = url.urlsplit(origin)
|
|
|
|
|
valid = parsed.scheme in ('http', 'https') and parsed.netloc == self.headers.get('Host')
|
|
|
|
|
except ValueError:
|
|
|
|
|
valid = False
|
|
|
|
|
if not valid:
|
|
|
|
|
raise Problem('请从系统页面提交操作', 403)
|
2026-10-03 16:12:25 +08:00
|
|
|
|
|
|
|
|
def login(self, data, setup=False):
|
|
|
|
|
ip = self.client_address[0]
|
|
|
|
|
now = time.time()
|
|
|
|
|
with LOCK:
|
|
|
|
|
for k in list(ATTEMPTS):
|
|
|
|
|
if ATTEMPTS[k][1] < now:
|
|
|
|
|
del ATTEMPTS[k]
|
|
|
|
|
count, expiry = ATTEMPTS.get(ip, (0, now + 900))
|
|
|
|
|
ATTEMPTS[ip] = (count + 1, expiry)
|
|
|
|
|
if count >= 12:
|
|
|
|
|
raise Problem('尝试次数过多,请 15 分钟后重试', 429)
|
2026-10-03 17:41:52 +08:00
|
|
|
accounts = objects('users')
|
2026-10-03 16:12:25 +08:00
|
|
|
password = data.get('password', '')
|
|
|
|
|
if setup:
|
2026-10-03 17:41:52 +08:00
|
|
|
if accounts:
|
2026-10-03 16:12:25 +08:00
|
|
|
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)
|
2026-10-03 17:41:52 +08:00
|
|
|
account = ACCOUNTS.save({'username': 'admin', 'name': '管理员', 'role': 'admin',
|
|
|
|
|
'password': password, 'familyAccess': 'edit'}, None)
|
2026-10-03 16:12:25 +08:00
|
|
|
(DATA / 'setup-code.txt').unlink(missing_ok=True)
|
|
|
|
|
audit('初始化管理员')
|
|
|
|
|
else:
|
2026-10-03 17:41:52 +08:00
|
|
|
if not accounts:
|
2026-10-03 16:12:25 +08:00
|
|
|
raise Problem('请先初始化管理员', 409)
|
2026-10-03 17:41:52 +08:00
|
|
|
account = ACCOUNTS.verify(data.get('username', ''), password)
|
|
|
|
|
if not account:
|
|
|
|
|
raise Problem('用户名或密码不正确', 403)
|
2026-10-03 16:12:25 +08:00
|
|
|
token = secrets.token_hex(32)
|
|
|
|
|
DB.execute('DELETE FROM sessions WHERE expires<?', (now,))
|
2026-10-03 17:41:52 +08:00
|
|
|
DB.execute('INSERT INTO sessions (hash,expires,user_id) VALUES (?,?,?)',
|
|
|
|
|
(hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7, account['id']))
|
2026-10-03 16:12:25 +08:00
|
|
|
DB.commit()
|
|
|
|
|
ATTEMPTS.pop(ip, None)
|
|
|
|
|
secure = '; Secure' if os.environ.get('VISION_SECURE_COOKIE') == '1' else ''
|
|
|
|
|
self.answer({'ok': True}, cookie='vision=' + token + '; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800' + secure)
|
|
|
|
|
|
|
|
|
|
def do_POST(self):
|
|
|
|
|
try:
|
|
|
|
|
path = url.urlsplit(self.path).path
|
2026-10-04 19:22:31 +08:00
|
|
|
if path == '/api/journal/chunk':
|
|
|
|
|
self.validate_origin(); self.require_auth()
|
|
|
|
|
query = dict(url.parse_qsl(url.urlsplit(self.path).query))
|
|
|
|
|
try:
|
|
|
|
|
size = int(self.headers.get('Content-Length', '0')); offset = int(query.get('offset', '-1'))
|
|
|
|
|
except ValueError: raise Problem('上传分片参数不正确')
|
|
|
|
|
if not 0 < size <= journal.CHUNK: raise Problem('上传分片大小不正确')
|
|
|
|
|
self.connection.settimeout(45)
|
|
|
|
|
raw = self.rfile.read(size)
|
|
|
|
|
if len(raw) != size: raise Problem('分片未完整接收,请重试')
|
|
|
|
|
self.answer(JOURNAL.chunk(self.user, query.get('id'), offset, raw))
|
|
|
|
|
return
|
2026-10-03 20:27:11 +08:00
|
|
|
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
|
2026-10-04 19:22:31 +08:00
|
|
|
data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage','/api/journal') else 16384)
|
2026-10-03 20:27:11 +08:00
|
|
|
self.validate_origin()
|
2026-10-03 16:12:25 +08:00
|
|
|
if path in ('/api/login', '/api/setup'):
|
|
|
|
|
self.login(data, path == '/api/setup')
|
|
|
|
|
return
|
|
|
|
|
self.require_auth()
|
2026-10-04 17:22:42 +08:00
|
|
|
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore',
|
2026-10-04 19:22:31 +08:00
|
|
|
'/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets',
|
2026-10-04 20:03:44 +08:00
|
|
|
'/api/journal', '/api/journal/action', '/api/journal/upload', '/api/journal/finish', '/api/journal/remove-upload', '/api/journal/restore-upload'):
|
2026-10-04 10:43:17 +08:00
|
|
|
self.require_manager()
|
2026-10-03 16:12:25 +08:00
|
|
|
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')
|
2026-10-04 17:22:42 +08:00
|
|
|
elif path == '/api/preferences':
|
|
|
|
|
self.answer(APPEARANCE.save_preferences(data, self.user))
|
2026-10-04 19:22:31 +08:00
|
|
|
elif path == '/api/baidu/config':
|
|
|
|
|
self.require_admin(); self.answer(BAIDU.configure(data, self.user))
|
|
|
|
|
elif path == '/api/baidu/connect':
|
|
|
|
|
self.require_admin()
|
|
|
|
|
result, cookie = BAIDU.begin(self.user, 'media', self.token(), self.headers.get('Host'))
|
|
|
|
|
self.answer(result, cookie=cookie)
|
|
|
|
|
elif path == '/api/baidu/disconnect':
|
|
|
|
|
self.require_admin(); self.answer(BAIDU.disconnect(self.user, 'media'))
|
|
|
|
|
elif path == '/api/journal':
|
|
|
|
|
self.answer(JOURNAL.save(self.user, data))
|
|
|
|
|
elif path == '/api/journal/action':
|
|
|
|
|
self.answer(JOURNAL.action(self.user, data))
|
|
|
|
|
elif path == '/api/journal/upload':
|
|
|
|
|
self.answer(JOURNAL.begin_upload(self.user, data))
|
|
|
|
|
elif path == '/api/journal/finish':
|
|
|
|
|
self.answer(JOURNAL.finish_upload(self.user, data.get('id')))
|
|
|
|
|
elif path == '/api/journal/remove-upload':
|
|
|
|
|
self.answer(JOURNAL.remove_upload(self.user, data.get('id')))
|
2026-10-04 20:03:44 +08:00
|
|
|
elif path == '/api/journal/restore-upload':
|
|
|
|
|
self.answer(JOURNAL.restore_upload(self.user, data.get('id')))
|
2026-10-04 17:22:42 +08:00
|
|
|
elif path == '/api/appearance':
|
|
|
|
|
self.require_admin()
|
|
|
|
|
self.answer(APPEARANCE.save_config(data, self.user))
|
|
|
|
|
elif path == '/api/backups/config':
|
|
|
|
|
self.require_admin()
|
|
|
|
|
self.answer(BACKUPS.save(data))
|
|
|
|
|
elif path == '/api/backups/run':
|
|
|
|
|
self.require_admin()
|
|
|
|
|
self.answer(BACKUPS.start())
|
2026-10-04 04:52:39 +08:00
|
|
|
elif path == '/api/account/password':
|
|
|
|
|
result = ACCOUNTS.change_password(data, self.user)
|
|
|
|
|
self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
|
|
|
|
elif path == '/api/family/restore':
|
|
|
|
|
self.answer(FAMILY.restore(data, self.user))
|
|
|
|
|
elif path == '/api/camera/settings':
|
|
|
|
|
c = ACCOUNTS.camera(self.user, get_object('cameras', data.get('camera')))
|
|
|
|
|
try:
|
|
|
|
|
result = camera_settings.CONTROLLER.save(c, data)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise Problem(str(e))
|
|
|
|
|
except Exception:
|
|
|
|
|
raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502)
|
|
|
|
|
audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username'])
|
|
|
|
|
self.answer(result)
|
2026-10-04 08:33:00 +08:00
|
|
|
elif path == '/api/ptz/presets':
|
|
|
|
|
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
|
|
|
|
|
if data.get('action') != 'goto':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.require_manager()
|
2026-10-04 08:33:00 +08:00
|
|
|
try:
|
|
|
|
|
result = ptz.CONTROLLER.preset_command(c, data)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise Problem(str(e), 400)
|
|
|
|
|
except Exception:
|
|
|
|
|
raise Problem('设备操作未确认,请核对画面并重新读取预置位,勿重复提交;转动异常可点击停止', 502)
|
|
|
|
|
audit('云台预置位 ' + data['action'], c['name'] + ' / ' + self.user['username'])
|
|
|
|
|
self.answer(result)
|
2026-10-03 18:29:18 +08:00
|
|
|
elif path == '/api/ptz':
|
|
|
|
|
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
|
|
|
|
|
try:
|
|
|
|
|
result = ptz.CONTROLLER.command(c, data.get('action'))
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise Problem(str(e), 400)
|
|
|
|
|
except Exception:
|
|
|
|
|
raise Problem('云台通信未成功,请检查设备连接;设备端的一秒超时保护会停止本次转动', 502)
|
|
|
|
|
audit('云台 ' + data['action'], c['name'])
|
|
|
|
|
self.answer(result)
|
2026-10-03 17:41:52 +08:00
|
|
|
elif path == '/api/users':
|
|
|
|
|
self.answer(ACCOUNTS.save(data, self.user))
|
2026-10-04 10:43:17 +08:00
|
|
|
elif path == '/api/households':
|
|
|
|
|
self.answer(HOUSEHOLDS.save(data, self.user))
|
|
|
|
|
elif path == '/api/households/leadership':
|
|
|
|
|
self.answer(HOUSEHOLDS.leadership(data, self.user))
|
2026-10-04 11:19:14 +08:00
|
|
|
elif path == '/api/device-view':
|
|
|
|
|
self.answer(HOUSEHOLDS.save_view(data, self.user))
|
2026-10-04 16:21:40 +08:00
|
|
|
elif path == '/api/heritage':
|
|
|
|
|
self.answer(HERITAGE.save(data, self.user))
|
2026-10-04 11:19:14 +08:00
|
|
|
elif path == '/api/devices':
|
|
|
|
|
self.answer(INVENTORY.save(data, self.user))
|
2026-10-03 20:02:07 +08:00
|
|
|
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
|
2026-10-03 17:41:52 +08:00
|
|
|
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
|
2026-10-03 16:12:25 +08:00
|
|
|
elif path == '/api/sites':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.answer(save_site(data, self.user))
|
2026-10-03 16:12:25 +08:00
|
|
|
elif path == '/api/cameras':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.answer(save_camera(data, self.user))
|
2026-10-03 16:12:25 +08:00
|
|
|
elif path == '/api/assets':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.answer(save_asset(data, self.user))
|
2026-10-03 16:12:25 +08:00
|
|
|
elif path == '/api/recorders':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.answer(save_recorder(data, self.user))
|
2026-10-03 16:16:23 +08:00
|
|
|
elif path == '/api/onvif':
|
|
|
|
|
old = get_object('cameras', data.get('id')) or {}
|
2026-10-04 10:43:17 +08:00
|
|
|
if data.get('id'):
|
|
|
|
|
HOUSEHOLDS.owns(self.user, old)
|
2026-10-03 16:16:23 +08:00
|
|
|
host = host_address(data.get('host', ''))
|
2026-10-04 10:43:17 +08:00
|
|
|
HOUSEHOLDS.endpoint(self.user, host)
|
2026-10-03 16:16:23 +08:00
|
|
|
port = integer(data.get('port', 80), 1, 65535)
|
|
|
|
|
username = clean_text(data.get('username', ''), 120, required=True)
|
|
|
|
|
password = data.get('password') or old.get('password', '')
|
|
|
|
|
if not isinstance(password, str) or len(password) > 256:
|
|
|
|
|
raise Problem('设备密码格式不正确')
|
|
|
|
|
try:
|
|
|
|
|
result = onvif.inspect(host, port, username, password)
|
2026-10-05 18:23:25 +08:00
|
|
|
except PermissionError:
|
|
|
|
|
raise Problem('摄像机认证失败,请填写设备本地账号和密码;平台接入密码可能不同', 422)
|
|
|
|
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
|
|
|
raise Problem('服务器无法连接摄像机,请检查设备地址、ONVIF 端口以及服务器到该局域网的路由', 502)
|
2026-10-03 16:16:23 +08:00
|
|
|
except Exception:
|
|
|
|
|
raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502)
|
|
|
|
|
self.answer(result)
|
2026-10-03 16:12:25 +08:00
|
|
|
elif path == '/api/storage':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.require_admin()
|
2026-10-03 16:12:25 +08:00
|
|
|
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
|
|
|
|
|
'maxGB': integer(data.get('maxGB'), 5, 100000),
|
|
|
|
|
'reserveGB': integer(data.get('reserveGB'), 2, 1000)}
|
|
|
|
|
set_setting('storage', policy)
|
|
|
|
|
write_media_config()
|
|
|
|
|
audit('更新录像保留策略')
|
|
|
|
|
self.answer({'ok': True})
|
|
|
|
|
elif path == '/api/discover':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.require_admin()
|
2026-10-03 16:12:25 +08:00
|
|
|
try:
|
|
|
|
|
network = ipaddress.ip_network(data.get('network', ''), strict=False)
|
|
|
|
|
if network.version != 4 or network.prefixlen < 24 or network.prefixlen > 30:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
host_address(str(network.network_address))
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise Problem('请输入最多 254 个地址的局域网网段,例如 192.168.1.0/24')
|
|
|
|
|
with LOCK:
|
|
|
|
|
if SCAN['running']:
|
|
|
|
|
raise Problem('探测正在进行', 409)
|
|
|
|
|
SCAN.update(running=True, items=[], error='', network=str(network))
|
|
|
|
|
threading.Thread(target=discover, args=(network,), daemon=True).start()
|
|
|
|
|
self.answer({'ok': True}, 202)
|
|
|
|
|
else:
|
|
|
|
|
raise Problem('接口不存在', 404)
|
|
|
|
|
except Problem as e:
|
|
|
|
|
self.answer({'error': str(e)}, e.status)
|
|
|
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
|
|
|
pass
|
|
|
|
|
except Exception:
|
|
|
|
|
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
|
|
|
|
|
|
2026-10-03 17:10:06 +08:00
|
|
|
def proxy(self, port, path, download=False):
|
2026-10-03 16:49:48 +08:00
|
|
|
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=55)
|
2026-10-03 16:12:25 +08:00
|
|
|
headers = {'Authorization': MEDIA_AUTH}
|
|
|
|
|
if self.headers.get('Range'):
|
|
|
|
|
headers['Range'] = self.headers['Range']
|
|
|
|
|
started = False
|
|
|
|
|
try:
|
|
|
|
|
conn.request('GET', path, headers=headers)
|
|
|
|
|
r = conn.getresponse()
|
|
|
|
|
if r.status >= 400:
|
|
|
|
|
raise Problem('暂时没有可播放画面,请确认设备网络、账号、码流路径和录像时间', 503)
|
2026-10-03 16:45:58 +08:00
|
|
|
location = None
|
|
|
|
|
if 300 <= r.status < 400:
|
|
|
|
|
# MediaMTX starts an HLS session with a relative redirect. The
|
|
|
|
|
# browser must keep that session query on subsequent playlists.
|
|
|
|
|
target = url.urlsplit(url.urljoin(path, r.getheader('Location', '')))
|
|
|
|
|
if (port != HLS_PORT or target.scheme or target.netloc
|
2026-10-03 16:58:04 +08:00
|
|
|
or not re.fullmatch(r'/cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', target.path)):
|
2026-10-03 16:45:58 +08:00
|
|
|
raise Problem('视频服务返回了无效的播放跳转', 502)
|
|
|
|
|
location = '/media/live' + target.path + ('?' + target.query if target.query else '')
|
2026-10-03 16:12:25 +08:00
|
|
|
self.send_response(r.status)
|
2026-10-03 17:10:06 +08:00
|
|
|
if download:
|
|
|
|
|
self.send_header('Content-Disposition', 'attachment; filename="recording.mp4"')
|
2026-10-03 16:45:58 +08:00
|
|
|
if location:
|
|
|
|
|
self.send_header('Location', location)
|
2026-10-03 16:12:25 +08:00
|
|
|
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
|
|
|
|
|
if r.getheader(k):
|
|
|
|
|
self.send_header(k, r.getheader(k))
|
|
|
|
|
self.send_header('Cache-Control', 'no-store')
|
|
|
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
|
|
|
self.end_headers()
|
|
|
|
|
started = True
|
|
|
|
|
while True:
|
|
|
|
|
chunk = r.read(65536)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
self.wfile.write(chunk)
|
|
|
|
|
except (OSError, http.client.HTTPException):
|
|
|
|
|
if not started:
|
|
|
|
|
raise Problem('视频服务连接失败', 503)
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
2026-10-03 17:10:06 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-10-03 16:12:25 +08:00
|
|
|
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()}
|
2026-10-04 19:22:31 +08:00
|
|
|
if path == baidu.CALLBACK:
|
|
|
|
|
try:
|
|
|
|
|
BAIDU.callback(query, self.headers.get('Cookie', '')); result = 'ok'
|
|
|
|
|
except Problem: result = 'failed'
|
|
|
|
|
self.send_response(303)
|
|
|
|
|
self.send_header('Location', '/?baidu='+result)
|
|
|
|
|
self.send_header('Cache-Control', 'no-store')
|
|
|
|
|
self.send_header('Referrer-Policy', 'no-referrer')
|
|
|
|
|
self.send_header('Set-Cookie', 'baidu_flow=; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=0')
|
|
|
|
|
self.send_header('Content-Length', '0'); self.end_headers()
|
|
|
|
|
return
|
2026-10-04 17:22:42 +08:00
|
|
|
if path == '/api/branding':
|
|
|
|
|
self.answer(APPEARANCE.public())
|
|
|
|
|
return
|
|
|
|
|
if path in ('/brand-logo.png', '/brand-icon.png'):
|
2026-10-04 21:30:30 +08:00
|
|
|
kind='logo' if path == '/brand-logo.png' else 'icon'
|
|
|
|
|
config=APPEARANCE.config();value=config.get(kind+'Data','')
|
|
|
|
|
raw = CLOUD_IMAGES.get(config[kind+'Ref']) if config.get(kind+'Ref') else base64.b64decode(value.split(',',1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
|
2026-10-04 17:22:42 +08:00
|
|
|
self.image(raw, public=True)
|
|
|
|
|
return
|
2026-10-03 16:12:25 +08:00
|
|
|
if path == '/api/session':
|
2026-10-03 17:41:52 +08:00
|
|
|
user = self.authenticated()
|
2026-10-04 10:43:17 +08:00
|
|
|
user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id'))
|
2026-10-03 17:41:52 +08:00
|
|
|
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
|
2026-10-04 17:22:42 +08:00
|
|
|
'setupRequired': not bool(objects('users')), 'preferences': APPEARANCE.preferences(user) if user else None})
|
2026-10-03 16:12:25 +08:00
|
|
|
return
|
|
|
|
|
if path == '/healthz':
|
|
|
|
|
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
|
|
|
|
|
return
|
|
|
|
|
if path.startswith(('/api/', '/media/')):
|
|
|
|
|
self.require_auth()
|
|
|
|
|
if path == '/api/state':
|
2026-10-03 17:41:52 +08:00
|
|
|
self.answer(ACCOUNTS.state(self.user, status()))
|
2026-10-04 19:22:31 +08:00
|
|
|
elif path == '/api/baidu/config':
|
|
|
|
|
self.require_admin(); self.answer(BAIDU.public_config())
|
|
|
|
|
elif path == '/api/baidu/status':
|
|
|
|
|
self.answer(BAIDU.status(self.user))
|
|
|
|
|
elif path == '/api/journal':
|
|
|
|
|
self.answer(JOURNAL.snapshot(self.user, query))
|
|
|
|
|
elif path == '/media/journal':
|
|
|
|
|
self.user = HOUSEHOLDS.context(self.user, query.get('family'))
|
|
|
|
|
item = JOURNAL.media(self.user, query.get('id'))
|
|
|
|
|
with BAIDU.open_file('shared', item, self.headers.get('Range', '')) as response:
|
|
|
|
|
if response.status not in (200, 206): raise Problem('网盘媒体暂时不可用', 502)
|
|
|
|
|
self.send_response(response.status)
|
|
|
|
|
self.send_header('Content-Type', item['mime'])
|
|
|
|
|
self.send_header('Cache-Control', 'no-store')
|
|
|
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
|
|
|
self.send_header('Referrer-Policy', 'no-referrer')
|
|
|
|
|
self.send_header('Accept-Ranges', 'bytes')
|
|
|
|
|
self.send_header('Content-Disposition', ('attachment' if query.get('download') == '1' else 'inline')+"; filename*=UTF-8''"+url.quote(item['name']))
|
|
|
|
|
for header in ('Content-Length', 'Content-Range'):
|
|
|
|
|
if response.headers.get(header): self.send_header(header, response.headers[header])
|
|
|
|
|
self.end_headers()
|
|
|
|
|
remaining = item['size']
|
|
|
|
|
try:
|
|
|
|
|
while remaining > 0:
|
|
|
|
|
chunk = response.read(min(65536, remaining))
|
|
|
|
|
if not chunk: break
|
|
|
|
|
self.wfile.write(chunk); remaining -= len(chunk)
|
|
|
|
|
except (OSError, http.client.HTTPException): self.close_connection = True
|
2026-10-04 17:22:42 +08:00
|
|
|
elif path == '/api/preferences':
|
|
|
|
|
self.answer(APPEARANCE.preferences(self.user))
|
|
|
|
|
elif path == '/api/appearance':
|
|
|
|
|
self.require_admin()
|
2026-10-04 21:30:30 +08:00
|
|
|
self.answer(APPEARANCE.editable())
|
2026-10-04 17:22:42 +08:00
|
|
|
elif path == '/api/backups':
|
|
|
|
|
self.require_admin()
|
|
|
|
|
self.answer(BACKUPS.status())
|
|
|
|
|
elif path == '/api/households/cover':
|
|
|
|
|
home = get_object('households', query.get('id'))
|
|
|
|
|
if not home or self.user['role'] != 'admin' and home['id'] != HOUSEHOLDS.scope(self.user):
|
|
|
|
|
raise Problem('封面不存在或未获授权', 404)
|
2026-10-04 21:30:30 +08:00
|
|
|
if not home.get('coverRef') and not home.get('coverData'): raise Problem('尚未上传封面', 404)
|
|
|
|
|
self.image(CLOUD_IMAGES.get(home['coverRef']) if home.get('coverRef') else base64.b64decode(home['coverData'].split(',', 1)[1]))
|
2026-10-03 17:41:52 +08:00
|
|
|
elif path == '/api/users':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.require_manager()
|
|
|
|
|
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
|
|
|
|
|
if self.user['role'] == 'admin' or u['role'] != 'admin']})
|
2026-10-04 16:21:40 +08:00
|
|
|
elif path == '/api/heritage':
|
|
|
|
|
self.answer(HERITAGE.snapshot(self.user))
|
2026-10-04 10:43:17 +08:00
|
|
|
elif path == '/api/households':
|
|
|
|
|
self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)})
|
2026-10-04 08:33:00 +08:00
|
|
|
elif path == '/api/ptz/presets':
|
|
|
|
|
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
|
|
|
|
|
try:
|
|
|
|
|
result, _ = ptz.CONTROLLER.read_presets(c)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise Problem(str(e), 400)
|
|
|
|
|
except Exception:
|
|
|
|
|
raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502)
|
2026-10-04 10:43:17 +08:00
|
|
|
self.answer(dict(result, canManage=HOUSEHOLDS.manager(self.user)))
|
2026-10-03 18:29:18 +08:00
|
|
|
elif path == '/api/ptz':
|
|
|
|
|
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
|
|
|
|
|
try:
|
|
|
|
|
result = ptz.CONTROLLER.capabilities(c)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise Problem(str(e), 400)
|
|
|
|
|
except Exception:
|
|
|
|
|
raise Problem('未能读取云台能力,请确认摄像头的 ONVIF 服务和连接', 502)
|
|
|
|
|
self.answer(result)
|
2026-10-03 17:41:52 +08:00
|
|
|
elif path == '/api/family':
|
|
|
|
|
self.answer(FAMILY.snapshot(self.user))
|
2026-10-04 04:52:39 +08:00
|
|
|
elif path == '/api/camera/settings':
|
2026-10-04 10:43:17 +08:00
|
|
|
self.require_manager()
|
2026-10-04 04:52:39 +08:00
|
|
|
c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera')))
|
|
|
|
|
try:
|
|
|
|
|
result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main'))
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise Problem(str(e))
|
|
|
|
|
except Exception:
|
|
|
|
|
raise Problem('读取摄像机参数失败,请检查连接、设备账号或 ONVIF 服务', 502)
|
|
|
|
|
self.answer(result)
|
|
|
|
|
elif path in ('/api/family/history', '/api/family/change'):
|
|
|
|
|
try:
|
|
|
|
|
number = int(query['before' if path.endswith('history') else 'revision']) if ('before' if path.endswith('history') else 'revision') in query else None
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise Problem('修改记录编号不正确')
|
|
|
|
|
self.answer(FAMILY.history(self.user, query.get('kind'), query.get('id'), number)
|
|
|
|
|
if path.endswith('history') else FAMILY.change(self.user, number))
|
2026-10-03 16:12:25 +08:00
|
|
|
elif path == '/api/audit':
|
2026-10-03 17:41:52 +08:00
|
|
|
self.require_admin()
|
2026-10-03 16:12:25 +08:00
|
|
|
with LOCK:
|
|
|
|
|
rows = [dict(r) for r in DB.execute('SELECT * FROM audit ORDER BY id DESC LIMIT 50')]
|
|
|
|
|
self.answer(rows)
|
|
|
|
|
elif path == '/api/recordings':
|
|
|
|
|
c = get_object('cameras', query.get('camera'))
|
|
|
|
|
if not c:
|
|
|
|
|
raise Problem('请选择摄像头')
|
2026-10-03 17:41:52 +08:00
|
|
|
ACCOUNTS.camera(self.user, c)
|
2026-10-03 16:12:25 +08:00
|
|
|
start, end = parse_time(query.get('start')), parse_time(query.get('end'))
|
|
|
|
|
if not 0 < (end - start).total_seconds() <= 172800:
|
|
|
|
|
raise Problem('每次查询最多两天')
|
|
|
|
|
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'end': end.isoformat()})
|
|
|
|
|
raw = media_json('/list?' + q, playback=True)
|
|
|
|
|
items = [{'start': r['start'], 'duration': r['duration']} for r in raw]
|
|
|
|
|
self.answer({'items': items})
|
|
|
|
|
elif path.startswith('/media/live/'):
|
|
|
|
|
suffix = path[len('/media/live/'):]
|
2026-10-03 16:58:04 +08:00
|
|
|
if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', suffix):
|
2026-10-03 16:12:25 +08:00
|
|
|
raise Problem('视频路径不正确')
|
2026-10-03 17:41:52 +08:00
|
|
|
ACCOUNTS.camera(self.user, get_object('cameras', suffix[4:20]))
|
2026-10-03 16:12:25 +08:00
|
|
|
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)
|
2026-10-03 17:41:52 +08:00
|
|
|
ACCOUNTS.camera(self.user, c)
|
2026-10-03 16:12:25 +08:00
|
|
|
start = parse_time(query.get('start'))
|
|
|
|
|
try:
|
|
|
|
|
duration = float(query.get('duration', '300'))
|
|
|
|
|
if not math.isfinite(duration) or not 0 < duration <= 3600:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise Problem('单次回放最多一小时')
|
2026-10-03 17:10:06 +08:00
|
|
|
download = query.get('download') == '1'
|
2026-10-03 16:12:25 +08:00
|
|
|
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'duration': duration,
|
2026-10-03 17:10:06 +08:00
|
|
|
'format': 'mp4' if download else 'fmp4'})
|
|
|
|
|
if download:
|
|
|
|
|
self.proxy(PLAYBACK_PORT, '/get?' + q, download=True)
|
|
|
|
|
else:
|
|
|
|
|
self.compatible_playback('/get?' + q, duration)
|
2026-10-03 16:12:25 +08:00
|
|
|
else:
|
|
|
|
|
raise Problem('接口不存在', 404)
|
|
|
|
|
else:
|
2026-10-03 17:26:41 +08:00
|
|
|
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
|
2026-10-04 10:43:17 +08:00
|
|
|
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
|
2026-10-04 11:19:14 +08:00
|
|
|
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
|
2026-10-04 17:22:42 +08:00
|
|
|
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js',
|
2026-10-04 19:22:31 +08:00
|
|
|
'/journal.js': 'journal.js', '/file-hash.js': 'file-hash.js',
|
2026-10-04 21:30:30 +08:00
|
|
|
'/geography.js': 'geography.js', '/data/geography-data.js': 'data/geography-data.js',
|
2026-10-03 20:02:07 +08:00
|
|
|
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
|
2026-10-04 08:59:15 +08:00
|
|
|
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
|
2026-10-03 20:02:07 +08:00
|
|
|
'/family-events.js': 'family-events.js',
|
2026-10-04 04:52:39 +08:00
|
|
|
'/family-history.js': 'family-history.js',
|
|
|
|
|
'/camera-settings.js': 'camera-settings.js',
|
2026-10-04 08:33:00 +08:00
|
|
|
'/recording-ranges.js': 'recording-ranges.js', '/recording-ui.js': 'recording-ui.js', '/snapshot.js': 'snapshot.js',
|
2026-10-03 18:29:18 +08:00
|
|
|
'/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js',
|
|
|
|
|
'/zhao-icon.png': 'zhao-icon.png',
|
2026-10-03 16:12:25 +08:00
|
|
|
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
|
|
|
|
if path not in files:
|
|
|
|
|
raise Problem('页面不存在', 404)
|
|
|
|
|
file = ROOT / 'web' / files[path]
|
|
|
|
|
raw = file.read_bytes()
|
2026-10-03 20:05:05 +08:00
|
|
|
if path == '/':
|
|
|
|
|
raw = raw.replace(b'__ASSET_VERSION__', (ROOT / 'VERSION').read_bytes().strip())
|
2026-10-03 16:12:25 +08:00
|
|
|
self.send_response(200)
|
|
|
|
|
self.send_header('Content-Type', (mimetypes.guess_type(file.name)[0] or 'application/octet-stream') + '; charset=utf-8')
|
|
|
|
|
self.send_header('Content-Length', str(len(raw)))
|
|
|
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
|
|
|
self.send_header('X-Frame-Options', 'DENY')
|
|
|
|
|
self.send_header('Referrer-Policy', 'same-origin')
|
2026-10-04 08:33:00 +08:00
|
|
|
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
2026-10-03 20:05:05 +08:00
|
|
|
self.send_header('Cache-Control', 'no-store' if path == '/' else 'no-cache')
|
2026-10-03 16:12:25 +08:00
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(raw)
|
|
|
|
|
except Problem as e:
|
|
|
|
|
self.answer({'error': str(e)}, e.status)
|
|
|
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
|
|
|
pass
|
|
|
|
|
except Exception:
|
|
|
|
|
self.answer({'error': '请求未完成'}, 500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def initialize():
|
|
|
|
|
global MEDIA_AUTH
|
2026-10-03 17:41:52 +08:00
|
|
|
FAMILY.initialize()
|
|
|
|
|
ACCOUNTS.initialize()
|
2026-10-04 10:43:17 +08:00
|
|
|
HOUSEHOLDS.initialize()
|
2026-10-04 16:21:40 +08:00
|
|
|
HERITAGE.initialize()
|
2026-10-04 19:22:31 +08:00
|
|
|
JOURNAL.initialize()
|
2026-10-03 16:12:25 +08:00
|
|
|
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()
|
2026-10-03 17:41:52 +08:00
|
|
|
if not objects('users') and not (DATA / 'setup-code.txt').exists():
|
2026-10-03 16:12:25 +08:00
|
|
|
(DATA / 'setup-code.txt').write_text(secrets.token_hex(12), encoding='utf8')
|
|
|
|
|
os.chmod(DATA / 'setup-code.txt', 0o600)
|
|
|
|
|
write_media_config()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
initialize()
|
|
|
|
|
threading.Thread(target=storage_watch, daemon=True).start()
|
2026-10-03 16:26:28 +08:00
|
|
|
threading.Thread(target=media_config_watch, daemon=True).start()
|
2026-10-04 17:22:42 +08:00
|
|
|
threading.Thread(target=BACKUPS.watch, daemon=True).start()
|
2026-10-03 16:21:00 +08:00
|
|
|
bindings = [h.strip() for h in os.environ.get('VISION_BIND', '127.0.0.1').split(',') if h.strip()]
|
2026-10-03 16:12:25 +08:00
|
|
|
port = int(os.environ.get('VISION_PORT', '8790'))
|
2026-10-03 16:21:00 +08:00
|
|
|
servers = []
|
|
|
|
|
for bind in dict.fromkeys(bindings):
|
|
|
|
|
server = ThreadingHTTPServer((bind, port), Handler)
|
|
|
|
|
server.daemon_threads = True
|
|
|
|
|
servers.append(server)
|
|
|
|
|
print('ZhaoVision listening on ' + bind + ':' + str(port), flush=True)
|
|
|
|
|
for server in servers[1:]:
|
|
|
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
|
|
|
servers[0].serve_forever()
|