wenhongquan 2 dienas atpakaļ
vecāks
revīzija
9bbbc5a546

+ 28 - 5
CMakeLists.txt

@@ -26,15 +26,30 @@ include_directories(${AVFORMAT_INCLUDE_DIR})
 include_directories(${AVUTIL_INCLUDE_DIR})
 include_directories(${SWSCALE_INCLUDE_DIR})
 
-# Source files
-file(GLOB SOURCES 
-    "src/*.cpp"
+# Source files for main application
+file(GLOB MAIN_SOURCES 
+    "src/config.cpp"
+    "src/rtsp_client.cpp"
+    "src/scheduler.cpp"
+    "src/reporter.cpp"
+    "src/concurrent_calculator.cpp"
+    "src/http_server.cpp"
     "main.cpp"
 )
 
-add_executable(jtjai_media ${SOURCES})
+# Source files for HTTP server
+file(GLOB HTTP_SERVER_SOURCES
+    "src/http_server.cpp"
+    "http_server_main.cpp"
+)
+
+# Main RTSP client application
+add_executable(jtjai_media ${MAIN_SOURCES})
+
+# HTTP server application
+add_executable(jtjai_http_server ${HTTP_SERVER_SOURCES})
 
-# 链接库
+# 链接库 - RTSP client
 target_link_libraries(jtjai_media PRIVATE 
     fmt::fmt
     Boost::json 
@@ -45,3 +60,11 @@ target_link_libraries(jtjai_media PRIVATE
     ${SWSCALE_LIBRARY}
     pthread
 )
+
+# 链接库 - HTTP server
+target_link_libraries(jtjai_http_server PRIVATE 
+    fmt::fmt
+    Boost::json 
+    Boost::system 
+    pthread
+)

+ 332 - 0
HTTP_API_DOC.md

@@ -0,0 +1,332 @@
+# RTSP视频流管理HTTP服务器 API文档
+
+## 概述
+
+这是一个用于管理RTSP视频流拉取结果的HTTP服务器,提供查询和删除视频文件的功能。
+
+## 启动服务器
+
+### 编译
+```bash
+cd /Users/wenhongquan/CLionProjects/jtjai_media
+cmake --build cmake-build-debug --target jtjai_http_server
+```
+
+### 运行
+```bash
+# 使用默认参数(输出目录:./output,端口:8080)
+./cmake-build-debug/jtjai_http_server
+
+# 指定输出目录
+./cmake-build-debug/jtjai_http_server ./output
+
+# 指定输出目录和端口
+./cmake-build-debug/jtjai_http_server ./output 8080
+```
+
+### 参数说明
+- 参数1:输出目录路径(默认:`./output`)
+- 参数2:监听端口(默认:`8080`)
+
+## API端点
+
+### 1. 列出所有时间戳目录
+
+**请求**
+```
+GET /api/timestamps
+```
+
+**响应示例**
+```json
+{
+  "count": 3,
+  "timestamps": [
+    {
+      "timestamp": "20251010_163200",
+      "video_count": 2,
+      "total_size": 8385920
+    },
+    {
+      "timestamp": "20251010_163100",
+      "video_count": 2,
+      "total_size": 8385920
+    },
+    {
+      "timestamp": "20251010_163000",
+      "video_count": 2,
+      "total_size": 8385920
+    }
+  ]
+}
+```
+
+**字段说明**
+- `count`: 时间戳目录总数
+- `timestamp`: 时间戳目录名称
+- `video_count`: 该目录下的视频数量
+- `total_size`: 该目录下所有视频的总大小(字节)
+
+---
+
+### 2. 列出所有视频
+
+**请求**
+```
+GET /api/videos
+```
+
+**响应示例**
+```json
+{
+  "count": 6,
+  "videos": [
+    {
+      "filename": "test_stream1.mp4",
+      "full_path": "./output/20251010_163000/test_stream1.mp4",
+      "timestamp_dir": "20251010_163000",
+      "file_size": 1027405,
+      "created_time": "2025-10-10 16:30:26",
+      "stream_index": "1"
+    },
+    {
+      "filename": "test_stream2.mp4",
+      "full_path": "./output/20251010_163000/test_stream2.mp4",
+      "timestamp_dir": "20251010_163000",
+      "file_size": 7358515,
+      "created_time": "2025-10-10 16:30:26",
+      "stream_index": "2"
+    }
+  ]
+}
+```
+
+**字段说明**
+- `filename`: 文件名
+- `full_path`: 完整路径
+- `timestamp_dir`: 所属时间戳目录
+- `file_size`: 文件大小(字节)
+- `created_time`: 创建时间
+- `stream_index`: 流索引(从文件名提取)
+
+---
+
+### 3. 列出指定时间戳目录的视频
+
+**请求**
+```
+GET /api/videos?timestamp=20251010_163000
+```
+
+或者
+
+```
+GET /api/videos/20251010_163000
+```
+
+**响应示例**
+```json
+{
+  "timestamp": "20251010_163000",
+  "count": 2,
+  "videos": [
+    {
+      "filename": "test_stream1.mp4",
+      "full_path": "./output/20251010_163000/test_stream1.mp4",
+      "timestamp_dir": "20251010_163000",
+      "file_size": 1027405,
+      "created_time": "2025-10-10 16:30:26",
+      "stream_index": "1"
+    }
+  ]
+}
+```
+
+---
+
+### 4. 删除单个视频文件
+
+**请求**
+```
+DELETE /api/video?path=./output/20251010_163000/test_stream1.mp4
+```
+
+**响应示例(成功)**
+```json
+{
+  "success": true,
+  "path": "./output/20251010_163000/test_stream1.mp4",
+  "message": "文件删除成功"
+}
+```
+
+**响应示例(失败)**
+```json
+{
+  "success": false,
+  "path": "./output/20251010_163000/test_stream1.mp4",
+  "message": "文件删除失败"
+}
+```
+
+**安全限制**
+- 只能删除输出目录内的文件
+- 尝试删除输出目录外的文件会返回403 Forbidden
+
+---
+
+### 5. 删除整个时间戳目录
+
+**请求**
+```
+DELETE /api/timestamp/20251010_163000
+```
+
+**响应示例(成功)**
+```json
+{
+  "success": true,
+  "timestamp": "20251010_163000",
+  "path": "./output/20251010_163000",
+  "message": "目录删除成功"
+}
+```
+
+**响应示例(失败)**
+```json
+{
+  "success": false,
+  "timestamp": "20251010_163000",
+  "path": "./output/20251010_163000",
+  "message": "目录删除失败"
+}
+```
+
+**注意**
+- 删除目录会删除该目录下的所有文件(包括视频、报告等)
+- 此操作不可逆,请谨慎使用
+
+---
+
+## 错误响应
+
+所有错误响应都遵循以下格式:
+
+```json
+{
+  "error": "错误类型",
+  "message": "详细错误信息"
+}
+```
+
+### 常见错误状态码
+
+- `400 Bad Request`: 请求参数错误
+- `403 Forbidden`: 无权访问或删除
+- `404 Not Found`: 请求的资源不存在
+- `500 Internal Server Error`: 服务器内部错误
+
+---
+
+## 使用示例
+
+### 使用curl命令
+
+#### 列出所有时间戳目录
+```bash
+curl http://localhost:8080/api/timestamps
+```
+
+#### 列出所有视频
+```bash
+curl http://localhost:8080/api/videos
+```
+
+#### 列出指定时间戳的视频
+```bash
+curl http://localhost:8080/api/videos/20251010_163000
+```
+
+#### 删除单个视频
+```bash
+curl -X DELETE "http://localhost:8080/api/video?path=./output/20251010_163000/test_stream1.mp4"
+```
+
+#### 删除整个时间戳目录
+```bash
+curl -X DELETE http://localhost:8080/api/timestamp/20251010_163000
+```
+
+### 使用JavaScript (浏览器)
+
+```javascript
+// 列出所有视频
+fetch('http://localhost:8080/api/videos')
+  .then(response => response.json())
+  .then(data => console.log(data));
+
+// 删除视频
+fetch('http://localhost:8080/api/video?path=./output/20251010_163000/test_stream1.mp4', {
+  method: 'DELETE'
+})
+  .then(response => response.json())
+  .then(data => console.log(data));
+```
+
+### 使用Python
+
+```python
+import requests
+
+# 列出所有视频
+response = requests.get('http://localhost:8080/api/videos')
+print(response.json())
+
+# 删除视频
+response = requests.delete('http://localhost:8080/api/video', 
+                          params={'path': './output/20251010_163000/test_stream1.mp4'})
+print(response.json())
+```
+
+---
+
+## CORS支持
+
+服务器支持跨域请求(CORS),可以从任何域的网页中调用API。
+
+---
+
+## 注意事项
+
+1. **安全性**: 当前版本没有身份验证机制,请勿在公网环境中使用
+2. **文件路径**: 删除操作只能针对输出目录内的文件,防止误删系统文件
+3. **并发**: 服务器支持并发请求处理
+4. **停止服务器**: 按 Ctrl+C 可以优雅地停止服务器
+
+---
+
+## 目录结构
+
+```
+output/
+├── 20251010_163000/        # 第1轮的输出
+│   ├── test_stream1.mp4
+│   ├── test_stream2.mp4
+│   ├── rtsp_report.json
+│   ├── report.txt
+│   └── streams.csv
+├── 20251010_163100/        # 第2轮的输出
+│   ├── test_stream1.mp4
+│   ├── test_stream2.mp4
+│   ├── rtsp_report.json
+│   ├── report.txt
+│   └── streams.csv
+└── 20251010_163200/        # 第3轮的输出
+    ├── test_stream1.mp4
+    ├── test_stream2.mp4
+    ├── rtsp_report.json
+    ├── report.txt
+    └── streams.csv
+```
+
+服务器会扫描输出目录下所有的时间戳子目录,并提供统一的查询和管理接口。

+ 286 - 0
README.md

@@ -0,0 +1,286 @@
+# RTSP视频流管理系统 - 使用指南
+
+## 项目概述
+
+本项目提供了一套完整的RTSP视频流拉取和管理解决方案,包括:
+
+1. **RTSP视频流拉取程序**:支持并发拉取多个RTSP流,按时间戳自动归档
+2. **HTTP管理服务器**:提供RESTful API用于查询和删除视频文件
+3. **Web管理界面**:可视化的视频文件管理界面
+
+## 功能特性
+
+### 1. RTSP视频流拉取(jtjai_media)
+
+- ✅ 支持多轮循环拉取
+- ✅ 自动按时间戳创建目录(格式:YYYYMMDD_HHMMSS)
+- ✅ 并发流控制
+- ✅ 权重调度算法
+- ✅ 自动生成详细报告(JSON、TXT、CSV格式)
+
+### 2. HTTP管理服务器(jtjai_http_server)
+
+- ✅ 列出所有时间戳目录
+- ✅ 列出所有视频文件
+- ✅ 按时间戳查询视频
+- ✅ 删除单个视频文件
+- ✅ 删除整个时间戳目录
+- ✅ RESTful API设计
+- ✅ CORS支持
+
+### 3. Web管理界面(video_manager.html)
+
+- ✅ 实时统计信息展示
+- ✅ 时间戳目录浏览
+- ✅ 视频文件查看
+- ✅ 一键删除功能
+- ✅ 响应式设计
+
+## 快速开始
+
+### 编译项目
+
+```bash
+cd /Users/wenhongquan/CLionProjects/jtjai_media
+
+# 编译RTSP拉取程序
+cmake --build cmake-build-debug --target jtjai_media
+
+# 编译HTTP服务器
+cmake --build cmake-build-debug --target jtjai_http_server
+```
+
+### 运行RTSP视频流拉取
+
+```bash
+# 使用默认配置文件
+./cmake-build-debug/jtjai_media
+
+# 或指定配置文件
+./cmake-build-debug/jtjai_media /path/to/config.json
+```
+
+**输出目录结构**:
+
+```
+output/
+├── 20251010_160000/        # 第1轮拉取(时间戳命名)
+│   ├── test_stream1.mp4
+│   ├── test_stream2.mp4
+│   ├── rtsp_report.json
+│   ├── report.txt
+│   └── streams.csv
+├── 20251010_170000/        # 第2轮拉取
+│   ├── test_stream1.mp4
+│   ├── test_stream2.mp4
+│   ├── rtsp_report.json
+│   ├── report.txt
+│   └── streams.csv
+└── ...
+```
+
+### 运行HTTP管理服务器
+
+```bash
+# 使用默认参数(目录:./output,端口:8080)
+./cmake-build-debug/jtjai_http_server
+
+# 指定输出目录
+./cmake-build-debug/jtjai_http_server ./output
+
+# 指定输出目录和端口
+./cmake-build-debug/jtjai_http_server ./output 8080
+```
+
+服务器启动后会显示:
+
+```
+========================================
+RTSP视频流管理HTTP服务器
+========================================
+输出目录: ./output
+监听端口: 8080
+HTTP服务器已启动,监听端口: 8080
+API端点:
+  GET  /api/videos - 列出所有视频
+  GET  /api/timestamps - 列出所有时间戳目录
+  GET  /api/videos/{timestamp} - 列出指定时间戳目录的视频
+  DELETE /api/video?path={path} - 删除指定视频文件
+  DELETE /api/timestamp/{timestamp} - 删除指定时间戳目录及其所有文件
+
+HTTP服务器已启动,按 Ctrl+C 停止服务器
+```
+
+### 使用Web管理界面
+
+1. 确保HTTP服务器正在运行
+2. 用浏览器打开 `video_manager.html` 文件
+3. 界面会自动连接到 `http://localhost:8080` 并加载数据
+
+**主要功能**:
+
+- 📊 查看统计信息(目录数、视频数、总大小)
+- 📁 浏览时间戳目录
+- 🎬 查看视频文件详情
+- 🗑️ 删除视频文件或整个目录
+- 🔄 一键刷新数据
+
+## API文档
+
+详细的API文档请参考:[HTTP_API_DOC.md](HTTP_API_DOC.md)
+
+### 快速示例
+
+#### 1. 列出所有时间戳目录
+
+```bash
+curl http://localhost:8080/api/timestamps
+```
+
+#### 2. 列出所有视频
+
+```bash
+curl http://localhost:8080/api/videos
+```
+
+#### 3. 列出指定时间戳的视频
+
+```bash
+curl http://localhost:8080/api/videos/20251010_160000
+```
+
+#### 4. 删除单个视频
+
+```bash
+curl -X DELETE "http://localhost:8080/api/video?path=./output/20251010_160000/test_stream1.mp4"
+```
+
+#### 5. 删除整个时间戳目录
+
+```bash
+curl -X DELETE http://localhost:8080/api/timestamp/20251010_160000
+```
+
+## 测试
+
+### 运行API测试脚本
+
+```bash
+./test_http_api.sh
+```
+
+该脚本会自动测试所有API端点并显示结果。
+
+## 配置文件说明
+
+RTSP拉取程序使用JSON配置文件(默认:`config.json`):
+
+```json
+{
+  "global_config": {
+    "total_poll_duration_seconds": 60,    // 每轮总时长
+    "max_concurrent_streams": 2,          // 最大并发数
+    "output_directory": "./output",       // 基础输出目录
+    "report_filename": "rtsp_report.json",
+    "connection_timeout_seconds": 3,
+    "read_timeout_seconds": 5,
+    "poll_cycles": 3,                     // 轮询次数(-1为无限)
+    "cycle_interval_seconds": 30          // 轮询间隔
+  },
+  "streams": [
+    {
+      "rtsp_url": "rtsp://...",
+      "duration_seconds": 15,
+      "weight": 1.0,
+      "output_filename": "test_stream1.mp4"
+    }
+  ]
+}
+```
+
+## 文件结构
+
+```
+jtjai_media/
+├── include/
+│   ├── config.h                 # 配置管理
+│   ├── rtsp_client.h           # RTSP客户端
+│   ├── scheduler.h             # 任务调度器
+│   ├── reporter.h              # 报告生成器
+│   ├── concurrent_calculator.h # 并发计算器
+│   └── http_server.h           # HTTP服务器
+├── src/
+│   ├── config.cpp
+│   ├── rtsp_client.cpp
+│   ├── scheduler.cpp
+│   ├── reporter.cpp
+│   ├── concurrent_calculator.cpp
+│   └── http_server.cpp
+├── main.cpp                    # RTSP拉取主程序
+├── http_server_main.cpp        # HTTP服务器主程序
+├── video_manager.html          # Web管理界面
+├── config.json                 # 配置文件
+├── HTTP_API_DOC.md            # API文档
+├── test_http_api.sh           # API测试脚本
+└── README.md                  # 本文件
+```
+
+## 工作流程
+
+1. **拉取视频流**
+
+   - 运行 `jtjai_media` 程序
+   - 根据配置文件拉取RTSP流
+   - 每轮拉取自动创建时间戳目录
+   - 生成详细报告
+2. **管理视频文件**
+
+   - 启动 `jtjai_http_server` 服务器
+   - 通过API或Web界面查看视频
+   - 按需删除不需要的视频或目录
+3. **监控和维护**
+
+   - 通过Web界面查看统计信息
+   - 定期清理旧的视频文件
+   - 检查报告了解拉取状态
+
+## 注意事项
+
+1. **存储空间**:视频文件会占用大量存储空间,请定期清理
+2. **网络带宽**:并发拉取会占用大量带宽,请根据实际情况调整并发数
+3. **安全性**:HTTP服务器没有身份验证,不要暴露到公网
+4. **路径安全**:删除操作有路径检查,只能删除输出目录内的文件
+
+## 常见问题
+
+### Q: 如何修改HTTP服务器端口?
+
+A: 启动时指定端口参数:`./jtjai_http_server ./output 9090`
+
+### Q: 如何查看某个时间段的视频?
+
+A: 时间戳目录名包含日期时间(YYYYMMDD_HHMMSS),可以通过API按时间戳查询
+
+### Q: 删除操作可以撤销吗?
+
+A: 不可以,删除操作是永久的,请谨慎操作
+
+### Q: 如何批量删除旧视频?
+
+A: 可以使用API遍历时间戳目录,删除指定日期之前的目录
+
+## 技术栈
+
+- **C++17**
+- **Boost**:JSON解析、ASIO网络库
+- **FFmpeg**:视频流处理
+- **HTML/CSS/JavaScript**:Web界面
+
+## 更新日志
+
+### 2025-10-10
+
+- ✅ 实现时间戳目录自动创建
+- ✅ 添加HTTP管理服务器
+- ✅ 创建Web可视化管理界面
+- ✅ 完善API文档和使用说明

+ 0 - 0
VIDEO_PLAYER_GUIDE.md


+ 2 - 2
config.json

@@ -11,13 +11,13 @@
   },
   "streams": [
     {
-      "rtsp_url": "rtsp://test1.example.com:554/live/stream1",
+      "rtsp_url": "rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001",
       "duration_seconds": 15,
       "weight": 1.0,
       "output_filename": "test_stream1.mp4"
     },
     {
-      "rtsp_url": "rtsp://test2.example.com:554/live/stream2", 
+      "rtsp_url": "rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001", 
       "duration_seconds": 20,
       "weight": 1.5,
       "output_filename": "test_stream2.mp4"

+ 70 - 0
http_server_main.cpp

@@ -0,0 +1,70 @@
+#include <iostream>
+#include <signal.h>
+#include <thread>
+#include <chrono>
+#include "http_server.h"
+
+using namespace jtjai_media;
+
+// 全局HTTP服务器实例
+static std::unique_ptr<HttpServer> g_http_server;
+static std::atomic<bool> g_interrupted(false);
+
+// 信号处理函数
+void signal_handler(int signal) {
+    std::cout << "\n接收到信号 " << signal << ",正在停止服务器..." << std::endl;
+    g_interrupted.store(true);
+    if (g_http_server) {
+        g_http_server->stop();
+    }
+}
+
+int main(int argc, char* argv[]) {
+    std::cout << "========================================" << std::endl;
+    std::cout << "RTSP视频流管理HTTP服务器" << std::endl;
+    std::cout << "========================================" << std::endl;
+    
+    // 注册信号处理器
+    signal(SIGINT, signal_handler);
+    signal(SIGTERM, signal_handler);
+    
+    // 解析命令行参数
+    std::string output_directory = "./output";
+    int port = 8080;
+    
+    if (argc > 1) {
+        output_directory = argv[1];
+    }
+    if (argc > 2) {
+        port = std::atoi(argv[2]);
+    }
+    
+    std::cout << "输出目录: " << output_directory << std::endl;
+    std::cout << "监听端口: " << port << std::endl;
+    
+    try {
+        // 创建HTTP服务器
+        g_http_server = std::make_unique<HttpServer>(output_directory, port);
+        
+        // 启动服务器
+        if (!g_http_server->start()) {
+            std::cerr << "启动HTTP服务器失败" << std::endl;
+            return 1;
+        }
+        
+        std::cout << "\nHTTP服务器已启动,按 Ctrl+C 停止服务器\n" << std::endl;
+        
+        // 保持主线程运行
+        while (!g_interrupted.load()) {
+            std::this_thread::sleep_for(std::chrono::seconds(1));
+        }
+        
+        std::cout << "\n服务器已停止" << std::endl;
+        
+    } catch (const std::exception& e) {
+        std::cerr << "服务器异常: " << e.what() << std::endl;
+        return 1;
+    }
+    
+    return 0;
+}

+ 110 - 0
include/http_server.h

@@ -0,0 +1,110 @@
+#ifndef JTJAI_MEDIA_HTTP_SERVER_H
+#define JTJAI_MEDIA_HTTP_SERVER_H
+
+#include <string>
+#include <vector>
+#include <map>
+#include <functional>
+#include <memory>
+#include <thread>
+#include <atomic>
+#include <boost/asio.hpp>
+#include <boost/json.hpp>
+
+namespace jtjai_media {
+
+// 视频文件信息结构
+struct VideoFileInfo {
+    std::string filename;           // 文件名
+    std::string full_path;          // 完整路径
+    std::string timestamp_dir;      // 所属时间戳目录
+    int64_t file_size;              // 文件大小(字节)
+    std::string created_time;       // 创建时间
+    std::string stream_index;       // 流索引(从文件名推断)
+};
+
+// HTTP请求结构
+struct HttpRequest {
+    std::string method;             // GET, POST, DELETE等
+    std::string path;               // 请求路径
+    std::string query_string;       // 查询字符串
+    std::string body;               // 请求体
+    std::map<std::string, std::string> headers;  // 请求头
+};
+
+// HTTP响应结构
+struct HttpResponse {
+    int status_code = 200;          // 状态码
+    std::string status_message = "OK";  // 状态消息
+    std::string content_type = "application/json";  // 内容类型
+    std::string body;               // 响应体
+    
+    HttpResponse() = default;
+    HttpResponse(int code, const std::string& msg, const std::string& content)
+        : status_code(code), status_message(msg), body(content) {}
+};
+
+class HttpServer {
+public:
+    explicit HttpServer(const std::string& output_directory, int port = 8080);
+    ~HttpServer();
+    
+    // 启动服务器
+    bool start();
+    
+    // 停止服务器
+    void stop();
+    
+    // 检查服务器是否在运行
+    bool is_running() const { return is_running_.load(); }
+    
+    // 获取服务器端口
+    int get_port() const { return port_; }
+
+private:
+    std::string output_directory_;   // 输出目录
+    int port_;                       // 监听端口
+    std::atomic<bool> is_running_;   // 运行状态
+    std::unique_ptr<std::thread> server_thread_;  // 服务器线程
+    
+    // 服务器主循环
+    void server_main_loop();
+    
+    // 处理客户端连接
+    void handle_client(boost::asio::ip::tcp::socket socket);
+    
+    // 解析HTTP请求
+    HttpRequest parse_request(const std::string& request_data);
+    
+    // 构建HTTP响应
+    std::string build_response(const HttpResponse& response);
+    
+    // 路由处理
+    HttpResponse route_request(const HttpRequest& request);
+    
+    // API处理函数
+    HttpResponse handle_list_videos(const HttpRequest& request);
+    HttpResponse handle_list_timestamps(const HttpRequest& request);
+    HttpResponse handle_delete_video(const HttpRequest& request);
+    HttpResponse handle_delete_timestamp(const HttpRequest& request);
+    HttpResponse handle_get_video_info(const HttpRequest& request);
+    HttpResponse handle_video_file(const HttpRequest& request);
+    HttpResponse handle_static_file(const HttpRequest& request);
+    
+    // 工具函数
+    std::vector<std::string> list_timestamp_directories();
+    std::vector<VideoFileInfo> list_videos_in_directory(const std::string& dir_path);
+    std::vector<VideoFileInfo> list_all_videos();
+    bool delete_file(const std::string& file_path);
+    bool delete_directory(const std::string& dir_path);
+    std::string get_file_created_time(const std::string& file_path);
+    int64_t get_file_size(const std::string& file_path);
+    std::map<std::string, std::string> parse_query_string(const std::string& query);
+    std::string url_decode(const std::string& str);
+    HttpResponse generate_index_page();
+    HttpResponse generate_api_doc();
+};
+
+} // namespace jtjai_media
+
+#endif // JTJAI_MEDIA_HTTP_SERVER_H

+ 57 - 25
main.cpp

@@ -2,14 +2,19 @@
 #include <signal.h>
 #include <chrono>
 #include <thread>
+#include <iomanip>
+#include <sstream>
+#include <filesystem>
 #include "config.h"
 #include "scheduler.h"
 #include "reporter.h"
+#include "http_server.h"
 
 using namespace jtjai_media;
 
 // 全局变量用于信号处理
 static std::unique_ptr<StreamScheduler> g_scheduler;
+static std::unique_ptr<HttpServer> g_http_server;
 static std::atomic<bool> g_interrupted(false);
 
 // 信号处理函数
@@ -19,6 +24,9 @@ void signal_handler(int signal) {
     if (g_scheduler) {
         g_scheduler->stop_execution();
     }
+    if (g_http_server) {
+        g_http_server->stop();
+    }
 }
 
 // 进度回调函数
@@ -28,30 +36,36 @@ void progress_callback(const StreamScheduler::SchedulerStats& stats) {
               << "当前并发: " << stats.max_concurrent_used << " 流" << std::flush;
 }
 
+// 生成时间戳字符串
+std::string generate_timestamp() {
+    auto now = std::chrono::system_clock::now();
+    auto time_t = std::chrono::system_clock::to_time_t(now);
+    auto tm = *std::localtime(&time_t);
+    
+    std::stringstream ss;
+    ss << std::put_time(&tm, "%Y%m%d_%H%M%S");
+    return ss.str();
+}
+
 // 报告生成回调函数
 void report_callback(const ConfigManager& config_mgr, int cycle_number, 
+                    const std::string& cycle_output_dir,
                     const std::vector<RTSPClientStats>& client_stats,
                     const StreamScheduler::SchedulerStats& scheduler_stats) {
     std::cout << "生成第 " << cycle_number << " 轮执行报告..." << std::endl;
     
-    // 为每个周期生成不同的报告文件
+    // 为每个周期使用独立的时间戳目录
     ConfigManager cycle_config = config_mgr;
     auto cycle_global_config = cycle_config.get_global_config();
     
-    // 修改报告文件名,包含周期编号
-    std::string base_name = cycle_global_config.report_filename;
-    size_t dot_pos = base_name.find_last_of('.');
-    if (dot_pos != std::string::npos) {
-        base_name = base_name.substr(0, dot_pos) + "_cycle" + std::to_string(cycle_number) + base_name.substr(dot_pos);
-    } else {
-        base_name = base_name + "_cycle" + std::to_string(cycle_number);
-    }
-    cycle_global_config.report_filename = base_name;
+    // 使用传入的时间戳目录
+    cycle_global_config.output_directory = cycle_output_dir;
     cycle_config.set_global_config(cycle_global_config);
     
     ResultReporter cycle_reporter(cycle_config);
     if (cycle_reporter.generate_report(client_stats, scheduler_stats)) {
         std::cout << "第 " << cycle_number << " 轮报告生成成功" << std::endl;
+        std::cout << "输出目录: " << cycle_output_dir << std::endl;
     } else {
         std::cerr << "第 " << cycle_number << " 轮报告生成失败" << std::endl;
     }
@@ -85,13 +99,20 @@ int main(int argc, char* argv[]) {
         std::cout << "配置加载成功" << std::endl;
         std::cout << config_mgr.to_string() << std::endl;
         
-        // 创建调度器
-        g_scheduler = std::make_unique<StreamScheduler>(config_mgr);
+        const auto& global_config = config_mgr.get_global_config();
+        
+        // 启动HTTP服务器
+        std::cout << "\n启动HTTP管理服务器..." << std::endl;
+        g_http_server = std::make_unique<HttpServer>(global_config.output_directory, 8080);
+        if (!g_http_server->start()) {
+            std::cerr << "启动HTTP服务器失败" << std::endl;
+            return 1;
+        }
         
-        // 设置进度回调
-        g_scheduler->set_progress_callback(progress_callback);
+        std::cout << "\n🌐 Web管理界面: http://localhost:8080" << std::endl;
+        std::cout << "📁 文件管理: http://localhost:8080/manager" << std::endl;
+        std::cout << "📊 API文档: http://localhost:8080/api" << std::endl;
         
-        const auto& global_config = config_mgr.get_global_config();
         int current_cycle = 0;
         
         // 开始多轮询循环
@@ -103,11 +124,30 @@ int main(int argc, char* argv[]) {
             }
             std::cout << " ==========" << std::endl;
             
+            // 为当前周期创建时间戳目录
+            std::string timestamp = generate_timestamp();
+            std::string base_output_dir = global_config.output_directory;
+            std::string cycle_output_dir = base_output_dir + "/" + timestamp;
+            
+            // 创建时间戳目录
+            std::filesystem::create_directories(cycle_output_dir);
+            std::cout << "当前周期输出目录: " << cycle_output_dir << std::endl;
+            
+            // 更新配置管理器的输出目录
+            ConfigManager cycle_config_mgr = config_mgr;
+            auto cycle_global_config = cycle_config_mgr.get_global_config();
+            cycle_global_config.output_directory = cycle_output_dir;
+            cycle_config_mgr.set_global_config(cycle_global_config);
+            
+            // 使用新的配置创建调度器
+            g_scheduler = std::make_unique<StreamScheduler>(cycle_config_mgr);
+            g_scheduler->set_progress_callback(progress_callback);
+            
             // 设置当前周期的报告回调
-            g_scheduler->set_report_callback([&config_mgr, current_cycle](
+            g_scheduler->set_report_callback([&config_mgr, current_cycle, cycle_output_dir](
                 const std::vector<RTSPClientStats>& client_stats,
                 const StreamScheduler::SchedulerStats& scheduler_stats) {
-                report_callback(config_mgr, current_cycle, client_stats, scheduler_stats);
+                report_callback(config_mgr, current_cycle, cycle_output_dir, client_stats, scheduler_stats);
             });
             
             // 启动调度执行
@@ -143,14 +183,6 @@ int main(int argc, char* argv[]) {
                     }
                 }
                 std::cout << std::endl;
-                
-                if (!g_interrupted.load()) {
-                    // 重置调度器状态,准备下一轮
-                    if (!g_scheduler->reset_for_next_cycle()) {
-                        std::cerr << "重置调度器失败" << std::endl;
-                        break;
-                    }
-                }
             }
         }
         

+ 1 - 0
output/20251010_175031/report.txt

@@ -0,0 +1 @@
+========================================\nRTSP视频流拉取报告\n========================================\n生成时间: 2025-10-10 17:50:51\n\n配置信息:\n--------\n总轮询时长: 60 秒\n最大并发流数: 2\n输出目录: ./output/20251010_175031\n配置的流数量: 2\n\n调度器统计:\n----------\n总任务数: 2\n已完成: 0\n失败: 0\n取消: 0\n最大并发数: 0\n完成率: 0.00%\n调度开始时间: 2025-10-10 17:50:31\n调度结束时间: N/A\n总调度时长: N/A\n\n汇总统计:\n--------\n成功流数: 2 / 2\n成功率: 100.00%\n总接收字节: 0.00 B\n总接收帧数: 985\n平均持续时间: 18.0 秒\n\n详细流信息:\n==========\n流 1:\n  RTSP地址: rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001\n  输出文件: ./output/20251010_175031/test_stream2.mp4\n  状态: 完成\n  开始时间: 2025-10-10 17:50:31\n  结束时间: 2025-10-10 17:50:51\n  持续时间: 20 秒\n  计划持续时间: 20 秒\n  接收字节: 0.00 B\n  接收帧数: 581\n\n流 0:\n  RTSP地址: rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001\n  输出文件: ./output/20251010_175031/test_stream1.mp4\n  状态: 完成\n  开始时间: 2025-10-10 17:50:31\n  结束时间: 2025-10-10 17:50:48\n  持续时间: 16 秒\n  计划持续时间: 15 秒\n  接收字节: 0.00 B\n  接收帧数: 404\n\n

+ 1 - 0
output/20251010_175031/rtsp_report.json

@@ -0,0 +1 @@
+{"report_generated_at":"2025-10-10 17:50:51","config":{"global_config":{"total_poll_duration_seconds":60,"max_concurrent_streams":2,"output_directory":"./output/20251010_175031","report_filename":"rtsp_report.json","connection_timeout_seconds":3,"read_timeout_seconds":5},"streams":[{"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001","duration_seconds":15,"weight":1E0,"output_filename":"test_stream1.mp4"},{"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001","duration_seconds":20,"weight":1.5E0,"output_filename":"test_stream2.mp4"}]},"scheduler_stats":{"total_tasks":2,"completed_tasks":0,"failed_tasks":0,"cancelled_tasks":0,"start_time":"2025-10-10 17:50:31","end_time":"N/A","max_concurrent_used":0,"completion_rate":0E0},"streams":[{"stream_index":1,"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001","output_file":"./output/20251010_175031/test_stream2.mp4","status":"完成","start_time":"2025-10-10 17:50:31","end_time":"2025-10-10 17:50:51","bytes_received":0,"frames_received":581,"duration_seconds":20,"error_message":"","actual_duration_seconds":20},{"stream_index":0,"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001","output_file":"./output/20251010_175031/test_stream1.mp4","status":"完成","start_time":"2025-10-10 17:50:31","end_time":"2025-10-10 17:50:48","bytes_received":0,"frames_received":404,"duration_seconds":15,"error_message":"","actual_duration_seconds":16}],"summary":{"total_streams":2,"successful_streams":2,"failed_streams":0,"cancelled_streams":0,"total_bytes":0,"total_frames":985,"success_rate":1E0,"average_duration":1.8E1,"earliest_start":"2025-10-10 17:50:31","latest_end":"2025-10-10 17:50:51"}}

+ 1 - 0
output/20251010_175031/streams.csv

@@ -0,0 +1 @@
+Stream Index,RTSP URL,Output File,Status,Start Time,End Time,Duration (s),Planned Duration (s),Bytes Received,Frames Received,Error Message\n1,\ << stats.rtsp_url << ,\ << stats.output_file << ,\ << status_to_string(stats.status) << ,\ << format_timestamp(stats.start_time) << ,\ << format_timestamp(stats.end_time) << ,20,20,0,581,\ << stats.error_message << \n0,\ << stats.rtsp_url << ,\ << stats.output_file << ,\ << status_to_string(stats.status) << ,\ << format_timestamp(stats.start_time) << ,\ << format_timestamp(stats.end_time) << ,16,15,0,404,\ << stats.error_message << \n

BIN
output/20251010_175031/test_stream1.mp4


BIN
output/20251010_175031/test_stream2.mp4


+ 1 - 0
output/20251010_175050/report.txt

@@ -0,0 +1 @@
+========================================\nRTSP视频流拉取报告\n========================================\n生成时间: 2025-10-10 17:51:00\n\n配置信息:\n--------\n总轮询时长: 60 秒\n最大并发流数: 2\n输出目录: ./output/20251010_175050\n配置的流数量: 2\n\n调度器统计:\n----------\n总任务数: 2\n已完成: 0\n失败: 0\n取消: 0\n最大并发数: 0\n完成率: 0.00%\n调度开始时间: 2025-10-10 17:50:50\n调度结束时间: N/A\n总调度时长: N/A\n\n汇总统计:\n--------\n成功流数: 0 / 2\n成功率: 0.00%\n总接收字节: 0.00 B\n总接收帧数: 605\n平均持续时间: 9.0 秒\n\n详细流信息:\n==========\n流 1:\n  RTSP地址: rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001\n  输出文件: ./output/20251010_175050/test_stream2.mp4\n  状态: 已取消\n  开始时间: 2025-10-10 17:50:50\n  结束时间: 2025-10-10 17:51:00\n  持续时间: 9 秒\n  计划持续时间: 20 秒\n  接收字节: 0.00 B\n  接收帧数: 295\n\n流 0:\n  RTSP地址: rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001\n  输出文件: ./output/20251010_175050/test_stream1.mp4\n  状态: 已取消\n  开始时间: 2025-10-10 17:50:50\n  结束时间: 2025-10-10 17:51:00\n  持续时间: 9 秒\n  计划持续时间: 15 秒\n  接收字节: 0.00 B\n  接收帧数: 310\n\n

+ 1 - 0
output/20251010_175050/rtsp_report.json

@@ -0,0 +1 @@
+{"report_generated_at":"2025-10-10 17:51:00","config":{"global_config":{"total_poll_duration_seconds":60,"max_concurrent_streams":2,"output_directory":"./output/20251010_175050","report_filename":"rtsp_report.json","connection_timeout_seconds":3,"read_timeout_seconds":5},"streams":[{"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001","duration_seconds":15,"weight":1E0,"output_filename":"test_stream1.mp4"},{"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001","duration_seconds":20,"weight":1.5E0,"output_filename":"test_stream2.mp4"}]},"scheduler_stats":{"total_tasks":2,"completed_tasks":0,"failed_tasks":0,"cancelled_tasks":0,"start_time":"2025-10-10 17:50:50","end_time":"N/A","max_concurrent_used":0,"completion_rate":0E0},"streams":[{"stream_index":1,"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000164_34020000001320000001","output_file":"./output/20251010_175050/test_stream2.mp4","status":"已取消","start_time":"2025-10-10 17:50:50","end_time":"2025-10-10 17:51:00","bytes_received":0,"frames_received":295,"duration_seconds":20,"error_message":"","actual_duration_seconds":9},{"stream_index":0,"rtsp_url":"rtsp://218.94.57.146:40007/rtp/44010200492000000074_34020000001320000001","output_file":"./output/20251010_175050/test_stream1.mp4","status":"已取消","start_time":"2025-10-10 17:50:50","end_time":"2025-10-10 17:51:00","bytes_received":0,"frames_received":310,"duration_seconds":15,"error_message":"","actual_duration_seconds":9}],"summary":{"total_streams":2,"successful_streams":0,"failed_streams":0,"cancelled_streams":2,"total_bytes":0,"total_frames":605,"success_rate":0E0,"average_duration":9E0,"earliest_start":"2025-10-10 17:50:50","latest_end":"2025-10-10 17:51:00"}}

+ 1 - 0
output/20251010_175050/streams.csv

@@ -0,0 +1 @@
+Stream Index,RTSP URL,Output File,Status,Start Time,End Time,Duration (s),Planned Duration (s),Bytes Received,Frames Received,Error Message\n1,\ << stats.rtsp_url << ,\ << stats.output_file << ,\ << status_to_string(stats.status) << ,\ << format_timestamp(stats.start_time) << ,\ << format_timestamp(stats.end_time) << ,9,20,0,295,\ << stats.error_message << \n0,\ << stats.rtsp_url << ,\ << stats.output_file << ,\ << status_to_string(stats.status) << ,\ << format_timestamp(stats.start_time) << ,\ << format_timestamp(stats.end_time) << ,9,15,0,310,\ << stats.error_message << \n

BIN
output/20251010_175050/test_stream1.mp4


BIN
output/20251010_175050/test_stream2.mp4


+ 875 - 0
src/http_server.cpp

@@ -0,0 +1,875 @@
+#include "http_server.h"
+#include <iostream>
+#include <fstream>
+#include <sstream>
+#include <filesystem>
+#include <algorithm>
+#include <iomanip>
+#include <ctime>
+
+namespace jtjai_media {
+
+HttpServer::HttpServer(const std::string& output_directory, int port)
+    : output_directory_(output_directory)
+    , port_(port)
+    , is_running_(false) {
+}
+
+HttpServer::~HttpServer() {
+    stop();
+}
+
+bool HttpServer::start() {
+    if (is_running_.load()) {
+        std::cerr << "HTTP服务器已经在运行中" << std::endl;
+        return false;
+    }
+    
+    is_running_.store(true);
+    server_thread_ = std::make_unique<std::thread>(&HttpServer::server_main_loop, this);
+    
+    std::cout << "HTTP服务器已启动,监听端口: " << port_ << std::endl;
+    std::cout << "API端点:" << std::endl;
+    std::cout << "  GET  /api/videos - 列出所有视频" << std::endl;
+    std::cout << "  GET  /api/timestamps - 列出所有时间戳目录" << std::endl;
+    std::cout << "  GET  /api/videos/{timestamp} - 列出指定时间戳目录的视频" << std::endl;
+    std::cout << "  DELETE /api/video?path={path} - 删除指定视频文件" << std::endl;
+    std::cout << "  DELETE /api/timestamp/{timestamp} - 删除指定时间戳目录及其所有文件" << std::endl;
+    std::cout << "Web界面:" << std::endl;
+    std::cout << "  GET  / - 主页" << std::endl;
+    std::cout << "  GET  /manager - 视频管理界面" << std::endl;
+    
+    return true;
+}
+
+void HttpServer::stop() {
+    if (!is_running_.load()) {
+        return;
+    }
+    
+    std::cout << "正在停止HTTP服务器..." << std::endl;
+    is_running_.store(false);
+    
+    if (server_thread_ && server_thread_->joinable()) {
+        server_thread_->join();
+    }
+    
+    std::cout << "HTTP服务器已停止" << std::endl;
+}
+
+void HttpServer::server_main_loop() {
+    try {
+        boost::asio::io_context io_context;
+        boost::asio::ip::tcp::acceptor acceptor(
+            io_context,
+            boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), port_)
+        );
+        
+        acceptor.listen();
+        
+        while (is_running_.load()) {
+            boost::asio::ip::tcp::socket socket(io_context);
+            
+            // 设置接受超时
+            acceptor.non_blocking(true);
+            
+            boost::system::error_code ec;
+            acceptor.accept(socket, ec);
+            
+            if (!ec) {
+                // 在新线程中处理请求
+                std::thread([this](boost::asio::ip::tcp::socket sock) {
+                    handle_client(std::move(sock));
+                }, std::move(socket)).detach();
+            } else if (ec != boost::asio::error::would_block) {
+                std::cerr << "接受连接错误: " << ec.message() << std::endl;
+            }
+            
+            // 短暂休眠避免CPU占用过高
+            std::this_thread::sleep_for(std::chrono::milliseconds(10));
+        }
+        
+    } catch (const std::exception& e) {
+        std::cerr << "HTTP服务器异常: " << e.what() << std::endl;
+    }
+}
+
+void HttpServer::handle_client(boost::asio::ip::tcp::socket socket) {
+    try {
+        // 读取请求
+        boost::asio::streambuf buffer;
+        boost::system::error_code ec;
+        
+        // 读取直到双换行(HTTP请求结束标志)
+        boost::asio::read_until(socket, buffer, "\r\n\r\n", ec);
+        
+        if (ec && ec != boost::asio::error::eof) {
+            std::cerr << "读取请求错误: " << ec.message() << std::endl;
+            return;
+        }
+        
+        // 转换为字符串
+        std::string request_data(
+            boost::asio::buffers_begin(buffer.data()),
+            boost::asio::buffers_begin(buffer.data()) + buffer.size()
+        );
+        
+        // 解析请求
+        HttpRequest request = parse_request(request_data);
+        
+        // 路由处理
+        HttpResponse response = route_request(request);
+        
+        // 构建响应
+        std::string response_data = build_response(response);
+        
+        // 发送响应
+        boost::asio::write(socket, boost::asio::buffer(response_data), ec);
+        
+        if (ec) {
+            std::cerr << "发送响应错误: " << ec.message() << std::endl;
+        }
+        
+        // 关闭连接
+        socket.close();
+        
+    } catch (const std::exception& e) {
+        std::cerr << "处理客户端请求异常: " << e.what() << std::endl;
+    }
+}
+
+HttpRequest HttpServer::parse_request(const std::string& request_data) {
+    HttpRequest request;
+    std::istringstream stream(request_data);
+    std::string line;
+    
+    // 解析请求行
+    if (std::getline(stream, line)) {
+        std::istringstream request_line(line);
+        std::string uri;
+        request_line >> request.method >> uri;
+        
+        // 分离路径和查询字符串
+        size_t query_pos = uri.find('?');
+        if (query_pos != std::string::npos) {
+            request.path = uri.substr(0, query_pos);
+            request.query_string = uri.substr(query_pos + 1);
+        } else {
+            request.path = uri;
+        }
+    }
+    
+    // 解析请求头
+    while (std::getline(stream, line) && line != "\r") {
+        size_t colon_pos = line.find(':');
+        if (colon_pos != std::string::npos) {
+            std::string key = line.substr(0, colon_pos);
+            std::string value = line.substr(colon_pos + 1);
+            // 去除首尾空格
+            value.erase(0, value.find_first_not_of(" \t\r\n"));
+            value.erase(value.find_last_not_of(" \t\r\n") + 1);
+            request.headers[key] = value;
+        }
+    }
+    
+    return request;
+}
+
+std::string HttpServer::build_response(const HttpResponse& response) {
+    std::ostringstream oss;
+    
+    // 状态行
+    oss << "HTTP/1.1 " << response.status_code << " " << response.status_message << "\r\n";
+    
+    // 响应头
+    oss << "Content-Type: " << response.content_type << "\r\n";
+    oss << "Content-Length: " << response.body.length() << "\r\n";
+    oss << "Access-Control-Allow-Origin: *\r\n";
+    oss << "Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\n";
+    oss << "Accept-Ranges: bytes\r\n";  // 支持Range请求
+    oss << "Connection: close\r\n";
+    oss << "\r\n";
+    
+    // 响应体
+    oss << response.body;
+    
+    return oss.str();
+}
+
+HttpResponse HttpServer::route_request(const HttpRequest& request) {
+    std::cout << request.method << " " << request.path;
+    if (!request.query_string.empty()) {
+        std::cout << "?" << request.query_string;
+    }
+    std::cout << std::endl;
+    
+    // OPTIONS请求处理(CORS预检)
+    if (request.method == "OPTIONS") {
+        return HttpResponse(200, "OK", "");
+    }
+    
+    // 路由分发
+    if (request.method == "GET" || request.method == "HEAD") {
+        if (request.path == "/api/videos") {
+            return handle_list_videos(request);
+        } else if (request.path == "/api/timestamps") {
+            return handle_list_timestamps(request);
+        } else if (request.path.find("/api/videos/") == 0) {
+            return handle_get_video_info(request);
+        } else if (request.path.find("/videos/") == 0) {
+            // 处理视频文件访问
+            auto response = handle_video_file(request);
+            // 如果是HEAD请求,只返回头部,不返回体
+            if (request.method == "HEAD") {
+                response.body.clear();
+            }
+            return response;
+        } else {
+            // 处理静态文件访问(HTML、CSS、JS等)
+            return handle_static_file(request);
+        }
+    } else if (request.method == "DELETE") {
+        if (request.path == "/api/video") {
+            return handle_delete_video(request);
+        } else if (request.path.find("/api/timestamp/") == 0) {
+            return handle_delete_timestamp(request);
+        }
+    }
+    
+    // 404未找到
+    boost::json::object error_obj;
+    error_obj["error"] = "Not Found";
+    error_obj["message"] = "请求的资源不存在";
+    return HttpResponse(404, "Not Found", boost::json::serialize(error_obj));
+}
+
+HttpResponse HttpServer::handle_list_videos(const HttpRequest& request) {
+    try {
+        auto query_params = parse_query_string(request.query_string);
+        std::vector<VideoFileInfo> videos;
+        
+        // 检查是否指定了时间戳
+        if (query_params.count("timestamp")) {
+            std::string timestamp = query_params["timestamp"];
+            std::string dir_path = output_directory_ + "/" + timestamp;
+            videos = list_videos_in_directory(dir_path);
+        } else {
+            videos = list_all_videos();
+        }
+        
+        // 构建JSON响应
+        boost::json::array videos_array;
+        for (const auto& video : videos) {
+            boost::json::object video_obj;
+            video_obj["filename"] = video.filename;
+            video_obj["full_path"] = video.full_path;
+            video_obj["timestamp_dir"] = video.timestamp_dir;
+            video_obj["file_size"] = video.file_size;
+            video_obj["created_time"] = video.created_time;
+            video_obj["stream_index"] = video.stream_index;
+            videos_array.push_back(video_obj);
+        }
+        
+        boost::json::object result;
+        result["count"] = videos.size();
+        result["videos"] = videos_array;
+        
+        return HttpResponse(200, "OK", boost::json::serialize(result));
+        
+    } catch (const std::exception& e) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Internal Server Error";
+        error_obj["message"] = e.what();
+        return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+    }
+}
+
+HttpResponse HttpServer::handle_list_timestamps(const HttpRequest& request) {
+    try {
+        auto timestamps = list_timestamp_directories();
+        
+        // 构建JSON响应
+        boost::json::array timestamps_array;
+        for (const auto& timestamp : timestamps) {
+            boost::json::object ts_obj;
+            ts_obj["timestamp"] = timestamp;
+            
+            // 获取该目录下的视频数量
+            std::string dir_path = output_directory_ + "/" + timestamp;
+            auto videos = list_videos_in_directory(dir_path);
+            ts_obj["video_count"] = videos.size();
+            
+            // 计算总大小
+            int64_t total_size = 0;
+            for (const auto& video : videos) {
+                total_size += video.file_size;
+            }
+            ts_obj["total_size"] = total_size;
+            
+            timestamps_array.push_back(ts_obj);
+        }
+        
+        boost::json::object result;
+        result["count"] = timestamps.size();
+        result["timestamps"] = timestamps_array;
+        
+        return HttpResponse(200, "OK", boost::json::serialize(result));
+        
+    } catch (const std::exception& e) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Internal Server Error";
+        error_obj["message"] = e.what();
+        return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+    }
+}
+
+HttpResponse HttpServer::handle_delete_video(const HttpRequest& request) {
+    try {
+        auto query_params = parse_query_string(request.query_string);
+        
+        if (!query_params.count("path")) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Bad Request";
+            error_obj["message"] = "缺少path参数";
+            return HttpResponse(400, "Bad Request", boost::json::serialize(error_obj));
+        }
+        
+        std::string file_path = query_params["path"];
+        
+        // 安全检查:确保文件路径在输出目录内
+        std::filesystem::path abs_path = std::filesystem::absolute(file_path);
+        std::filesystem::path abs_output = std::filesystem::absolute(output_directory_);
+        
+        if (abs_path.string().find(abs_output.string()) != 0) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Forbidden";
+            error_obj["message"] = "无权删除该文件";
+            return HttpResponse(403, "Forbidden", boost::json::serialize(error_obj));
+        }
+        
+        bool success = delete_file(file_path);
+        
+        boost::json::object result;
+        result["success"] = success;
+        result["path"] = file_path;
+        
+        if (success) {
+            result["message"] = "文件删除成功";
+            return HttpResponse(200, "OK", boost::json::serialize(result));
+        } else {
+            result["message"] = "文件删除失败";
+            return HttpResponse(500, "Internal Server Error", boost::json::serialize(result));
+        }
+        
+    } catch (const std::exception& e) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Internal Server Error";
+        error_obj["message"] = e.what();
+        return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+    }
+}
+
+HttpResponse HttpServer::handle_delete_timestamp(const HttpRequest& request) {
+    try {
+        // 从路径中提取时间戳 /api/timestamp/{timestamp}
+        std::string path = request.path;
+        size_t pos = path.find_last_of('/');
+        if (pos == std::string::npos) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Bad Request";
+            error_obj["message"] = "无效的请求路径";
+            return HttpResponse(400, "Bad Request", boost::json::serialize(error_obj));
+        }
+        
+        std::string timestamp = path.substr(pos + 1);
+        std::string dir_path = output_directory_ + "/" + timestamp;
+        
+        // 安全检查
+        std::filesystem::path abs_path = std::filesystem::absolute(dir_path);
+        std::filesystem::path abs_output = std::filesystem::absolute(output_directory_);
+        
+        if (abs_path.string().find(abs_output.string()) != 0) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Forbidden";
+            error_obj["message"] = "无权删除该目录";
+            return HttpResponse(403, "Forbidden", boost::json::serialize(error_obj));
+        }
+        
+        bool success = delete_directory(dir_path);
+        
+        boost::json::object result;
+        result["success"] = success;
+        result["timestamp"] = timestamp;
+        result["path"] = dir_path;
+        
+        if (success) {
+            result["message"] = "目录删除成功";
+            return HttpResponse(200, "OK", boost::json::serialize(result));
+        } else {
+            result["message"] = "目录删除失败";
+            return HttpResponse(500, "Internal Server Error", boost::json::serialize(result));
+        }
+        
+    } catch (const std::exception& e) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Internal Server Error";
+        error_obj["message"] = e.what();
+        return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+    }
+}
+
+HttpResponse HttpServer::handle_get_video_info(const HttpRequest& request) {
+    // 从路径中提取时间戳 /api/videos/{timestamp}
+    std::string path = request.path;
+    size_t pos = path.find_last_of('/');
+    if (pos == std::string::npos) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Bad Request";
+        error_obj["message"] = "无效的请求路径";
+        return HttpResponse(400, "Bad Request", boost::json::serialize(error_obj));
+    }
+    
+    std::string timestamp = path.substr(pos + 1);
+    std::string dir_path = output_directory_ + "/" + timestamp;
+    
+    auto videos = list_videos_in_directory(dir_path);
+    
+    boost::json::array videos_array;
+    for (const auto& video : videos) {
+        boost::json::object video_obj;
+        video_obj["filename"] = video.filename;
+        video_obj["full_path"] = video.full_path;
+        video_obj["timestamp_dir"] = video.timestamp_dir;
+        video_obj["file_size"] = video.file_size;
+        video_obj["created_time"] = video.created_time;
+        video_obj["stream_index"] = video.stream_index;
+        videos_array.push_back(video_obj);
+    }
+    
+    boost::json::object result;
+    result["timestamp"] = timestamp;
+    result["count"] = videos.size();
+    result["videos"] = videos_array;
+    
+    return HttpResponse(200, "OK", boost::json::serialize(result));
+}
+
+HttpResponse HttpServer::handle_video_file(const HttpRequest& request) {
+    try {
+        // 从路径中提取文件路径 /videos/{timestamp}/{filename}
+        std::string path = request.path;
+        size_t pos = path.find("/videos/");
+        if (pos == std::string::npos) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Bad Request";
+            error_obj["message"] = "无效的请求路径";
+            return HttpResponse(400, "Bad Request", boost::json::serialize(error_obj));
+        }
+        
+        // 获取相对路径(URL解码)
+        std::string encoded_path = path.substr(pos + 8); // 跳过 "/videos/"
+        std::string relative_path = url_decode(encoded_path);
+        
+        // 构建完整文件路径
+        std::string file_path = output_directory_ + "/" + relative_path;
+        
+        std::cout << "请求视频文件: " << file_path << std::endl;
+        
+        // 安全检查:确保文件路径在输出目录内
+        std::filesystem::path abs_path = std::filesystem::absolute(file_path);
+        std::filesystem::path abs_output = std::filesystem::absolute(output_directory_);
+        
+        if (abs_path.string().find(abs_output.string()) != 0) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Forbidden";
+            error_obj["message"] = "无权访问该文件";
+            return HttpResponse(403, "Forbidden", boost::json::serialize(error_obj));
+        }
+        
+        // 检查文件是否存在
+        if (!std::filesystem::exists(file_path)) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Not Found";
+            error_obj["message"] = "文件不存在: " + file_path;
+            return HttpResponse(404, "Not Found", boost::json::serialize(error_obj));
+        }
+        
+        // 获取文件大小
+        size_t file_size = std::filesystem::file_size(file_path);
+        
+        // 读取文件内容
+        std::ifstream file(file_path, std::ios::binary);
+        if (!file.is_open()) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Internal Server Error";
+            error_obj["message"] = "无法打开文件";
+            return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+        }
+        
+        // 读取全部文件内容
+        std::string content(file_size, '\0');
+        file.read(&content[0], file_size);
+        file.close();
+        
+        std::cout << "发送视频文件: " << file_path << " (" << file_size << " bytes)" << std::endl;
+        
+        // 构建响应
+        HttpResponse response;
+        response.status_code = 200;
+        response.status_message = "OK";
+        
+        // 根据文件扩展名设置Content-Type
+        std::string ext = std::filesystem::path(file_path).extension().string();
+        if (ext == ".mp4") {
+            response.content_type = "video/mp4";
+        } else if (ext == ".avi") {
+            response.content_type = "video/x-msvideo";
+        } else if (ext == ".mkv") {
+            response.content_type = "video/x-matroska";
+        } else if (ext == ".flv") {
+            response.content_type = "video/x-flv";
+        } else {
+            response.content_type = "application/octet-stream";
+        }
+        
+        response.body = content;
+        
+        return response;
+        
+    } catch (const std::exception& e) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Internal Server Error";
+        error_obj["message"] = e.what();
+        return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+    }
+}
+
+std::vector<std::string> HttpServer::list_timestamp_directories() {
+    std::vector<std::string> timestamps;
+    
+    try {
+        if (!std::filesystem::exists(output_directory_)) {
+            return timestamps;
+        }
+        
+        for (const auto& entry : std::filesystem::directory_iterator(output_directory_)) {
+            if (entry.is_directory()) {
+                timestamps.push_back(entry.path().filename().string());
+            }
+        }
+        
+        // 按时间戳排序(最新的在前)
+        std::sort(timestamps.begin(), timestamps.end(), std::greater<std::string>());
+        
+    } catch (const std::exception& e) {
+        std::cerr << "列出时间戳目录错误: " << e.what() << std::endl;
+    }
+    
+    return timestamps;
+}
+
+std::vector<VideoFileInfo> HttpServer::list_videos_in_directory(const std::string& dir_path) {
+    std::vector<VideoFileInfo> videos;
+    
+    try {
+        if (!std::filesystem::exists(dir_path)) {
+            return videos;
+        }
+        
+        for (const auto& entry : std::filesystem::directory_iterator(dir_path)) {
+            if (entry.is_regular_file()) {
+                std::string filename = entry.path().filename().string();
+                std::string ext = entry.path().extension().string();
+                
+                // 只列出视频文件
+                if (ext == ".mp4" || ext == ".avi" || ext == ".mkv" || ext == ".flv") {
+                    VideoFileInfo info;
+                    info.filename = filename;
+                    info.full_path = entry.path().string();
+                    info.timestamp_dir = std::filesystem::path(dir_path).filename().string();
+                    info.file_size = get_file_size(info.full_path);
+                    info.created_time = get_file_created_time(info.full_path);
+                    
+                    // 从文件名提取流索引
+                    size_t stream_pos = filename.find("stream");
+                    if (stream_pos != std::string::npos) {
+                        size_t num_start = stream_pos + 6;
+                        size_t num_end = filename.find_first_not_of("0123456789", num_start);
+                        if (num_end != std::string::npos) {
+                            info.stream_index = filename.substr(num_start, num_end - num_start);
+                        }
+                    }
+                    
+                    videos.push_back(info);
+                }
+            }
+        }
+        
+        // 按文件名排序
+        std::sort(videos.begin(), videos.end(), 
+            [](const VideoFileInfo& a, const VideoFileInfo& b) {
+                return a.filename < b.filename;
+            });
+        
+    } catch (const std::exception& e) {
+        std::cerr << "列出视频文件错误: " << e.what() << std::endl;
+    }
+    
+    return videos;
+}
+
+std::vector<VideoFileInfo> HttpServer::list_all_videos() {
+    std::vector<VideoFileInfo> all_videos;
+    
+    auto timestamps = list_timestamp_directories();
+    for (const auto& timestamp : timestamps) {
+        std::string dir_path = output_directory_ + "/" + timestamp;
+        auto videos = list_videos_in_directory(dir_path);
+        all_videos.insert(all_videos.end(), videos.begin(), videos.end());
+    }
+    
+    return all_videos;
+}
+
+bool HttpServer::delete_file(const std::string& file_path) {
+    try {
+        if (std::filesystem::exists(file_path)) {
+            std::filesystem::remove(file_path);
+            std::cout << "已删除文件: " << file_path << std::endl;
+            return true;
+        }
+        return false;
+    } catch (const std::exception& e) {
+        std::cerr << "删除文件错误: " << e.what() << std::endl;
+        return false;
+    }
+}
+
+bool HttpServer::delete_directory(const std::string& dir_path) {
+    try {
+        if (std::filesystem::exists(dir_path)) {
+            std::filesystem::remove_all(dir_path);
+            std::cout << "已删除目录: " << dir_path << std::endl;
+            return true;
+        }
+        return false;
+    } catch (const std::exception& e) {
+        std::cerr << "删除目录错误: " << e.what() << std::endl;
+        return false;
+    }
+}
+
+std::string HttpServer::get_file_created_time(const std::string& file_path) {
+    try {
+        auto ftime = std::filesystem::last_write_time(file_path);
+        auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
+            ftime - std::filesystem::file_time_type::clock::now() + std::chrono::system_clock::now()
+        );
+        auto time_t = std::chrono::system_clock::to_time_t(sctp);
+        auto tm = *std::localtime(&time_t);
+        
+        std::ostringstream oss;
+        oss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
+        return oss.str();
+    } catch (const std::exception& e) {
+        return "Unknown";
+    }
+}
+
+int64_t HttpServer::get_file_size(const std::string& file_path) {
+    try {
+        return std::filesystem::file_size(file_path);
+    } catch (const std::exception& e) {
+        return 0;
+    }
+}
+
+std::map<std::string, std::string> HttpServer::parse_query_string(const std::string& query) {
+    std::map<std::string, std::string> params;
+    
+    if (query.empty()) {
+        return params;
+    }
+    
+    std::istringstream stream(query);
+    std::string pair;
+    
+    while (std::getline(stream, pair, '&')) {
+        size_t eq_pos = pair.find('=');
+        if (eq_pos != std::string::npos) {
+            std::string key = pair.substr(0, eq_pos);
+            std::string value = pair.substr(eq_pos + 1);
+            params[key] = value;
+        }
+    }
+    
+    return params;
+}
+
+std::string HttpServer::url_decode(const std::string& str) {
+    std::string result;
+    result.reserve(str.size());
+    
+    for (size_t i = 0; i < str.size(); ++i) {
+        if (str[i] == '%') {
+            if (i + 2 < str.size()) {
+                int value;
+                std::istringstream is(str.substr(i + 1, 2));
+                if (is >> std::hex >> value) {
+                    result += static_cast<char>(value);
+                    i += 2;
+                } else {
+                    result += str[i];
+                }
+            } else {
+                result += str[i];
+            }
+        } else if (str[i] == '+') {
+            result += ' ';
+        } else {
+            result += str[i];
+        }
+    }
+    
+    return result;
+}
+
+HttpResponse HttpServer::handle_static_file(const HttpRequest& request) {
+    try {
+        std::string file_path;
+        std::string content_type = "text/html";
+        
+        // 路由映射
+        if (request.path == "/" || request.path == "/index.html") {
+            // 主页 - 显示系统信息
+            return generate_index_page();
+        } else if (request.path == "/manager" || request.path == "/manager.html") {
+            // 视频管理界面
+            file_path = "video_manager.html";
+            content_type = "text/html";
+        } else if (request.path == "/api" || request.path == "/api.html") {
+            // API文档
+            return generate_api_doc();
+        } else {
+            // 404
+            boost::json::object error_obj;
+            error_obj["error"] = "Not Found";
+            error_obj["message"] = "请求的资源不存在";
+            return HttpResponse(404, "Not Found", boost::json::serialize(error_obj));
+        }
+        
+        // 读取静态文件
+        if (!std::filesystem::exists(file_path)) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Not Found";
+            error_obj["message"] = "文件不存在: " + file_path;
+            return HttpResponse(404, "Not Found", boost::json::serialize(error_obj));
+        }
+        
+        std::ifstream file(file_path);
+        if (!file.is_open()) {
+            boost::json::object error_obj;
+            error_obj["error"] = "Internal Server Error";
+            error_obj["message"] = "无法打开文件";
+            return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+        }
+        
+        std::string content((std::istreambuf_iterator<char>(file)),
+                           std::istreambuf_iterator<char>());
+        file.close();
+        
+        HttpResponse response;
+        response.status_code = 200;
+        response.status_message = "OK";
+        response.content_type = content_type;
+        response.body = content;
+        
+        return response;
+        
+    } catch (const std::exception& e) {
+        boost::json::object error_obj;
+        error_obj["error"] = "Internal Server Error";
+        error_obj["message"] = e.what();
+        return HttpResponse(500, "Internal Server Error", boost::json::serialize(error_obj));
+    }
+}
+
+HttpResponse HttpServer::generate_index_page() {
+    std::string html = R"(
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>RTSP视频流管理系统</title>
+    <style>
+        body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
+        .container { max-width: 800px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
+        h1 { color: #333; text-align: center; }
+        .card { background: #f8f9fa; padding: 20px; margin: 20px 0; border-radius: 8px; }
+        .btn { display: inline-block; padding: 12px 24px; background: #667eea; color: white; text-decoration: none; border-radius: 5px; margin: 10px 10px 10px 0; }
+        .btn:hover { background: #5568d3; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>🌅 RTSP视频流管理系统</h1>
+        <div class="card">
+            <h2>🚀 快速开始</h2>
+            <p>欢迎使用RTSP视频流管理系统!</p>
+            <a href="/manager" class="btn">📹 视频管理</a>
+            <a href="/api" class="btn">📊 API文档</a>
+        </div>
+    </div>
+</body>
+</html>
+    )";
+    
+    HttpResponse response;
+    response.status_code = 200;
+    response.status_message = "OK";
+    response.content_type = "text/html";
+    response.body = html;
+    return response;
+}
+
+HttpResponse HttpServer::generate_api_doc() {
+    std::string html = R"(
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>API文档</title>
+    <style>
+        body { font-family: sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
+        .container { max-width: 1000px; margin: 0 auto; background: white; padding: 40px; border-radius: 10px; }
+        .endpoint { background: #f8f9fa; padding: 20px; margin: 20px 0; border-radius: 8px; }
+        .method { display: inline-block; padding: 4px 8px; border-radius: 4px; color: white; font-weight: bold; margin-right: 10px; }
+        .get { background: #28a745; }
+        .delete { background: #dc3545; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>📊 API文档</h1>
+        <div class="endpoint">
+            <h3><span class="method get">GET</span>/api/videos</h3>
+            <p>列出所有视频文件</p>
+        </div>
+        <div class="endpoint">
+            <h3><span class="method delete">DELETE</span>/api/video</h3>
+            <p>删除指定视频文件</p>
+        </div>
+    </div>
+</body>
+</html>
+    )";
+    
+    HttpResponse response;
+    response.status_code = 200;
+    response.status_message = "OK";
+    response.content_type = "text/html";
+    response.body = html;
+    return response;
+}
+
+} // namespace jtjai_media

+ 75 - 0
test_http_api.sh

@@ -0,0 +1,75 @@
+#!/bin/bash
+
+# HTTP服务器测试脚本
+
+BASE_URL="http://localhost:8080"
+
+echo "=========================================="
+echo "RTSP视频流管理HTTP服务器 API测试"
+echo "=========================================="
+echo ""
+
+# 颜色定义
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# 测试函数
+test_api() {
+    local method=$1
+    local endpoint=$2
+    local description=$3
+    
+    echo -e "${YELLOW}测试: ${description}${NC}"
+    echo "请求: ${method} ${endpoint}"
+    echo "响应:"
+    
+    if [ "$method" == "GET" ]; then
+        curl -s -w "\nHTTP状态码: %{http_code}\n" "${BASE_URL}${endpoint}" | jq . 2>/dev/null || curl -s -w "\nHTTP状态码: %{http_code}\n" "${BASE_URL}${endpoint}"
+    elif [ "$method" == "DELETE" ]; then
+        curl -s -X DELETE -w "\nHTTP状态码: %{http_code}\n" "${BASE_URL}${endpoint}" | jq . 2>/dev/null || curl -s -X DELETE -w "\nHTTP状态码: %{http_code}\n" "${BASE_URL}${endpoint}"
+    fi
+    
+    echo ""
+    echo "----------------------------------------"
+    echo ""
+}
+
+# 检查服务器是否运行
+echo "检查服务器状态..."
+if ! curl -s "${BASE_URL}/api/timestamps" > /dev/null 2>&1; then
+    echo -e "${RED}错误: HTTP服务器未运行或无法连接${NC}"
+    echo "请先启动服务器: ./cmake-build-debug/jtjai_http_server"
+    exit 1
+fi
+
+echo -e "${GREEN}服务器已就绪${NC}"
+echo ""
+
+# 1. 列出所有时间戳目录
+test_api "GET" "/api/timestamps" "列出所有时间戳目录"
+
+# 2. 列出所有视频
+test_api "GET" "/api/videos" "列出所有视频"
+
+# 3. 列出指定时间戳的视频(需要根据实际情况修改)
+# 先获取第一个时间戳
+FIRST_TIMESTAMP=$(curl -s "${BASE_URL}/api/timestamps" | jq -r '.timestamps[0].timestamp' 2>/dev/null)
+
+if [ ! -z "$FIRST_TIMESTAMP" ] && [ "$FIRST_TIMESTAMP" != "null" ]; then
+    echo -e "${YELLOW}找到时间戳: ${FIRST_TIMESTAMP}${NC}"
+    test_api "GET" "/api/videos/${FIRST_TIMESTAMP}" "列出时间戳 ${FIRST_TIMESTAMP} 的视频"
+    test_api "GET" "/api/videos?timestamp=${FIRST_TIMESTAMP}" "列出时间戳 ${FIRST_TIMESTAMP} 的视频(查询参数方式)"
+fi
+
+echo ""
+echo -e "${GREEN}测试完成!${NC}"
+echo ""
+echo "删除操作测试(注释掉以避免误删):"
+echo "# 删除单个视频:"
+echo "# curl -X DELETE \"${BASE_URL}/api/video?path=./output/TIMESTAMP/filename.mp4\""
+echo ""
+echo "# 删除整个时间戳目录:"
+echo "# curl -X DELETE \"${BASE_URL}/api/timestamp/TIMESTAMP\""
+echo ""

+ 687 - 0
video_manager.html

@@ -0,0 +1,687 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>RTSP视频流管理系统</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1400px;
+            margin: 0 auto;
+        }
+
+        .header {
+            background: white;
+            padding: 30px;
+            border-radius: 10px;
+            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
+            margin-bottom: 30px;
+        }
+
+        h1 {
+            color: #333;
+            margin-bottom: 10px;
+        }
+
+        .subtitle {
+            color: #666;
+            font-size: 14px;
+        }
+
+        .stats {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+            gap: 20px;
+            margin-bottom: 30px;
+        }
+
+        .stat-card {
+            background: white;
+            padding: 20px;
+            border-radius: 10px;
+            box-shadow: 0 5px 15px rgba(0,0,0,0.1);
+        }
+
+        .stat-card h3 {
+            color: #666;
+            font-size: 14px;
+            margin-bottom: 10px;
+        }
+
+        .stat-card .value {
+            color: #333;
+            font-size: 32px;
+            font-weight: bold;
+        }
+
+        .content {
+            background: white;
+            padding: 30px;
+            border-radius: 10px;
+            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
+        }
+
+        .tabs {
+            display: flex;
+            gap: 10px;
+            margin-bottom: 20px;
+            border-bottom: 2px solid #eee;
+        }
+
+        .tab {
+            padding: 10px 20px;
+            background: none;
+            border: none;
+            cursor: pointer;
+            font-size: 16px;
+            color: #666;
+            transition: all 0.3s;
+        }
+
+        .tab.active {
+            color: #667eea;
+            border-bottom: 2px solid #667eea;
+            margin-bottom: -2px;
+        }
+
+        .tab-content {
+            display: none;
+        }
+
+        .tab-content.active {
+            display: block;
+        }
+
+        .timestamp-list {
+            display: grid;
+            gap: 15px;
+        }
+
+        .timestamp-item {
+            background: #f8f9fa;
+            padding: 20px;
+            border-radius: 8px;
+            border-left: 4px solid #667eea;
+        }
+
+        .timestamp-header {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 10px;
+        }
+
+        .timestamp-name {
+            font-size: 18px;
+            font-weight: bold;
+            color: #333;
+        }
+
+        .timestamp-info {
+            color: #666;
+            font-size: 14px;
+        }
+
+        .btn {
+            padding: 8px 16px;
+            border: none;
+            border-radius: 5px;
+            cursor: pointer;
+            font-size: 14px;
+            transition: all 0.3s;
+        }
+
+        .btn-primary {
+            background: #667eea;
+            color: white;
+        }
+
+        .btn-primary:hover {
+            background: #5568d3;
+        }
+
+        .btn-danger {
+            background: #dc3545;
+            color: white;
+        }
+
+        .btn-danger:hover {
+            background: #c82333;
+        }
+
+        .btn-success {
+            background: #28a745;
+            color: white;
+        }
+
+        .btn-success:hover {
+            background: #218838;
+        }
+
+        .video-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+            gap: 20px;
+            margin-top: 15px;
+        }
+
+        .video-card {
+            background: white;
+            border: 1px solid #ddd;
+            border-radius: 8px;
+            padding: 15px;
+            transition: all 0.3s;
+        }
+
+        .video-card:hover {
+            box-shadow: 0 5px 15px rgba(0,0,0,0.1);
+        }
+
+        .video-name {
+            font-weight: bold;
+            color: #333;
+            margin-bottom: 8px;
+        }
+
+        .video-info {
+            font-size: 12px;
+            color: #666;
+            margin-bottom: 5px;
+        }
+
+        .loading {
+            text-align: center;
+            padding: 40px;
+            color: #666;
+        }
+
+        .error {
+            background: #f8d7da;
+            color: #721c24;
+            padding: 15px;
+            border-radius: 5px;
+            margin-bottom: 20px;
+        }
+
+        .success {
+            background: #d4edda;
+            color: #155724;
+            padding: 15px;
+            border-radius: 5px;
+            margin-bottom: 20px;
+        }
+
+        .refresh-btn {
+            float: right;
+            margin-bottom: 20px;
+        }
+
+        /* 视频播放器样式 */
+        .video-modal {
+            display: none;
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            background: rgba(0, 0, 0, 0.9);
+            z-index: 1000;
+            justify-content: center;
+            align-items: center;
+        }
+
+        .video-modal.active {
+            display: flex;
+        }
+
+        .video-modal-content {
+            background: white;
+            border-radius: 10px;
+            padding: 20px;
+            max-width: 90%;
+            max-height: 90%;
+            position: relative;
+        }
+
+        .video-modal-header {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 15px;
+        }
+
+        .video-modal-title {
+            font-size: 18px;
+            font-weight: bold;
+            color: #333;
+        }
+
+        .close-btn {
+            background: #dc3545;
+            color: white;
+            border: none;
+            border-radius: 50%;
+            width: 35px;
+            height: 35px;
+            cursor: pointer;
+            font-size: 20px;
+            line-height: 1;
+            transition: all 0.3s;
+        }
+
+        .close-btn:hover {
+            background: #c82333;
+            transform: rotate(90deg);
+        }
+
+        .video-player {
+            width: 100%;
+            max-width: 1200px;
+            max-height: 70vh;
+            background: #000;
+            border-radius: 5px;
+        }
+
+        .video-info-panel {
+            margin-top: 15px;
+            padding: 15px;
+            background: #f8f9fa;
+            border-radius: 5px;
+        }
+
+        .video-info-item {
+            display: flex;
+            justify-content: space-between;
+            margin-bottom: 8px;
+            font-size: 14px;
+        }
+
+        .video-info-label {
+            color: #666;
+            font-weight: bold;
+        }
+
+        .video-info-value {
+            color: #333;
+        }
+
+        .btn-play {
+            background: #28a745;
+            color: white;
+            margin-right: 5px;
+        }
+
+        .btn-play:hover {
+            background: #218838;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>🎥 RTSP视频流管理系统</h1>
+            <p class="subtitle">查看和管理您的视频流录制文件</p>
+        </div>
+
+        <div class="stats" id="stats">
+            <div class="stat-card">
+                <h3>时间戳目录</h3>
+                <div class="value" id="timestamp-count">-</div>
+            </div>
+            <div class="stat-card">
+                <h3>视频总数</h3>
+                <div class="value" id="video-count">-</div>
+            </div>
+            <div class="stat-card">
+                <h3>总大小</h3>
+                <div class="value" id="total-size">-</div>
+            </div>
+        </div>
+
+        <div class="content">
+            <div id="message"></div>
+            
+            <button class="btn btn-success refresh-btn" onclick="loadData()">🔄 刷新</button>
+            
+            <div class="tabs">
+                <button class="tab active" onclick="switchTab('timestamps')">时间戳目录</button>
+                <button class="tab" onclick="switchTab('videos')">所有视频</button>
+            </div>
+
+            <div id="timestamps-tab" class="tab-content active">
+                <div class="loading">加载中...</div>
+            </div>
+
+            <div id="videos-tab" class="tab-content">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 视频播放器模态框 -->
+    <div id="video-modal" class="video-modal">
+        <div class="video-modal-content">
+            <div class="video-modal-header">
+                <div class="video-modal-title" id="video-title">视频播放</div>
+                <button class="close-btn" onclick="closeVideoPlayer()">×</button>
+            </div>
+            <video id="video-player" class="video-player" controls>
+                您的浏览器不支持视频播放。
+            </video>
+            <div class="video-info-panel" id="video-details">
+                <!-- 视频详细信息将在这里显示 -->
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const API_BASE = 'http://localhost:8080';
+
+        function switchTab(tab) {
+            document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+            document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+            
+            event.target.classList.add('active');
+            document.getElementById(tab + '-tab').classList.add('active');
+        }
+
+        function formatBytes(bytes) {
+            if (bytes === 0) return '0 B';
+            const k = 1024;
+            const sizes = ['B', 'KB', 'MB', 'GB'];
+            const i = Math.floor(Math.log(bytes) / Math.log(k));
+            return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+        }
+
+        function showMessage(message, type = 'success') {
+            const messageDiv = document.getElementById('message');
+            messageDiv.innerHTML = `<div class="${type}">${message}</div>`;
+            setTimeout(() => {
+                messageDiv.innerHTML = '';
+            }, 3000);
+        }
+
+        function playVideo(videoPath, filename, fileSize, createdTime, timestampDir, streamIndex) {
+            const modal = document.getElementById('video-modal');
+            const player = document.getElementById('video-player');
+            const title = document.getElementById('video-title');
+            const details = document.getElementById('video-details');
+
+            // 设置视频标题
+            title.textContent = `▶ ${filename}`;
+
+            // 设置视频源 - 使用相对路径
+            const relativePath = `${timestampDir}/${filename}`;
+            const videoUrl = `${API_BASE}/videos/${encodeURIComponent(relativePath)}`;
+            console.log('设置视频源:', videoUrl);
+            
+            player.src = videoUrl;
+            player.load();
+
+            // 显示视频详细信息
+            details.innerHTML = `
+                <div class="video-info-item">
+                    <span class="video-info-label">📁 文件名:</span>
+                    <span class="video-info-value">${filename}</span>
+                </div>
+                <div class="video-info-item">
+                    <span class="video-info-label">📊 文件大小:</span>
+                    <span class="video-info-value">${formatBytes(fileSize)}</span>
+                </div>
+                <div class="video-info-item">
+                    <span class="video-info-label">🕒 创建时间:</span>
+                    <span class="video-info-value">${createdTime}</span>
+                </div>
+                <div class="video-info-item">
+                    <span class="video-info-label">📂 时间戳目录:</span>
+                    <span class="video-info-value">${timestampDir}</span>
+                </div>
+                <div class="video-info-item">
+                    <span class="video-info-label">📍 流索引:</span>
+                    <span class="video-info-value">${streamIndex}</span>
+                </div>
+                <div class="video-info-item">
+                    <span class="video-info-label">📍 完整路径:</span>
+                    <span class="video-info-value">${videoPath}</span>
+                </div>
+            `;
+
+            // 显示模态框
+            modal.classList.add('active');
+
+            // 尝试自动播放
+            player.play().catch(err => {
+                console.log('自动播放失败,需要用户交互:', err);
+            });
+        }
+
+        function closeVideoPlayer() {
+            const modal = document.getElementById('video-modal');
+            const player = document.getElementById('video-player');
+
+            // 暂停播放
+            player.pause();
+            player.src = '';
+
+            // 隐藏模态框
+            modal.classList.remove('active');
+        }
+
+        // 点击模态框背景关闭
+        document.getElementById('video-modal').addEventListener('click', function(e) {
+            if (e.target === this) {
+                closeVideoPlayer();
+            }
+        });
+
+        // ESC键关闭
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape') {
+                closeVideoPlayer();
+            }
+        });
+
+        async function loadStats() {
+            try {
+                const [timestampsRes, videosRes] = await Promise.all([
+                    fetch(`${API_BASE}/api/timestamps`),
+                    fetch(`${API_BASE}/api/videos`)
+                ]);
+
+                const timestamps = await timestampsRes.json();
+                const videos = await videosRes.json();
+
+                document.getElementById('timestamp-count').textContent = timestamps.count;
+                document.getElementById('video-count').textContent = videos.count;
+
+                const totalSize = videos.videos.reduce((sum, video) => sum + video.file_size, 0);
+                document.getElementById('total-size').textContent = formatBytes(totalSize);
+            } catch (error) {
+                console.error('加载统计数据失败:', error);
+            }
+        }
+
+        async function loadTimestamps() {
+            const container = document.getElementById('timestamps-tab');
+            container.innerHTML = '<div class="loading">加载中...</div>';
+
+            try {
+                const response = await fetch(`${API_BASE}/api/timestamps`);
+                const data = await response.json();
+
+                if (data.count === 0) {
+                    container.innerHTML = '<div class="loading">暂无时间戳目录</div>';
+                    return;
+                }
+
+                let html = '<div class="timestamp-list">';
+                for (const ts of data.timestamps) {
+                    html += `
+                        <div class="timestamp-item">
+                            <div class="timestamp-header">
+                                <div>
+                                    <div class="timestamp-name">📁 ${ts.timestamp}</div>
+                                    <div class="timestamp-info">
+                                        ${ts.video_count} 个视频 | ${formatBytes(ts.total_size)}
+                                    </div>
+                                </div>
+                                <div>
+                                    <button class="btn btn-primary" onclick="viewTimestamp('${ts.timestamp}')">查看</button>
+                                    <button class="btn btn-danger" onclick="deleteTimestamp('${ts.timestamp}')">删除</button>
+                                </div>
+                            </div>
+                            <div id="videos-${ts.timestamp}"></div>
+                        </div>
+                    `;
+                }
+                html += '</div>';
+                container.innerHTML = html;
+            } catch (error) {
+                container.innerHTML = `<div class="error">加载失败: ${error.message}</div>`;
+            }
+        }
+
+        async function viewTimestamp(timestamp) {
+            const container = document.getElementById(`videos-${timestamp}`);
+            
+            if (container.innerHTML) {
+                container.innerHTML = '';
+                return;
+            }
+
+            container.innerHTML = '<div class="loading">加载中...</div>';
+
+            try {
+                const response = await fetch(`${API_BASE}/api/videos/${timestamp}`);
+                const data = await response.json();
+
+                let html = '<div class="video-grid">';
+                for (const video of data.videos) {
+                    html += `
+                        <div class="video-card">
+                            <div class="video-name">🎬 ${video.filename}</div>
+                            <div class="video-info">📊 大小: ${formatBytes(video.file_size)}</div>
+                            <div class="video-info">🕒 创建时间: ${video.created_time}</div>
+                            <div class="video-info">📍 流索引: ${video.stream_index}</div>
+                            <div style="margin-top: 10px; display: flex; gap: 5px;">
+                                <button class="btn btn-play" style="flex: 1;" 
+                                        onclick="playVideo('${video.full_path}', '${video.filename}', ${video.file_size}, '${video.created_time}', '${video.timestamp_dir}', '${video.stream_index}')">▶ 播放</button>
+                                <button class="btn btn-danger" style="flex: 1;" 
+                                        onclick="deleteVideo('${video.full_path}')">删除</button>
+                            </div>
+                        </div>
+                    `;
+                }
+                html += '</div>';
+                container.innerHTML = html;
+            } catch (error) {
+                container.innerHTML = `<div class="error">加载失败: ${error.message}</div>`;
+            }
+        }
+
+        async function loadAllVideos() {
+            const container = document.getElementById('videos-tab');
+            container.innerHTML = '<div class="loading">加载中...</div>';
+
+            try {
+                const response = await fetch(`${API_BASE}/api/videos`);
+                const data = await response.json();
+
+                if (data.count === 0) {
+                    container.innerHTML = '<div class="loading">暂无视频文件</div>';
+                    return;
+                }
+
+                let html = '<div class="video-grid">';
+                for (const video of data.videos) {
+                    html += `
+                        <div class="video-card">
+                            <div class="video-name">🎬 ${video.filename}</div>
+                            <div class="video-info">📁 目录: ${video.timestamp_dir}</div>
+                            <div class="video-info">📊 大小: ${formatBytes(video.file_size)}</div>
+                            <div class="video-info">🕒 创建时间: ${video.created_time}</div>
+                            <div class="video-info">📍 流索引: ${video.stream_index}</div>
+                            <div style="margin-top: 10px; display: flex; gap: 5px;">
+                                <button class="btn btn-play" style="flex: 1;" 
+                                        onclick="playVideo('${video.full_path}', '${video.filename}', ${video.file_size}, '${video.created_time}', '${video.timestamp_dir}', '${video.stream_index}')">▶ 播放</button>
+                                <button class="btn btn-danger" style="flex: 1;" 
+                                        onclick="deleteVideo('${video.full_path}')">删除</button>
+                            </div>
+                        </div>
+                    `;
+                }
+                html += '</div>';
+                container.innerHTML = html;
+            } catch (error) {
+                container.innerHTML = `<div class="error">加载失败: ${error.message}</div>`;
+            }
+        }
+
+        async function deleteVideo(path) {
+            if (!confirm(`确定要删除视频文件吗?\n${path}`)) {
+                return;
+            }
+
+            try {
+                const response = await fetch(`${API_BASE}/api/video?path=${encodeURIComponent(path)}`, {
+                    method: 'DELETE'
+                });
+                const data = await response.json();
+
+                if (data.success) {
+                    showMessage('视频删除成功', 'success');
+                    loadData();
+                } else {
+                    showMessage('视频删除失败: ' + data.message, 'error');
+                }
+            } catch (error) {
+                showMessage('删除失败: ' + error.message, 'error');
+            }
+        }
+
+        async function deleteTimestamp(timestamp) {
+            if (!confirm(`确定要删除整个时间戳目录吗?\n${timestamp}\n\n这将删除该目录下的所有文件!`)) {
+                return;
+            }
+
+            try {
+                const response = await fetch(`${API_BASE}/api/timestamp/${timestamp}`, {
+                    method: 'DELETE'
+                });
+                const data = await response.json();
+
+                if (data.success) {
+                    showMessage('目录删除成功', 'success');
+                    loadData();
+                } else {
+                    showMessage('目录删除失败: ' + data.message, 'error');
+                }
+            } catch (error) {
+                showMessage('删除失败: ' + error.message, 'error');
+            }
+        }
+
+        function loadData() {
+            loadStats();
+            loadTimestamps();
+            loadAllVideos();
+        }
+
+        // 页面加载时初始化
+        window.onload = loadData;
+    </script>
+</body>
+</html>

+ 27 - 0
video_test.html

@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>视频播放测试</title>
+</head>
+<body>
+    <h1>视频播放测试</h1>
+    
+    <video width="640" height="480" controls>
+        <source src="http://localhost:8080/videos/20251010_160000/test_stream1.mp4" type="video/mp4">
+        您的浏览器不支持视频播放。
+    </video>
+    
+    <script>
+        const video = document.querySelector('video');
+        video.addEventListener('error', (e) => {
+            console.log('视频加载错误:', e);
+        });
+        video.addEventListener('loadstart', () => {
+            console.log('开始加载视频');
+        });
+        video.addEventListener('canplay', () => {
+            console.log('视频可以播放');
+        });
+    </script>
+</body>
+</html>