video_monitor/app/views/NvrView.py

365 lines
14 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""
NvrView 模块 24/7 录像 + FFmpeg 截图
"""
import os
import re
import time
import subprocess
from datetime import timedelta
from app.views.ViewsBase import *
from app.utils.Utils import buildPageLabels
from app.utils.LanguageUtils import LANG_VIEWS_T
from django.http import HttpResponse
from django.shortcuts import render
def record_index(request):
params = f_parseGetParams(request)
stream_id = int(params.get("stream_id", 0) or 0)
stream_name = ""
record_enable = 0
is_recording = False
if stream_id:
from app.models import StreamModel
stream = StreamModel.objects.filter(id=stream_id).first()
if stream:
stream_name = stream.nickname or stream.name or ("#" + str(stream_id))
record_enable = int(stream.record_enable or 0)
try:
from app.recording.manager import get_recording_manager
is_recording = get_recording_manager().is_recording(stream_id)
except Exception:
pass
else:
stream_id = 0
if not stream_id:
from django.shortcuts import redirect
return redirect("/stream/index")
return render(request, "app/record/index.html", {
"stream_id": stream_id,
"stream_name": stream_name,
"record_enable": record_enable,
"is_recording": is_recording,
})
def _sync_stream_recording(stream):
"""根据 record_enable 与转发状态同步录像进程(关闭录像时停止进程,保留历史文件)"""
try:
from app.recording.manager import get_recording_manager
mgr = get_recording_manager()
if int(stream.record_enable or 0) == 1 and int(stream.forward_state or 0) == 1:
mgr.start_stream(stream)
else:
mgr.stop_stream(stream.id)
except Exception as e:
g_logger.warning("sync recording stream=%s err: %s" % (stream.id, str(e)))
def _get_stream(params):
from app.models import StreamModel
sid = int(params.get("stream_id") or params.get("id") or 0)
app = (params.get("app") or "").strip()
name = (params.get("name") or "").strip()
if sid:
return StreamModel.objects.filter(id=sid).first()
if app and name:
return StreamModel.objects.filter(app=app, name=name).first()
return None
def api_openVideoIsRecording(request):
if request.method != 'GET':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parseGetParams(request)
stream = _get_stream(params)
if not stream:
return f_responseJson({"code": 0, "msg": "stream not found"})
from app.recording.manager import get_recording_manager
mgr = get_recording_manager()
return f_responseJson({
"code": 1000, "msg": "ok",
"is_recording": mgr.is_recording(stream.id),
"record_enable": stream.record_enable,
})
def api_openStartRecordVideo(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parsePostParams(request)
stream = _get_stream(params)
if not stream:
return f_responseJson({"code": 0, "msg": "stream not found"})
stream.record_enable = 1
stream.save(update_fields=["record_enable"])
2026-09-04 18:16:14 +08:00
# 同进程(嵌入式)立即同步启动;分进程则由领导者进程的同步循环异步接管
_sync_stream_recording(stream)
return f_responseJson({"code": 1000, "msg": "ok"})
2026-08-30 22:22:11 +08:00
def api_openStopRecordVideo(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parsePostParams(request)
stream = _get_stream(params)
if not stream:
return f_responseJson({"code": 0, "msg": "stream not found"})
stream.record_enable = 0
stream.save(update_fields=["record_enable"])
2026-09-04 18:16:14 +08:00
_sync_stream_recording(stream)
return f_responseJson({"code": 1000, "msg": "ok"})
2026-08-30 22:22:11 +08:00
def _scan_disk_recordings(stream_id=None):
"""从磁盘扫描录像分段FFmpeg segment 输出)"""
items = []
try:
base = g_config.storageRecordDir
except Exception:
return items
if not base or not os.path.isdir(base):
return items
for root, _dirs, files in os.walk(base):
for fn in files:
if not fn.lower().endswith((".mp4", ".ts", ".mkv")):
continue
fp = os.path.join(root, fn)
try:
st = os.stat(fp)
except Exception:
continue
sid = 0
parts = fn.split("_", 1)
if parts and parts[0].isdigit():
sid = int(parts[0])
if stream_id and sid != int(stream_id):
continue
from datetime import datetime
start = datetime.fromtimestamp(st.st_mtime)
items.append({
"id": 0,
"stream_id": sid,
"stream_name": "",
"file_path": fp,
"start_time": str(start),
"end_time": str(start),
"duration": 0,
"file_size": st.st_size,
"file_exists": True,
})
items.sort(key=lambda x: x["start_time"], reverse=True)
return items
def api_openRecordIndex(request):
"""分页列出录像分段"""
if request.method != 'GET':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
from app.models import RecordingModel, StreamModel
params = f_parseGetParams(request)
page = int(params.get('p', 1) or 1)
ps = int(params.get('ps', 20) or 20)
sid = params.get('stream_id')
stream_filter = int(sid) if sid else None
qs = RecordingModel.objects.all().order_by('-start_time')
if stream_filter:
qs = qs.filter(stream_id=stream_filter)
db_rows = list(qs.select_related('stream')[:500])
data = []
seen_paths = set()
for r in db_rows:
seen_paths.add(r.file_path)
data.append({
"id": r.id,
"stream_id": r.stream_id,
"stream_name": (r.stream.nickname if r.stream else ""),
"file_path": r.file_path,
"start_time": str(r.start_time),
"end_time": str(r.end_time),
"duration": r.duration,
"file_size": r.file_size,
"file_exists": os.path.isfile(r.file_path) if r.file_path else False,
})
for item in _scan_disk_recordings(stream_filter):
if item["file_path"] in seen_paths:
continue
if item["stream_id"]:
try:
s = StreamModel.objects.filter(id=item["stream_id"]).first()
if s:
item["stream_name"] = s.nickname or s.name
except Exception:
pass
data.append(item)
data.sort(key=lambda x: x.get("start_time", ""), reverse=True)
count = len(data)
skip = (page - 1) * ps
page_rows = data[skip:skip + ps]
page_num = int(count / ps) + (1 if count % ps else 0)
page_data = {
"page": page, "page_size": ps, "page_num": max(1, page_num), "count": count,
"pageLabels": buildPageLabels(page=page, page_num=max(1, page_num), lang=f_parseRequestLang(request)),
}
return f_responseJson({"code": 1000, "msg": "ok", "data": page_rows, "pageData": page_data})
def api_openRecordFile(request):
if request.method != 'GET':
return HttpResponse(b"method not allowed", status=405)
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return HttpResponse(__check_msg.encode("utf-8"), status=403)
params = f_parseGetParams(request)
rid = int(params.get("id", 0) or 0)
fpath = (params.get("path") or "").strip()
fp = None
if rid:
from app.models import RecordingModel
try:
rec = RecordingModel.objects.get(id=rid)
fp = rec.file_path
except Exception:
return HttpResponse(b"not found", status=404)
elif fpath and os.path.isfile(fpath):
try:
base = g_config.storageRecordDir
if base and os.path.commonpath([os.path.abspath(fpath), os.path.abspath(base)]) == os.path.abspath(base):
fp = fpath
except Exception:
pass
if not fp or not os.path.isfile(fp):
return HttpResponse(b"file missing", status=404)
with open(fp, "rb") as f:
data = f.read()
resp = HttpResponse(data, content_type="video/mp4")
resp["Content-Disposition"] = 'inline; filename="%s"' % os.path.basename(fp)
return resp
def api_openRecordDel(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
params = f_parsePostParams(request)
rid = int(params.get("id", 0) or 0)
fpath = (params.get("path") or "").strip()
if not rid and not fpath:
return f_responseJson({"code": 0, "msg": "missing id or path"})
from app.models import RecordingModel
try:
if rid:
rec = RecordingModel.objects.get(id=rid)
fp = rec.file_path
rec.delete()
else:
fp = fpath
base = g_config.storageRecordDir
if not base or not os.path.isfile(fp):
return f_responseJson({"code": 0, "msg": "file missing"})
if os.path.commonpath([os.path.abspath(fp), os.path.abspath(base)]) != os.path.abspath(base):
return f_responseJson({"code": 0, "msg": "invalid path"})
RecordingModel.objects.filter(file_path=fp).delete()
if fp and os.path.isfile(fp):
os.remove(fp)
return f_responseJson({"code": 1000, "msg": LANG_VIEWS_T(request, "msg_success")})
except Exception as e:
return f_responseJson({"code": 0, "msg": str(e)})
def api_openSnapShot(request):
if request.method != 'POST':
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "msg_method_not_supported")})
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return f_responseJson({"code": 0, "msg": __check_msg})
return f_responseJson({"code": 0, "msg": LANG_VIEWS_T(request, "nvr_snapshot_removed")})
def _snap_http_response(data, status=200):
resp = HttpResponse(data, content_type="image/jpeg", status=status)
resp["Cache-Control"] = "no-store"
return resp
def _capture_snap_file(rtsp_url, snap_path, ffmpeg_cmd):
if os.path.exists(snap_path):
try:
os.remove(snap_path)
except Exception:
pass
command = [
ffmpeg_cmd, "-nostdin", "-loglevel", "quiet", "-rtsp_transport", "tcp",
"-stimeout", "5000000", "-i", rtsp_url, "-frames:v", "1", "-y", snap_path,
]
subprocess.run(command, shell=False, timeout=12, capture_output=True, check=False)
if os.path.exists(snap_path):
with open(snap_path, "rb") as f:
data = f.read()
if len(data) > 100:
return data
return None
def api_openSnap(request):
try:
if request.method != "GET":
return HttpResponse(b"method not allowed", status=405, content_type="text/plain")
__check_ret, __check_msg = f_checkRequestSafe(request)
if not __check_ret:
return HttpResponse(b"forbidden", status=403, content_type="text/plain")
params = f_parseGetParams(request)
app = params.get("app", "").strip()
name = params.get("name", "").strip()
force = str(params.get("force", "0")).strip() == "1"
if not app or not name:
return HttpResponse(b"missing app or name", status=400, content_type="text/plain")
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,50}", app) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,50}", name):
return HttpResponse(b"invalid app or name", status=400, content_type="text/plain")
from app.models import StreamModel
stream = StreamModel.objects.filter(app=app, name=name).only("app", "name").first()
if not stream:
return HttpResponse(b"stream not found", status=404, content_type="text/plain")
app, name = stream.app, stream.name
snap_dir = os.path.join(g_config.storageDir, "snapshots")
snap_path = os.path.join(snap_dir, "%s_%s.jpg" % (app, name))
if not force and os.path.exists(snap_path):
mtime = os.path.getmtime(snap_path)
if (time.time() - mtime) < 30:
with open(snap_path, "rb") as f:
data = f.read()
if len(data) > 100:
return _snap_http_response(data)
rtsp_url = g_zlm.get_rtspUrl(app=app, name=name, request_ip="127.0.0.1")
if not rtsp_url:
return HttpResponse(b"no stream url available", status=404, content_type="text/plain")
os.makedirs(snap_dir, exist_ok=True)
ffmpeg_cmd = g_config.ffmpeg
data = _capture_snap_file(rtsp_url, snap_path, ffmpeg_cmd)
if not data:
# RTSP 首帧可能较慢,短暂等待后重试一次
time.sleep(0.6)
data = _capture_snap_file(rtsp_url, snap_path, ffmpeg_cmd)
if data:
return _snap_http_response(data)
return HttpResponse(b"snap failed", status=404, content_type="text/plain")
except subprocess.TimeoutExpired:
return HttpResponse(b"snap timeout", status=404, content_type="text/plain")
except Exception as e:
return HttpResponse(("snap exception: " + str(e)).encode("utf-8"), status=500, content_type="text/plain")