新增获取视频地址页面,雏形未修改
This commit is contained in:
455
live_catalog/index.html
Normal file
455
live_catalog/index.html
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>Live Catalog - 树形直播目录</title>
|
||||||
|
<!-- HLS.js 用于播放 .m3u8 流 -->
|
||||||
|
<script src="/static/js/hls.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主布局 */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧树形菜单 */
|
||||||
|
.sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e8e8e8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1f2f3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 树形结构样式 */
|
||||||
|
.tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px 8px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2c3e50;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content:hover {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content.active {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开/折叠图标 */
|
||||||
|
.expand-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中指示器 */
|
||||||
|
.loading-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-top-color: #1890ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子节点容器 */
|
||||||
|
.children {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 28px;
|
||||||
|
margin: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧视频区域 */
|
||||||
|
.video-area {
|
||||||
|
flex: 1;
|
||||||
|
background: #141414;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式小屏 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
📺 直播目录
|
||||||
|
</div>
|
||||||
|
<div class="tree-container">
|
||||||
|
<ul class="tree" id="tree-root">
|
||||||
|
<!-- 根节点会动态渲染到这里 -->
|
||||||
|
<li class="tree-node loading-placeholder" style="padding: 12px; text-align: center; color: #999;">加载中...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-area">
|
||||||
|
<div id="video-container">
|
||||||
|
<div class="placeholder">👈 从左侧选择一个直播源</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// API 基础路径(与后端同源)
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
// 全局状态
|
||||||
|
let currentVideoNode = null; // 当前播放的节点对象
|
||||||
|
let hls = null; // HLS 实例
|
||||||
|
|
||||||
|
// 缓存已加载的子节点数据: { parentId: [childrenNodes] }
|
||||||
|
const childrenCache = new Map();
|
||||||
|
|
||||||
|
// 获取根节点
|
||||||
|
async function fetchRoots() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/roots`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取根节点失败:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定节点的子节点
|
||||||
|
async function fetchChildren(parentId) {
|
||||||
|
// 检查缓存
|
||||||
|
if (childrenCache.has(parentId)) {
|
||||||
|
return childrenCache.get(parentId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/children/${parentId}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const children = await res.json();
|
||||||
|
childrenCache.set(parentId, children);
|
||||||
|
return children;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`获取节点 ${parentId} 的子节点失败:`, err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点详情(这里主要用来获取视频流地址,但也可以直接用子节点数据,不过为符合 API 设计,单独调用 stream 接口)
|
||||||
|
async function fetchStreamUrl(nodeId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/stream/${nodeId}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.url;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`获取节点 ${nodeId} 的视频地址失败:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放视频
|
||||||
|
async function playVideo(node) {
|
||||||
|
if (!node || !node.is_leaf) return;
|
||||||
|
|
||||||
|
// 显示加载占位
|
||||||
|
const container = document.getElementById('video-container');
|
||||||
|
container.innerHTML = '<div class="placeholder">📡 正在加载直播流...</div>';
|
||||||
|
|
||||||
|
// 获取流地址
|
||||||
|
const streamUrl = await fetchStreamUrl(node.id);
|
||||||
|
if (!streamUrl) {
|
||||||
|
container.innerHTML = '<div class="placeholder">❌ 无法获取视频地址,请稍后重试</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧播放器
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy();
|
||||||
|
hls = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 video 元素
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.controls = true;
|
||||||
|
video.autoplay = true;
|
||||||
|
video.style.width = '100%';
|
||||||
|
video.style.height = '100%';
|
||||||
|
video.style.objectFit = 'contain';
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(video);
|
||||||
|
|
||||||
|
// 判断是否 HLS 流
|
||||||
|
if (Hls.isSupported() && streamUrl.includes('.m3u8')) {
|
||||||
|
hls = new Hls();
|
||||||
|
hls.loadSource(streamUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
video.play().catch(e => console.warn('自动播放被阻止:', e));
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
console.error('HLS 错误:', data);
|
||||||
|
container.innerHTML = '<div class="placeholder">⚠️ 直播流播放失败,请检查地址或网络</div>';
|
||||||
|
});
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl') && streamUrl.includes('.m3u8')) {
|
||||||
|
// 原生支持 HLS(Safari)
|
||||||
|
video.src = streamUrl;
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
video.play().catch(e => console.warn('自动播放被阻止:', e));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 非 HLS 直接播放(如 mp4)
|
||||||
|
video.src = streamUrl;
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
container.innerHTML = '<div class="placeholder">❌ 视频无法播放,格式可能不支持</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVideoNode = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染树节点(递归方式,但为了动态添加子节点,我们只渲染当前层的 ul)
|
||||||
|
// 核心思路:为每个节点生成一个 <li>,内部包含 .tree-node-content 和一个 .children 容器
|
||||||
|
// 点击节点时,如果已经有 .children 内容则切换显示;否则加载子节点并填充
|
||||||
|
async function renderTree() {
|
||||||
|
const rootContainer = document.getElementById('tree-root');
|
||||||
|
const roots = await fetchRoots();
|
||||||
|
if (!roots.length) {
|
||||||
|
rootContainer.innerHTML = '<li class="tree-node" style="padding: 12px; text-align: center; color: #999;">暂无数据</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空并重新构建根节点
|
||||||
|
rootContainer.innerHTML = '';
|
||||||
|
for (const node of roots) {
|
||||||
|
const li = createTreeNodeElement(node);
|
||||||
|
rootContainer.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单个树节点的 DOM 元素(不含子节点,子节点容器为空)
|
||||||
|
function createTreeNodeElement(node) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'tree-node';
|
||||||
|
li.dataset.id = node.id;
|
||||||
|
li.dataset.isLeaf = node.is_leaf;
|
||||||
|
|
||||||
|
// 内容包装器
|
||||||
|
const contentDiv = document.createElement('div');
|
||||||
|
contentDiv.className = 'tree-node-content';
|
||||||
|
if (!node.is_leaf) {
|
||||||
|
// 非叶子节点:带展开图标
|
||||||
|
const iconSpan = document.createElement('span');
|
||||||
|
iconSpan.className = 'expand-icon';
|
||||||
|
iconSpan.innerHTML = '▶'; // 初始向右,展开时旋转
|
||||||
|
iconSpan.style.display = 'inline-block';
|
||||||
|
contentDiv.appendChild(iconSpan);
|
||||||
|
} else {
|
||||||
|
// 叶子节点占位空白
|
||||||
|
const placeholder = document.createElement('span');
|
||||||
|
placeholder.style.width = '28px';
|
||||||
|
placeholder.style.display = 'inline-block';
|
||||||
|
contentDiv.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'node-name';
|
||||||
|
nameSpan.textContent = node.name;
|
||||||
|
contentDiv.appendChild(nameSpan);
|
||||||
|
|
||||||
|
li.appendChild(contentDiv);
|
||||||
|
|
||||||
|
// 子节点容器
|
||||||
|
const childrenUl = document.createElement('ul');
|
||||||
|
childrenUl.className = 'children';
|
||||||
|
li.appendChild(childrenUl);
|
||||||
|
|
||||||
|
// 绑定点击事件(在父容器上委托)
|
||||||
|
contentDiv.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNodeClick(node, li, childrenUl, contentDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理节点点击
|
||||||
|
async function onNodeClick(node, li, childrenUl, contentDiv) {
|
||||||
|
// 如果是叶子节点:播放视频
|
||||||
|
if (node.is_leaf) {
|
||||||
|
// 高亮当前选中的节点
|
||||||
|
clearActiveHighlight();
|
||||||
|
contentDiv.classList.add('active');
|
||||||
|
await playVideo(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非叶子节点:展开/折叠逻辑
|
||||||
|
const isExpanded = childrenUl.classList.contains('show');
|
||||||
|
if (isExpanded) {
|
||||||
|
// 折叠
|
||||||
|
childrenUl.classList.remove('show');
|
||||||
|
const icon = contentDiv.querySelector('.expand-icon');
|
||||||
|
if (icon) icon.classList.remove('expanded');
|
||||||
|
} else {
|
||||||
|
// 展开:检查是否已加载子节点
|
||||||
|
if (childrenUl.children.length === 0) {
|
||||||
|
// 显示加载动画
|
||||||
|
const loadingSpan = document.createElement('span');
|
||||||
|
loadingSpan.className = 'loading-icon';
|
||||||
|
loadingSpan.style.marginLeft = '8px';
|
||||||
|
contentDiv.appendChild(loadingSpan);
|
||||||
|
|
||||||
|
const children = await fetchChildren(node.id);
|
||||||
|
contentDiv.removeChild(loadingSpan);
|
||||||
|
|
||||||
|
if (children && children.length) {
|
||||||
|
for (const child of children) {
|
||||||
|
const childLi = createTreeNodeElement(child);
|
||||||
|
childrenUl.appendChild(childLi);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无子节点:可显示“暂无子节点”提示
|
||||||
|
const emptyMsg = document.createElement('li');
|
||||||
|
emptyMsg.textContent = '(暂无下级)';
|
||||||
|
emptyMsg.style.padding = '6px 12px';
|
||||||
|
emptyMsg.style.color = '#999';
|
||||||
|
emptyMsg.style.fontSize = '12px';
|
||||||
|
childrenUl.appendChild(emptyMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 展开并旋转图标
|
||||||
|
childrenUl.classList.add('show');
|
||||||
|
const icon = contentDiv.querySelector('.expand-icon');
|
||||||
|
if (icon) icon.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有高亮
|
||||||
|
function clearActiveHighlight() {
|
||||||
|
document.querySelectorAll('.tree-node-content.active').forEach(el => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化视频区域(可选:尝试自动播放需用户交互)
|
||||||
|
function initVideoArea() {
|
||||||
|
// 预留
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动
|
||||||
|
(async function init() {
|
||||||
|
await renderTree();
|
||||||
|
initVideoArea();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
237
live_catalog/live_catalog_backend.py
Normal file
237
live_catalog/live_catalog_backend.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
import socket
|
||||||
|
import json
|
||||||
|
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
# ========== 硬编码的树形数据 ==========
|
||||||
|
# 节点结构:
|
||||||
|
# {
|
||||||
|
# "id": 1,
|
||||||
|
# "name": "河北省",
|
||||||
|
# "parent_id": None, # None 表示根节点
|
||||||
|
# "is_leaf": False,
|
||||||
|
# "stream_url": None # 叶子节点才会有值
|
||||||
|
# }
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
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},
|
||||||
|
3: {"id": 3, "name": "石家庄市", "parent_id": 1, "is_leaf": False, "stream_url": None},
|
||||||
|
4: {"id": 4, "name": "保定市", "parent_id": 1, "is_leaf": False, "stream_url": None},
|
||||||
|
5: {"id": 5, "name": "郑州市", "parent_id": 2, "is_leaf": False, "stream_url": None},
|
||||||
|
6: {"id": 6, "name": "长安区", "parent_id": 3, "is_leaf": True,
|
||||||
|
"stream_url": "http://localhost:8355/stream.m3u8"},
|
||||||
|
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,
|
||||||
|
"stream_url": "https://example.com/live/jingxiu.m3u8"},
|
||||||
|
9: {"id": 9, "name": "莲池区", "parent_id": 4, "is_leaf": True,
|
||||||
|
"stream_url": "https://example.com/live/lianchi.m3u8"},
|
||||||
|
10: {"id": 10, "name": "中原区", "parent_id": 5, "is_leaf": True,
|
||||||
|
"stream_url": "https://example.com/live/zhongyuan.m3u8"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_children(parent_id):
|
||||||
|
"""返回父节点下的直接子节点列表"""
|
||||||
|
return [node for node in nodes.values() if node["parent_id"] == parent_id]
|
||||||
|
|
||||||
|
def get_node(node_id):
|
||||||
|
"""根据 id 获取节点详情"""
|
||||||
|
return nodes.get(node_id)
|
||||||
|
|
||||||
|
def get_stream_url(node_id):
|
||||||
|
"""获取叶子节点的视频流地址"""
|
||||||
|
node = nodes.get(node_id)
|
||||||
|
if node and node["is_leaf"]:
|
||||||
|
return node["stream_url"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ========== HTTP 处理器 ==========
|
||||||
|
class APIHandler(SimpleHTTPRequestHandler):
|
||||||
|
# 设置超时
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
# MIME 类型映射
|
||||||
|
MIME_TYPES = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
print(f"[{self.log_date_time_string()}] {self.address_string()} - {format % args}")
|
||||||
|
|
||||||
|
def send_json_response(self, data, status=200):
|
||||||
|
"""统一返回 JSON 格式"""
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-type', 'application/json')
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
|
||||||
|
|
||||||
|
def send_error_json(self, message, status=400):
|
||||||
|
"""返回错误 JSON"""
|
||||||
|
self.send_json_response({"error": message}, status)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
try:
|
||||||
|
parsed_path = urllib.parse.urlparse(self.path)
|
||||||
|
path = parsed_path.path
|
||||||
|
query = parsed_path.query
|
||||||
|
|
||||||
|
# 新 API 路由 (RESTful)
|
||||||
|
if path.startswith('/api/roots'):
|
||||||
|
# GET /api/roots
|
||||||
|
roots = get_children(None)
|
||||||
|
self.send_json_response(roots)
|
||||||
|
return
|
||||||
|
|
||||||
|
elif path.startswith('/api/children/'):
|
||||||
|
# GET /api/children/3
|
||||||
|
try:
|
||||||
|
node_id = int(path.split('/')[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
self.send_error_json("Invalid node id", 400)
|
||||||
|
return
|
||||||
|
children = get_children(node_id)
|
||||||
|
self.send_json_response(children)
|
||||||
|
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/'):
|
||||||
|
# GET /api/stream/6
|
||||||
|
try:
|
||||||
|
node_id = int(path.split('/')[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
self.send_error_json("Invalid node id", 400)
|
||||||
|
return
|
||||||
|
url = get_stream_url(node_id)
|
||||||
|
if url is None:
|
||||||
|
self.send_error_json("Stream not found or node is not a leaf", 404)
|
||||||
|
return
|
||||||
|
self.send_json_response({"url": url})
|
||||||
|
return
|
||||||
|
|
||||||
|
# 静态文件服务(与原逻辑一致)
|
||||||
|
if path == '/' or path == '/index.html':
|
||||||
|
self.serve_file('index.html', query='api=1')
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
filename = path.lstrip('/')
|
||||||
|
if os.path.exists(filename):
|
||||||
|
self.serve_static_file(filename)
|
||||||
|
else:
|
||||||
|
self.send_error(404, 'Not Found')
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error handling request: {e}")
|
||||||
|
self.send_error(500, 'Internal Server Error')
|
||||||
|
|
||||||
|
def serve_file(self, filename, query=None):
|
||||||
|
"""与原代码相同的文件服务(保留原逻辑)"""
|
||||||
|
try:
|
||||||
|
file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if query:
|
||||||
|
try:
|
||||||
|
content = content.decode('utf-8')
|
||||||
|
# 替换原有 js 中的 apiParam 赋值语句(兼容旧前端)
|
||||||
|
content = content.replace(
|
||||||
|
"const apiParam = urlParams.get('api') || '1';",
|
||||||
|
f"const apiParam = '{query.split('=')[1]}';"
|
||||||
|
)
|
||||||
|
content = content.encode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error modifying HTML content: {e}")
|
||||||
|
|
||||||
|
self.wfile.write(content)
|
||||||
|
else:
|
||||||
|
self.send_error(404, f'{filename} not found')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serving file: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def serve_static_file(self, filename):
|
||||||
|
"""与原代码相同的静态文件服务"""
|
||||||
|
try:
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
content_type = self.MIME_TYPES.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', content_type)
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
with open(filename, 'rb') as f:
|
||||||
|
self.wfile.write(f.read())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serving static file: {e}")
|
||||||
|
self.send_error(500, 'Internal Server Error')
|
||||||
|
|
||||||
|
def check_port_available(port):
|
||||||
|
"""检查端口是否可用"""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind(('', port))
|
||||||
|
return True
|
||||||
|
except socket.error:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run():
|
||||||
|
port = 18369
|
||||||
|
if not check_port_available(port):
|
||||||
|
print(f"错误: 端口 {port} 已被占用")
|
||||||
|
return
|
||||||
|
|
||||||
|
server_address = ('', port)
|
||||||
|
httpd = ThreadingHTTPServer(server_address, APIHandler)
|
||||||
|
print(f'Server running on http://localhost:{port}')
|
||||||
|
print('API endpoints:')
|
||||||
|
print(' GET /api/roots - 获取所有根节点')
|
||||||
|
print(' GET /api/children/<id> - 获取指定节点的子节点')
|
||||||
|
print(' GET /api/node/<id> - 获取节点详情')
|
||||||
|
print(' GET /api/stream/<id> - 获取视频流地址')
|
||||||
|
print('静态文件服务: 访问 / 或 /index.html')
|
||||||
|
print('按 Ctrl+C 停止服务器')
|
||||||
|
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\n服务器已停止')
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
2
live_catalog/static/js/hls.min.js
vendored
Normal file
2
live_catalog/static/js/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
|
|||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
import yaml
|
|
||||||
from hikvision_openapi_signer import HikvisionOpenAPISigner
|
from hikvision_openapi_signer import HikvisionOpenAPISigner
|
||||||
|
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
@@ -13,12 +12,12 @@ signer = HikvisionOpenAPISigner(
|
|||||||
headers={'tagId': '0'} # 根据平台要求设置
|
headers={'tagId': '0'} # 根据平台要求设置
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_camera_preview_url(camera_index_code):
|
def get_organization_list(parentIndexCode):
|
||||||
# 签名一个请求
|
# 签名一个请求
|
||||||
request = signer.sign(
|
request = signer.sign(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/video/v1/cameras/previewURLs',
|
'/api/resource/v1/regions/subRegions',
|
||||||
jsons={'cameraIndexCode': camera_index_code, 'protocol': 'rtsp', 'expand': 'streamform=rtp'},
|
jsons={'parentIndexCode': parentIndexCode, 'treeCode': '0'},
|
||||||
accept='application/json'
|
accept='application/json'
|
||||||
)
|
)
|
||||||
method, url, headers, body = request
|
method, url, headers, body = request
|
||||||
@@ -26,28 +25,28 @@ def get_camera_preview_url(camera_index_code):
|
|||||||
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
# 读取 config.yaml
|
def get_final_list(regionIndexCode):
|
||||||
with open('../config.yaml', 'r', encoding='utf-8') as f:
|
# 签名一个请求
|
||||||
config = yaml.safe_load(f)
|
request = signer.sign(
|
||||||
|
'POST',
|
||||||
|
'/api/resource/v1/regions/regionIndexCode/cameras',
|
||||||
|
jsons={'pageNo': 1, 'pageSize': 100, 'regionIndexCode': regionIndexCode, 'treeCode': '0'},
|
||||||
|
accept='application/json'
|
||||||
|
)
|
||||||
|
method, url, headers, body = request
|
||||||
|
# 发送请求(注意离线环境下verify=False可能必要,但需知安全风险)
|
||||||
|
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
# 遍历所有摄像头
|
def get_camera_preview_url(camera_index_code, stream_type = 0):
|
||||||
for camera in config['cameras']:
|
# 签名一个请求
|
||||||
if 'index' in camera:
|
request = signer.sign(
|
||||||
index = camera['index']
|
'POST',
|
||||||
print(f"正在获取摄像头 {camera['name']} (index: {index}) 的预览地址...")
|
'/api/video/v1/cameras/previewURLs',
|
||||||
result = get_camera_preview_url(index)
|
jsons={'cameraIndexCode': camera_index_code, 'protocol': 'hls', 'streamType': stream_type, 'expand': 'transcode=1'},
|
||||||
print(f"API返回结果: {result}")
|
accept='application/json'
|
||||||
|
)
|
||||||
# 提取 url 并更新到 config
|
method, url, headers, body = request
|
||||||
if 'data' in result and 'url' in result['data']:
|
# 发送请求
|
||||||
rtsp_url = result['data']['url']
|
response = requests.request(method, url, headers=headers, data=body, verify=False)
|
||||||
camera['rtsp_url'] = rtsp_url
|
return response.json()
|
||||||
print(f"更新 rtsp_url: {rtsp_url}")
|
|
||||||
else:
|
|
||||||
print(f"未找到 url 在返回结果中")
|
|
||||||
|
|
||||||
# 保存更新后的 config.yaml
|
|
||||||
with open('../config.yaml', 'w', encoding='utf-8') as f:
|
|
||||||
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
|
|
||||||
|
|
||||||
print("config.yaml 已更新")
|
|
||||||
Reference in New Issue
Block a user