Compare commits
3 Commits
1aecfba186
...
aa4f167840
| Author | SHA1 | Date | |
|---|---|---|---|
| aa4f167840 | |||
| 68d6849120 | |||
| bfbe69eeb5 |
@@ -150,6 +150,63 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 视频控制栏 */
|
||||||
|
.video-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-controls label {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-controls select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频信息栏 */
|
||||||
|
.video-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info .info-row {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info .info-label {
|
||||||
|
color: #888;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info .info-value {
|
||||||
|
color: #0f0;
|
||||||
|
}
|
||||||
|
|
||||||
#video-container {
|
#video-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -196,9 +253,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-area">
|
<div class="video-area">
|
||||||
|
<!-- 视频控制栏 -->
|
||||||
|
<div class="video-controls" id="video-controls" style="display: none;">
|
||||||
|
<label>码流类型:</label>
|
||||||
|
<select id="stream-type">
|
||||||
|
<option value="0">主码流(高清)</option>
|
||||||
|
<option value="1">子码流(流畅)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div id="video-container">
|
<div id="video-container">
|
||||||
<div class="placeholder">👈 从左侧选择一个直播源</div>
|
<div class="placeholder">👈 从左侧选择一个直播源</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 视频信息栏 -->
|
||||||
|
<div class="video-info" id="video-info" style="display: none;">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">摄像头ID:</span>
|
||||||
|
<span class="info-value" id="info-camera-id">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">播放地址:</span>
|
||||||
|
<span class="info-value" id="info-url">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,6 +285,7 @@
|
|||||||
// 全局状态
|
// 全局状态
|
||||||
let currentVideoNode = null; // 当前播放的节点对象
|
let currentVideoNode = null; // 当前播放的节点对象
|
||||||
let hls = null; // HLS 实例
|
let hls = null; // HLS 实例
|
||||||
|
let currentStreamType = 0; // 当前码流类型
|
||||||
|
|
||||||
// 缓存已加载的子节点数据: { parentId: [childrenNodes] }
|
// 缓存已加载的子节点数据: { parentId: [childrenNodes] }
|
||||||
const childrenCache = new Map();
|
const childrenCache = new Map();
|
||||||
@@ -243,13 +320,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取节点详情(这里主要用来获取视频流地址,但也可以直接用子节点数据,不过为符合 API 设计,单独调用 stream 接口)
|
// 获取视频流地址
|
||||||
async function fetchStreamUrl(nodeId) {
|
async function fetchStreamUrl(nodeId, streamType = 0) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/stream/${nodeId}`);
|
const res = await fetch(`${API_BASE}/stream/${nodeId}?stream_type=${streamType}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
return await res.json();
|
||||||
return data.url;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
|
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
|
||||||
return null;
|
return null;
|
||||||
@@ -257,20 +333,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 播放视频
|
// 播放视频
|
||||||
async function playVideo(node) {
|
async function playVideo(node, streamType = 0) {
|
||||||
if (!node || !node.is_leaf) return;
|
if (!node || !node.is_leaf) return;
|
||||||
|
|
||||||
// 显示加载占位
|
// 显示加载占位
|
||||||
const container = document.getElementById('video-container');
|
const container = document.getElementById('video-container');
|
||||||
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
|
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
|
||||||
|
|
||||||
|
// 隐藏信息和控制栏
|
||||||
|
document.getElementById('video-controls').style.display = 'none';
|
||||||
|
document.getElementById('video-info').style.display = 'none';
|
||||||
|
|
||||||
// 获取流地址
|
// 获取流地址
|
||||||
const streamUrl = await fetchStreamUrl(node.id);
|
const streamData = await fetchStreamUrl(node.id, streamType);
|
||||||
if (!streamUrl) {
|
if (!streamData || !streamData.url) {
|
||||||
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
|
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamUrl = streamData.url;
|
||||||
|
|
||||||
// 清理旧播放器
|
// 清理旧播放器
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
@@ -313,6 +395,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新并显示视频信息
|
||||||
|
document.getElementById('info-camera-id').textContent = streamData.cameraIndexCode;
|
||||||
|
document.getElementById('info-url').textContent = streamUrl;
|
||||||
|
document.getElementById('video-info').style.display = 'block';
|
||||||
|
|
||||||
|
// 显示控制栏
|
||||||
|
document.getElementById('video-controls').style.display = 'flex';
|
||||||
|
|
||||||
currentVideoNode = node;
|
currentVideoNode = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +478,7 @@
|
|||||||
// 高亮当前选中的节点
|
// 高亮当前选中的节点
|
||||||
clearActiveHighlight();
|
clearActiveHighlight();
|
||||||
contentDiv.classList.add('active');
|
contentDiv.classList.add('active');
|
||||||
await playVideo(node);
|
await playVideo(node, currentStreamType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +532,14 @@
|
|||||||
|
|
||||||
// 初始化视频区域(可选:尝试自动播放需用户交互)
|
// 初始化视频区域(可选:尝试自动播放需用户交互)
|
||||||
function initVideoArea() {
|
function initVideoArea() {
|
||||||
// 预留
|
// 监听码流类型切换
|
||||||
|
document.getElementById('stream-type').addEventListener('change', async (e) => {
|
||||||
|
currentStreamType = parseInt(e.target.value);
|
||||||
|
// 如果当前有播放的视频,重新加载
|
||||||
|
if (currentVideoNode) {
|
||||||
|
await playVideo(currentVideoNode, currentStreamType);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动
|
// 启动
|
||||||
|
|||||||
@@ -3,49 +3,89 @@ import urllib.parse
|
|||||||
import socket
|
import socket
|
||||||
import json
|
import json
|
||||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from utils.hikvision_cam_utils import get_organization_list, get_final_list, get_camera_preview_url
|
||||||
|
|
||||||
# ========== 硬编码的树形数据 ==========
|
# ========== 海康威视 API 配置 ==========
|
||||||
# 节点结构:
|
ROOT_PARENT_INDEX_CODE = "4fa15af07b6b400f94af1e35d8235c30"
|
||||||
# {
|
|
||||||
# "id": 1,
|
|
||||||
# "name": "河北省",
|
|
||||||
# "parent_id": None, # None 表示根节点
|
|
||||||
# "is_leaf": False,
|
|
||||||
# "stream_url": None # 叶子节点才会有值
|
|
||||||
# }
|
|
||||||
|
|
||||||
nodes = {
|
def transform_org_node(item, parent_id=None):
|
||||||
1: {"id": 1, "name": "河北省", "parent_id": None, "is_leaf": False, "stream_url": None},
|
"""将海康威视组织节点转换为前端期望的格式"""
|
||||||
2: {"id": 2, "name": "河南省", "parent_id": None, "is_leaf": False, "stream_url": None},
|
return {
|
||||||
3: {"id": 3, "name": "石家庄市", "parent_id": 1, "is_leaf": False, "stream_url": None},
|
"id": item["indexCode"],
|
||||||
4: {"id": 4, "name": "保定市", "parent_id": 1, "is_leaf": False, "stream_url": None},
|
"name": item["name"],
|
||||||
5: {"id": 5, "name": "郑州市", "parent_id": 2, "is_leaf": False, "stream_url": None},
|
"parent_id": parent_id or item.get("parentIndexCode"),
|
||||||
6: {"id": 6, "name": "长安区", "parent_id": 3, "is_leaf": True,
|
"is_leaf": False, # 组织机构节点不是叶子节点
|
||||||
"stream_url": "http://localhost:8355/stream.m3u8"},
|
"stream_url": None
|
||||||
7: {"id": 7, "name": "桥西区", "parent_id": 3, "is_leaf": True,
|
}
|
||||||
"stream_url": "https://example.com/live/qiaoxi.m3u8"},
|
|
||||||
8: {"id": 8, "name": "竞秀区", "parent_id": 4, "is_leaf": True,
|
def transform_camera_node(item, parent_id=None):
|
||||||
"stream_url": "https://example.com/live/jingxiu.m3u8"},
|
"""将海康威视摄像头节点转换为前端期望的格式(叶子节点)"""
|
||||||
9: {"id": 9, "name": "莲池区", "parent_id": 4, "is_leaf": True,
|
return {
|
||||||
"stream_url": "https://example.com/live/lianchi.m3u8"},
|
"id": item["cameraIndexCode"],
|
||||||
10: {"id": 10, "name": "中原区", "parent_id": 5, "is_leaf": True,
|
"name": item["name"],
|
||||||
"stream_url": "https://example.com/live/zhongyuan.m3u8"},
|
"parent_id": parent_id,
|
||||||
}
|
"is_leaf": True, # 摄像头是叶子节点
|
||||||
|
"stream_url": None
|
||||||
|
}
|
||||||
|
|
||||||
def get_children(parent_id):
|
def get_children(parent_id):
|
||||||
"""返回父节点下的直接子节点列表"""
|
"""返回父节点下的直接子节点列表(从海康威视 API 获取)
|
||||||
return [node for node in nodes.values() if node["parent_id"] == parent_id]
|
|
||||||
|
逻辑:
|
||||||
|
1. 先调用 get_organization_list 获取子组织
|
||||||
|
2. 如果返回 list 为空,则调用 get_final_list 获取摄像头(叶子节点)
|
||||||
|
"""
|
||||||
|
if parent_id is None:
|
||||||
|
parent_id = ROOT_PARENT_INDEX_CODE
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 先尝试获取子组织
|
||||||
|
result = get_organization_list(parent_id)
|
||||||
|
if result.get("code") != "0":
|
||||||
|
print(f"海康威视 API 返回错误: {result.get('msg')}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
items = result.get("data", {}).get("list", [])
|
||||||
|
|
||||||
|
# 如果有子组织,返回组织节点
|
||||||
|
if items:
|
||||||
|
return [transform_org_node(item, parent_id) for item in items]
|
||||||
|
|
||||||
|
# 如果没有子组织,尝试获取摄像头列表(叶子节点)
|
||||||
|
print(f"组织 {parent_id} 无下级组织,尝试获取摄像头列表...")
|
||||||
|
final_result = get_final_list(parent_id)
|
||||||
|
if final_result.get("code") != "0":
|
||||||
|
print(f"获取摄像头列表失败: {final_result.get('msg')}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
camera_items = final_result.get("data", {}).get("list", [])
|
||||||
|
print(f"获取到 {len(camera_items)} 个摄像头")
|
||||||
|
return [transform_camera_node(item, parent_id) for item in camera_items]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"调用海康威视 API 失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def get_node(node_id):
|
def get_stream_url(node_id, stream_type=0):
|
||||||
"""根据 id 获取节点详情"""
|
"""获取摄像头的视频流地址
|
||||||
return nodes.get(node_id)
|
|
||||||
|
Args:
|
||||||
def get_stream_url(node_id):
|
node_id: 摄像头的 cameraIndexCode
|
||||||
"""获取叶子节点的视频流地址"""
|
stream_type: 码流类型,0=主码流,1=子码流
|
||||||
node = nodes.get(node_id)
|
"""
|
||||||
if node and node["is_leaf"]:
|
try:
|
||||||
return node["stream_url"]
|
result = get_camera_preview_url(node_id, stream_type)
|
||||||
return None
|
if result.get("code") != "0":
|
||||||
|
print(f"获取视频流地址失败: {result.get('msg')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = result.get("data", {}).get("url")
|
||||||
|
return url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"调用 get_camera_preview_url 失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
# ========== HTTP 处理器 ==========
|
# ========== HTTP 处理器 ==========
|
||||||
class APIHandler(SimpleHTTPRequestHandler):
|
class APIHandler(SimpleHTTPRequestHandler):
|
||||||
@@ -99,42 +139,36 @@ class APIHandler(SimpleHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif path.startswith('/api/children/'):
|
elif path.startswith('/api/children/'):
|
||||||
# GET /api/children/3
|
# GET /api/children/21020000
|
||||||
try:
|
node_id = path.split('/')[-1]
|
||||||
node_id = int(path.split('/')[-1])
|
if not node_id:
|
||||||
except (ValueError, IndexError):
|
|
||||||
self.send_error_json("Invalid node id", 400)
|
self.send_error_json("Invalid node id", 400)
|
||||||
return
|
return
|
||||||
children = get_children(node_id)
|
children = get_children(node_id)
|
||||||
self.send_json_response(children)
|
self.send_json_response(children)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif path.startswith('/api/node/'):
|
|
||||||
# GET /api/node/3
|
|
||||||
try:
|
|
||||||
node_id = int(path.split('/')[-1])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
self.send_error_json("Invalid node id", 400)
|
|
||||||
return
|
|
||||||
node = get_node(node_id)
|
|
||||||
if node is None:
|
|
||||||
self.send_error_json("Node not found", 404)
|
|
||||||
return
|
|
||||||
self.send_json_response(node)
|
|
||||||
return
|
|
||||||
|
|
||||||
elif path.startswith('/api/stream/'):
|
elif path.startswith('/api/stream/'):
|
||||||
# GET /api/stream/6
|
# GET /api/stream/21020000?stream_type=0
|
||||||
try:
|
node_id = path.split('/')[-1]
|
||||||
node_id = int(path.split('/')[-1])
|
if not node_id:
|
||||||
except (ValueError, IndexError):
|
|
||||||
self.send_error_json("Invalid node id", 400)
|
self.send_error_json("Invalid node id", 400)
|
||||||
return
|
return
|
||||||
url = get_stream_url(node_id)
|
|
||||||
|
# 解析 stream_type 参数
|
||||||
|
params = urllib.parse.parse_qs(query)
|
||||||
|
stream_type = int(params.get('stream_type', ['0'])[0])
|
||||||
|
|
||||||
|
url = get_stream_url(node_id, stream_type)
|
||||||
if url is None:
|
if url is None:
|
||||||
self.send_error_json("Stream not found or node is not a leaf", 404)
|
self.send_error_json("Stream not found or node is not a leaf", 404)
|
||||||
return
|
return
|
||||||
self.send_json_response({"url": url})
|
# 返回完整信息
|
||||||
|
self.send_json_response({
|
||||||
|
"cameraIndexCode": node_id,
|
||||||
|
"url": url,
|
||||||
|
"stream_type": stream_type
|
||||||
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# 静态文件服务(与原逻辑一致)
|
# 静态文件服务(与原逻辑一致)
|
||||||
@@ -221,8 +255,7 @@ def run():
|
|||||||
print(f'Server running on http://localhost:{port}')
|
print(f'Server running on http://localhost:{port}')
|
||||||
print('API endpoints:')
|
print('API endpoints:')
|
||||||
print(' GET /api/roots - 获取所有根节点')
|
print(' GET /api/roots - 获取所有根节点')
|
||||||
print(' GET /api/children/<id> - 获取指定节点的子节点')
|
print(' GET /api/children/<id> - 获取指定节点的子节点(自动判断组织/摄像头)')
|
||||||
print(' GET /api/node/<id> - 获取节点详情')
|
|
||||||
print(' GET /api/stream/<id> - 获取视频流地址')
|
print(' GET /api/stream/<id> - 获取视频流地址')
|
||||||
print('静态文件服务: 访问 / 或 /index.html')
|
print('静态文件服务: 访问 / 或 /index.html')
|
||||||
print('按 Ctrl+C 停止服务器')
|
print('按 Ctrl+C 停止服务器')
|
||||||
|
|||||||
Reference in New Issue
Block a user