feat(数据库): 添加MySQL和SQL Server数据库支持并实现摄像头管理功能

实现数据库集成功能,支持MySQL和SQL Server作为后端存储。主要变更包括:
1. 新增数据库配置结构和初始化逻辑
2. 添加摄像头管理API接口和Web页面
3. 实现流配置与数据库的同步机制
4. 新增数据库初始化SQL脚本和文档
5. 优化日志系统和启动流程

同时更新了前端界面,完成中文本地化适配,并添加了相关API文档。
ziyun-rtsp-web
ytx@queuingsystem.cn 8 months ago
parent fe342c3ed2
commit 2a37fbf7be

@ -0,0 +1,23 @@
{
// 使 IntelliSense
//
// 访: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
},
{
"name": "Launch Test",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${fileDirname}"
}
]
}

@ -0,0 +1,275 @@
# 数据库集成功能说明
本项目已扩展支持MySQL和SQL Server数据库用于管理摄像头信息实现动态配置和实时预览功能。
## 功能特性
- ✅ 支持MySQL和SQL Server数据库
- ✅ 摄像头信息的增删改查
- ✅ Web界面管理摄像头
- ✅ 实时预览新增摄像头
- ✅ 数据库与配置文件双模式支持
- ✅ 自动数据库表创建
- ✅ 数据库连接状态监控
## 配置说明
### 1. 配置文件设置
`config.json` 中添加数据库配置:
```json
{
"server": {
"database_enabled": true,
// ... 其他服务器配置
},
"database": {
"enabled": true,
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "password",
"database": "rtsp_cameras",
"table_name": "cameras"
}
}
```
### 2. 数据库类型配置
#### MySQL配置
```json
"database": {
"enabled": true,
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "password",
"database": "rtsp_cameras",
"table_name": "cameras"
}
```
#### SQL Server配置
```json
"database": {
"enabled": true,
"type": "sqlserver",
"host": "localhost",
"port": 1433,
"username": "sa",
"password": "YourPassword123",
"database": "rtsp_cameras",
"table_name": "cameras"
}
```
## 数据库初始化
### 1. 使用提供的SQL脚本
执行 `database_init.sql` 文件来创建数据库和表:
```bash
# MySQL
mysql -u root -p < database_init.sql
# SQL Server
sqlcmd -S localhost -U sa -P YourPassword123 -i database_init.sql
```
### 2. 手动创建MySQL
```sql
CREATE DATABASE rtsp_cameras CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE rtsp_cameras;
CREATE TABLE cameras (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
ip VARCHAR(45) NOT NULL,
username VARCHAR(100) DEFAULT NULL,
password VARCHAR(255) DEFAULT NULL,
rtsp_url VARCHAR(500) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### 3. 手动创建SQL Server
```sql
CREATE DATABASE rtsp_cameras;
GO
USE rtsp_cameras;
GO
CREATE TABLE cameras (
id NVARCHAR(36) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
ip NVARCHAR(45) NOT NULL,
username NVARCHAR(100) NULL,
password NVARCHAR(255) NULL,
rtsp_url NVARCHAR(500) NOT NULL,
enabled BIT DEFAULT 1,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE()
);
```
## API接口
### 摄像头管理接口
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/cameras` | 获取所有摄像头列表 |
| POST | `/camera/add` | 添加新摄像头 |
| GET | `/camera/{id}` | 获取单个摄像头信息 |
| PUT | `/camera/{id}` | 更新摄像头信息 |
| DELETE | `/camera/{id}` | 删除摄像头 |
| POST | `/cameras/refresh` | 刷新摄像头列表 |
| GET | `/database/status` | 获取数据库状态 |
### 请求示例
#### 添加摄像头
```bash
curl -X POST http://localhost:8083/camera/add \
-H "Content-Type: application/json" \
-d '{
"name": "测试摄像头",
"ip": "192.168.1.100",
"username": "admin",
"password": "admin123",
"rtsp_url": "rtsp://192.168.1.100:554/stream1",
"enabled": true
}'
```
#### 更新摄像头
```bash
curl -X PUT http://localhost:8083/camera/{camera_id} \
-H "Content-Type: application/json" \
-d '{
"name": "更新的摄像头名称",
"ip": "192.168.1.101",
"username": "admin",
"password": "newpassword",
"rtsp_url": "rtsp://192.168.1.101:554/stream1",
"enabled": true
}'
```
## Web界面使用
### 1. 访问摄像头管理页面
打开浏览器访问:`http://localhost:8083/pages/cameras`
### 2. 功能说明
- **摄像头列表**:显示所有摄像头的基本信息和状态
- **添加摄像头**:点击"添加摄像头"按钮,填写摄像头信息
- **编辑摄像头**:点击摄像头卡片上的"编辑"按钮
- **删除摄像头**:点击摄像头卡片上的"删除"按钮
- **实时预览**:点击摄像头卡片上的"预览"按钮
- **刷新列表**:点击"刷新"按钮重新加载摄像头列表
- **数据库状态**:页面顶部显示数据库连接状态
## 运行模式
### 1. 数据库模式
`database.enabled = true` 时:
- 摄像头信息从数据库加载
- 所有配置变更保存到数据库
- 配置文件中的streams配置被忽略
### 2. 配置文件模式
`database.enabled = false` 时:
- 摄像头信息从config.json加载
- 配置变更保存到配置文件
- 数据库功能不可用
### 3. 混合模式
可以在运行时动态切换模式,但建议重启应用以确保配置生效。
## 依赖包
项目添加了以下Go依赖包
```go
// MySQL驱动
github.com/go-sql-driver/mysql v1.7.1
// SQL Server驱动
github.com/denisenkom/go-mssqldb v0.12.3
// UUID生成
github.com/google/uuid v1.3.0
```
## 安装依赖
```bash
go mod tidy
```
## 编译运行
```bash
# 编译
go build -o rtsp-to-web
# 运行
./rtsp-to-web -config config.json
```
## 故障排除
### 1. 数据库连接失败
- 检查数据库服务是否运行
- 验证连接参数(主机、端口、用户名、密码)
- 确认数据库存在
- 检查防火墙设置
### 2. 表不存在错误
- 运行数据库初始化脚本
- 检查数据库权限
- 确认表名配置正确
### 3. 摄像头无法预览
- 检查RTSP URL是否正确
- 验证摄像头网络连接
- 确认用户名密码正确
- 检查摄像头是否支持RTSP协议
### 4. Web界面无法访问
- 确认HTTP服务端口配置
- 检查防火墙设置
- 验证demo模式是否启用
## 注意事项
1. **密码安全**:数据库中的密码以明文存储,生产环境建议加密
2. **并发访问**:多个实例同时访问数据库时注意数据一致性
3. **备份恢复**:定期备份数据库数据
4. **性能优化**:大量摄像头时考虑数据库索引优化
5. **网络安全**生产环境建议使用HTTPS和数据库SSL连接
## 更新日志
- v1.0.0: 初始版本支持MySQL和SQL Server
- 添加Web界面管理功能
- 支持实时预览和动态配置

Binary file not shown.

@ -10,6 +10,9 @@ import (
)
func main() {
println("Starting RTSPtoWeb server...")
// Set correct log level after Storage initialization
SetLogLevel()
log.WithFields(logrus.Fields{
"module": "main",
"func": "main",

@ -0,0 +1,415 @@
package main
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
// CameraRequest 摄像头请求结构
type CameraRequest struct {
Name string `json:"name" binding:"required"`
IP string `json:"ip" binding:"required"`
Username string `json:"username"`
Password string `json:"password"`
RTSPURL string `json:"rtsp_url" binding:"required"`
Enabled bool `json:"enabled"`
}
// CameraResponse 摄像头响应结构
type CameraResponse struct {
ID string `json:"id"`
Name string `json:"name"`
IP string `json:"ip"`
Username string `json:"username"`
Password string `json:"password,omitempty"` // 密码在响应中可选
RTSPURL string `json:"rtsp_url"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// HTTPAPIServerCameras 获取所有摄像头列表
func HTTPAPIServerCameras(c *gin.Context) {
if !Storage.Server.DatabaseEnabled {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Database is not enabled",
})
return
}
if Storage.dbManager == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Database manager not initialized",
})
return
}
cameras, err := Storage.dbManager.GetAllCameras()
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameras",
"call": "GetAllCameras",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
// 转换为响应格式
var response []CameraResponse
for _, camera := range cameras {
status := "offline"
if stream, exists := Storage.Streams[camera.ID]; exists {
if len(stream.Channels) > 0 {
for _, channel := range stream.Channels {
if channel.runLock {
status = "online"
break
}
}
}
}
response = append(response, CameraResponse{
ID: camera.ID,
Name: camera.Name,
IP: camera.IP,
Username: camera.Username,
// Password: camera.Password, // 不返回密码
RTSPURL: camera.RTSPURL,
Enabled: camera.Enabled,
Status: status,
CreatedAt: camera.CreatedAt,
UpdatedAt: camera.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"cameras": response,
"count": len(response),
})
}
// HTTPAPIServerCameraAdd 添加新摄像头
func HTTPAPIServerCameraAdd(c *gin.Context) {
if !Storage.Server.DatabaseEnabled {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Database is not enabled",
})
return
}
var req CameraRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
// 生成新的UUID
cameraID := uuid.New().String()
// 创建摄像头记录
camera := Camera{
ID: cameraID,
Name: req.Name,
IP: req.IP,
Username: req.Username,
Password: req.Password,
RTSPURL: req.RTSPURL,
Enabled: req.Enabled,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 保存到数据库
err := Storage.dbManager.CreateCamera(&camera)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraAdd",
"call": "CreateCamera",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
// 转换为流配置并添加到内存
stream := CameraToStream(camera)
err = Storage.StreamAdd(cameraID, stream)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraAdd",
"call": "StreamAdd",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Camera added successfully",
"camera_id": cameraID,
})
}
// HTTPAPIServerCameraUpdate 更新摄像头
func HTTPAPIServerCameraUpdate(c *gin.Context) {
if !Storage.Server.DatabaseEnabled {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Database is not enabled",
})
return
}
cameraID := c.Param("uuid")
if cameraID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Camera ID is required",
})
return
}
var req CameraRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
// 检查摄像头是否存在
existingCamera, err := Storage.dbManager.GetCameraByID(cameraID)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraUpdate",
"call": "GetCameraByID",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if existingCamera == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Camera not found",
})
return
}
// 更新摄像头信息
updatedCamera := Camera{
ID: cameraID,
Name: req.Name,
IP: req.IP,
Username: req.Username,
Password: req.Password,
RTSPURL: req.RTSPURL,
Enabled: req.Enabled,
CreatedAt: existingCamera.CreatedAt,
UpdatedAt: time.Now(),
}
// 更新数据库
err = Storage.dbManager.UpdateCamera(&updatedCamera)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraUpdate",
"call": "UpdateCamera",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
// 更新内存中的流配置
stream := CameraToStream(updatedCamera)
err = Storage.StreamEdit(cameraID, stream)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraUpdate",
"call": "StreamEdit",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Camera updated successfully",
})
}
// HTTPAPIServerCameraDelete 删除摄像头
func HTTPAPIServerCameraDelete(c *gin.Context) {
if !Storage.Server.DatabaseEnabled {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Database is not enabled",
})
return
}
cameraID := c.Param("uuid")
if cameraID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Camera ID is required",
})
return
}
// 检查摄像头是否存在
existingCamera, err := Storage.dbManager.GetCameraByID(cameraID)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraDelete",
"call": "GetCameraByID",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if existingCamera == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Camera not found",
})
return
}
// 从内存中删除流
err = Storage.StreamDelete(cameraID)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraDelete",
"call": "StreamDelete",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Camera deleted successfully",
})
}
// HTTPAPIServerCameraGet 获取单个摄像头信息
func HTTPAPIServerCameraGet(c *gin.Context) {
if !Storage.Server.DatabaseEnabled {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Database is not enabled",
})
return
}
cameraID := c.Param("uuid")
if cameraID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Camera ID is required",
})
return
}
camera, err := Storage.dbManager.GetCameraByID(cameraID)
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraGet",
"call": "GetCameraByID",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if camera == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Camera not found",
})
return
}
// 检查流状态
status := "offline"
if stream, exists := Storage.Streams[camera.ID]; exists {
if len(stream.Channels) > 0 {
for _, channel := range stream.Channels {
if channel.runLock {
status = "online"
break
}
}
}
}
response := CameraResponse{
ID: camera.ID,
Name: camera.Name,
IP: camera.IP,
Username: camera.Username,
// Password: camera.Password, // 不返回密码
RTSPURL: camera.RTSPURL,
Enabled: camera.Enabled,
Status: status,
CreatedAt: camera.CreatedAt,
UpdatedAt: camera.UpdatedAt,
}
c.JSON(http.StatusOK, response)
}
// HTTPAPIServerCameraRefresh 刷新摄像头列表(从数据库重新加载)
func HTTPAPIServerCameraRefresh(c *gin.Context) {
if !Storage.Server.DatabaseEnabled {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Database is not enabled",
})
return
}
err := Storage.RefreshStreamsFromDatabase()
if err != nil {
log.WithFields(logrus.Fields{
"module": "api",
"func": "HTTPAPIServerCameraRefresh",
"call": "RefreshStreamsFromDatabase",
}).Errorln(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Cameras refreshed successfully",
})
}
// HTTPAPIServerDatabaseStatus 获取数据库状态
func HTTPAPIServerDatabaseStatus(c *gin.Context) {
status := Storage.GetDatabaseStatus()
c.JSON(http.StatusOK, status)
}

@ -0,0 +1,14 @@
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HTTPAPICameraManagement 摄像头管理页面
func HTTPAPICameraManagement(c *gin.Context) {
c.HTML(http.StatusOK, "cameras.tmpl", gin.H{
"title": "摄像头管理",
})
}

@ -1,12 +1,18 @@
package main
import (
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/autotls"
"github.com/gin-gonic/gin"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/sirupsen/logrus"
)
@ -57,7 +63,9 @@ func HTTPAPIServer() {
public.Any("/pages/multiview/full", HTTPAPIFullScreenMultiView)
public.GET("/pages/documentation", HTTPAPIServerDocumentation)
public.GET("/pages/player/all/:uuid/:channel", HTTPAPIPlayAll)
public.GET("/pages/cameras", HTTPAPICameraManagement)
public.StaticFS("/static", http.Dir(Storage.ServerHTTPDir()+"/static"))
public.GET("/docs/*filepath", HTTPAPIMarkdownRenderer)
}
/*
@ -78,6 +86,18 @@ func HTTPAPIServer() {
privat.POST("/streams/multi/control/add", HTTPAPIServerStreamsMultiControlAdd)
privat.POST("/streams/multi/control/delete", HTTPAPIServerStreamsMultiControlDelete)
/*
Camera Management elements
*/
privat.GET("/cameras", HTTPAPIServerCameras)
privat.POST("/camera/add", HTTPAPIServerCameraAdd)
privat.GET("/camera/:uuid", HTTPAPIServerCameraGet)
privat.PUT("/camera/:uuid", HTTPAPIServerCameraUpdate)
privat.DELETE("/camera/:uuid", HTTPAPIServerCameraDelete)
privat.POST("/cameras/refresh", HTTPAPIServerCameraRefresh)
privat.GET("/database/status", HTTPAPIServerDatabaseStatus)
/*
Stream Channel elements
*/
@ -286,6 +306,83 @@ func HTTPAPIFullScreenMultiView(c *gin.Context) {
})
}
// HTTPAPIMarkdownRenderer renders markdown files as HTML
func HTTPAPIMarkdownRenderer(c *gin.Context) {
filePath := c.Param("filepath")
// Remove leading slash
if strings.HasPrefix(filePath, "/") {
filePath = filePath[1:]
}
// Construct full file path
fullPath := filepath.Join("./docs", filePath)
// Check if file exists and has .md extension
if !strings.HasSuffix(filePath, ".md") {
c.String(http.StatusNotFound, "只支持 .md 文件")
return
}
// Read markdown file
markdownBytes, err := ioutil.ReadFile(fullPath)
if err != nil {
c.String(http.StatusNotFound, "文件未找到")
return
}
// Create markdown parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse(markdownBytes)
// Create HTML renderer with extensions
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
// Render markdown to HTML
htmlContent := markdown.Render(doc, renderer)
// Create HTML content by concatenating strings
htmlStart := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API </title>
<link rel="stylesheet" href="/static/plugins/bootstrap/css/bootstrap.min.css">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.markdown-body { line-height: 1.6; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { color: #333; margin-top: 24px; margin-bottom: 16px; }
.markdown-body code { background-color: #f6f8fa; padding: 2px 4px; border-radius: 3px; }
.markdown-body pre { background-color: #f6f8fa; padding: 16px; border-radius: 6px; overflow: auto; }
.markdown-body table { border-collapse: collapse; width: 100%; }
.markdown-body th, .markdown-body td { border: 1px solid #dfe2e5; padding: 6px 13px; }
.markdown-body th { background-color: #f6f8fa; font-weight: 600; }
</style>
</head>
<body>
<div class="container">
<nav class="mb-4">
<a href="/" class="btn btn-secondary"></a>
<a href="/pages/documentation" class="btn btn-secondary ml-2"></a>
</nav>
<div class="markdown-body">`
htmlEnd := ` </div>
</div>
</body>
</html>`
// Combine HTML parts with markdown content
finalHTML := htmlStart + string(htmlContent) + htmlEnd
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, finalHTML)
}
// CrossOrigin Access-Control-Allow-Origin any methods
func CrossOrigin() gin.HandlerFunc {
return func(c *gin.Context) {

@ -23,6 +23,16 @@
"audio": true
}
},
"database": {
"enabled": false,
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "password",
"database": "rtsp_cameras",
"table_name": "cameras"
},
"streams": {
"27aec28e-6181-4753-9acd-0456a75f0289": {
"channels": {

@ -0,0 +1,271 @@
package main
import (
"database/sql"
"fmt"
"time"
_ "github.com/denisenkom/go-mssqldb"
_ "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
)
// DatabaseConfig 数据库配置结构
type DatabaseConfig struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // mysql 或 sqlserver
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
SSLMode string `json:"ssl_mode,omitempty"` // for mysql
}
// Camera 摄像头数据库模型
type Camera struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
IP string `json:"ip" db:"ip"`
Username string `json:"username" db:"username"`
Password string `json:"password" db:"password"`
RTSPURL string `json:"rtsp_url" db:"rtsp_url"`
Enabled bool `json:"enabled" db:"enabled"`
OnDemand bool `json:"on_demand" db:"on_demand"`
Audio bool `json:"audio" db:"audio"`
Debug bool `json:"debug" db:"debug"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// DatabaseManager 数据库管理器
type DatabaseManager struct {
db *sql.DB
config DatabaseConfig
}
// NewDatabaseManager 创建数据库管理器
func NewDatabaseManager(config DatabaseConfig) (*DatabaseManager, error) {
if !config.Enabled {
return nil, nil
}
var dsn string
switch config.Type {
case "mysql":
dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.Username, config.Password, config.Host, config.Port, config.Database)
if config.SSLMode != "" {
dsn += "&tls=" + config.SSLMode
}
case "sqlserver":
dsn = fmt.Sprintf("server=%s;port=%d;database=%s;user id=%s;password=%s",
config.Host, config.Port, config.Database, config.Username, config.Password)
default:
return nil, fmt.Errorf("unsupported database type: %s", config.Type)
}
db, err := sql.Open(config.Type, dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %v", err)
}
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %v", err)
}
// 设置连接池参数
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
dm := &DatabaseManager{
db: db,
config: config,
}
// 初始化数据库表
if err := dm.initTables(); err != nil {
return nil, fmt.Errorf("failed to initialize tables: %v", err)
}
return dm, nil
}
// initTables 初始化数据库表
func (dm *DatabaseManager) initTables() error {
var createTableSQL string
switch dm.config.Type {
case "mysql":
createTableSQL = `
CREATE TABLE IF NOT EXISTS cameras (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
ip VARCHAR(45) NOT NULL,
username VARCHAR(100),
password VARCHAR(100),
rtsp_url TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
on_demand BOOLEAN DEFAULT FALSE,
audio BOOLEAN DEFAULT TRUE,
debug BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_enabled (enabled),
INDEX idx_ip (ip)
)`
case "sqlserver":
createTableSQL = `
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='cameras' AND xtype='U')
CREATE TABLE cameras (
id NVARCHAR(36) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
ip NVARCHAR(45) NOT NULL,
username NVARCHAR(100),
password NVARCHAR(100),
rtsp_url NTEXT NOT NULL,
enabled BIT DEFAULT 1,
on_demand BIT DEFAULT 0,
audio BIT DEFAULT 1,
debug BIT DEFAULT 0,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE()
)`
}
_, err := dm.db.Exec(createTableSQL)
return err
}
// GetAllCameras 获取所有摄像头
func (dm *DatabaseManager) GetAllCameras() ([]Camera, error) {
query := `SELECT id, name, ip, username, password, rtsp_url, enabled, on_demand, audio, debug, created_at, updated_at FROM cameras WHERE enabled = ?`
rows, err := dm.db.Query(query, true)
if err != nil {
return nil, err
}
defer rows.Close()
var cameras []Camera
for rows.Next() {
var camera Camera
err := rows.Scan(&camera.ID, &camera.Name, &camera.IP, &camera.Username,
&camera.Password, &camera.RTSPURL, &camera.Enabled, &camera.OnDemand,
&camera.Audio, &camera.Debug, &camera.CreatedAt, &camera.UpdatedAt)
if err != nil {
return nil, err
}
cameras = append(cameras, camera)
}
return cameras, nil
}
// GetCameraByID 根据ID获取摄像头
func (dm *DatabaseManager) GetCameraByID(id string) (*Camera, error) {
query := `SELECT id, name, ip, username, password, rtsp_url, enabled, on_demand, audio, debug, created_at, updated_at FROM cameras WHERE id = ?`
var camera Camera
err := dm.db.QueryRow(query, id).Scan(&camera.ID, &camera.Name, &camera.IP,
&camera.Username, &camera.Password, &camera.RTSPURL, &camera.Enabled,
&camera.OnDemand, &camera.Audio, &camera.Debug, &camera.CreatedAt, &camera.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &camera, nil
}
// CreateCamera 创建摄像头
func (dm *DatabaseManager) CreateCamera(camera *Camera) error {
if camera.ID == "" {
camera.ID = uuid.New().String()
}
camera.CreatedAt = time.Now()
camera.UpdatedAt = time.Now()
query := `INSERT INTO cameras (id, name, ip, username, password, rtsp_url, enabled, on_demand, audio, debug, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := dm.db.Exec(query, camera.ID, camera.Name, camera.IP, camera.Username,
camera.Password, camera.RTSPURL, camera.Enabled, camera.OnDemand,
camera.Audio, camera.Debug, camera.CreatedAt, camera.UpdatedAt)
return err
}
// UpdateCamera 更新摄像头
func (dm *DatabaseManager) UpdateCamera(camera *Camera) error {
camera.UpdatedAt = time.Now()
query := `UPDATE cameras SET name=?, ip=?, username=?, password=?, rtsp_url=?,
enabled=?, on_demand=?, audio=?, debug=?, updated_at=? WHERE id=?`
_, err := dm.db.Exec(query, camera.Name, camera.IP, camera.Username,
camera.Password, camera.RTSPURL, camera.Enabled, camera.OnDemand,
camera.Audio, camera.Debug, camera.UpdatedAt, camera.ID)
return err
}
// DeleteCamera 删除摄像头
func (dm *DatabaseManager) DeleteCamera(id string) error {
query := `DELETE FROM cameras WHERE id = ?`
_, err := dm.db.Exec(query, id)
return err
}
// Close 关闭数据库连接
func (dm *DatabaseManager) Close() error {
if dm.db != nil {
return dm.db.Close()
}
return nil
}
// CameraToStream 将摄像头转换为流配置
func CameraToStream(camera Camera) StreamST {
return StreamST{
Name: camera.Name,
Channels: map[string]ChannelST{
"0": {
Name: camera.Name,
URL: camera.RTSPURL,
OnDemand: camera.OnDemand,
Debug: camera.Debug,
Audio: camera.Audio,
},
},
}
}
// StreamToCamera 将流配置转换为摄像头
func StreamToCamera(id string, stream StreamST) Camera {
camera := Camera{
ID: id,
Name: stream.Name,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 获取第一个通道的配置
if len(stream.Channels) > 0 {
for _, channel := range stream.Channels {
camera.RTSPURL = channel.URL
camera.OnDemand = channel.OnDemand
camera.Debug = channel.Debug
camera.Audio = channel.Audio
break
}
}
return camera
}

@ -0,0 +1,101 @@
-- MySQL数据库初始化脚本
-- 创建数据库
CREATE DATABASE IF NOT EXISTS rtsp_cameras CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用数据库
USE rtsp_cameras;
-- 创建摄像头表
CREATE TABLE IF NOT EXISTS cameras (
id VARCHAR(36) PRIMARY KEY COMMENT '摄像头唯一标识符',
name VARCHAR(255) NOT NULL COMMENT '摄像头名称',
ip VARCHAR(45) NOT NULL COMMENT 'IP地址',
username VARCHAR(100) DEFAULT NULL COMMENT '用户名',
password VARCHAR(255) DEFAULT NULL COMMENT '密码',
rtsp_url VARCHAR(500) NOT NULL COMMENT 'RTSP URL',
enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_name (name),
INDEX idx_ip (ip),
INDEX idx_enabled (enabled),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='摄像头信息表';
-- 插入示例数据
INSERT INTO cameras (id, name, ip, username, password, rtsp_url, enabled) VALUES
('27aec28e-6181-4753-9acd-0456a75f0289', '测试摄像头1', '192.168.1.100', 'admin', 'admin123', 'rtsp://192.168.1.100:554/stream1', TRUE),
('38bfd39f-7292-5864-0bde-1567b86f1390', '测试摄像头2', '192.168.1.101', 'admin', 'admin123', 'rtsp://192.168.1.101:554/stream1', TRUE)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
ip = VALUES(ip),
username = VALUES(username),
password = VALUES(password),
rtsp_url = VALUES(rtsp_url),
enabled = VALUES(enabled),
updated_at = CURRENT_TIMESTAMP;
-- SQL Server数据库初始化脚本注释版本
/*
-- 创建数据库
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'rtsp_cameras')
BEGIN
CREATE DATABASE rtsp_cameras;
END
GO
-- 使用数据库
USE rtsp_cameras;
GO
-- 创建摄像头表
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='cameras' AND xtype='U')
BEGIN
CREATE TABLE cameras (
id NVARCHAR(36) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
ip NVARCHAR(45) NOT NULL,
username NVARCHAR(100) NULL,
password NVARCHAR(255) NULL,
rtsp_url NVARCHAR(500) NOT NULL,
enabled BIT DEFAULT 1,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE()
);
-- 创建索引
CREATE INDEX idx_cameras_name ON cameras(name);
CREATE INDEX idx_cameras_ip ON cameras(ip);
CREATE INDEX idx_cameras_enabled ON cameras(enabled);
CREATE INDEX idx_cameras_created_at ON cameras(created_at);
END
GO
-- 创建更新触发器
IF NOT EXISTS (SELECT * FROM sys.triggers WHERE name = 'tr_cameras_update')
BEGIN
EXEC('CREATE TRIGGER tr_cameras_update ON cameras
AFTER UPDATE
AS
BEGIN
UPDATE cameras
SET updated_at = GETDATE()
WHERE id IN (SELECT id FROM inserted);
END');
END
GO
-- 插入示例数据
IF NOT EXISTS (SELECT * FROM cameras WHERE id = '27aec28e-6181-4753-9acd-0456a75f0289')
BEGIN
INSERT INTO cameras (id, name, ip, username, password, rtsp_url, enabled) VALUES
('27aec28e-6181-4753-9acd-0456a75f0289', N'测试摄像头1', N'192.168.1.100', N'admin', N'admin123', N'rtsp://192.168.1.100:554/stream1', 1);
END
IF NOT EXISTS (SELECT * FROM cameras WHERE id = '38bfd39f-7292-5864-0bde-1567b86f1390')
BEGIN
INSERT INTO cameras (id, name, ip, username, password, rtsp_url, enabled) VALUES
('38bfd39f-7292-5864-0bde-1567b86f1390', N'测试摄像头2', N'192.168.1.101', N'admin', N'admin123', N'rtsp://192.168.1.101:554/stream1', 1);
END
GO
*/

@ -1,31 +1,31 @@
# RTSPtoWeb API
* [Streams](#streams)
* [List streams](#list-streams)
* [Add a stream](#add-a-stream)
* [Update a stream](#update-a-stream)
* [Reload a stream](#reload-a-stream)
* [Get stream info](#get-stream-info)
* [Delete a stream](#delete-a-stream)
* [Channels](#channels)
* [Add a channel to a stream](#add-a-channel-to-a-stream)
* [Update a stream channel](#update-a-stream-channel)
* [Reload a stream channel](#reload-a-stream-channel)
* [Get stream channel info](#get-stream-channel-info)
* [Get stream channel codec](#get-stream-channel-codec)
* [Delete a stream channel](#delete-a-stream-channel)
* [Video endpoints](#video-endpoints)
* [流管理](#streams)
* [列出流](#list-streams)
* [添加流](#add-a-stream)
* [更新流](#update-a-stream)
* [重新加载流](#reload-a-stream)
* [获取流信息](#get-stream-info)
* [删除流](#delete-a-stream)
* [通道管理](#channels)
* [向流添加通道](#add-a-channel-to-a-stream)
* [更新流通道](#update-a-stream-channel)
* [重新加载流通道](#reload-a-stream-channel)
* [获取流通道信息](#get-stream-channel-info)
* [获取流通道编解码器](#get-stream-channel-codec)
* [删除流通道](#delete-a-stream-channel)
* [视频端点](#video-endpoints)
* [HLS](#hls)
* [HLS-LL](#hls-ll)
* [MSE](#mse)
* [WebRTC](#webrtc)
* [RTSP](#rtsp)
## Streams
## 流管理
### List streams
### 列出流
#### Request
#### 请求
`GET /streams`
@ -33,7 +33,7 @@
curl http://demo:demo@127.0.0.1:8083/streams
```
#### Response
#### 响应
```json
{
@ -81,9 +81,9 @@ curl http://demo:demo@127.0.0.1:8083/streams
}
```
### Add a stream
### 添加流
#### Request
#### 请求
`POST /stream/{STREAM_ID}/add`
@ -113,7 +113,7 @@ curl \
http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/add
```
#### Response
#### 响应
```json
{
@ -122,9 +122,9 @@ curl \
}
```
### Update a stream
### 更新流
#### Request
#### 请求
`POST /stream/{STREAM_ID}/edit`
@ -154,7 +154,7 @@ curl \
http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/edit
```
#### Response
#### 响应
```json
{
@ -163,9 +163,9 @@ curl \
}
```
### Reload a stream
### 重新加载流
#### Request
#### 请求
`GET /stream/{STREAM_ID}/reload`
@ -173,7 +173,7 @@ curl \
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/reload
```
#### Response
#### 响应
```json
{
@ -182,9 +182,9 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/reload
}
```
### Get stream info
### 获取流信息
#### Request
#### 请求
`GET /stream/{STREAM_ID}/info`
@ -192,7 +192,7 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/reload
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/info
```
#### Response
#### 响应
```json
{
@ -219,9 +219,9 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/info
}
```
### Delete a stream
### 删除流
#### Request
#### 请求
`GET /stream/{STREAM_ID}/delete`
@ -229,7 +229,7 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/info
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/delete
```
#### Response
#### 响应
```json
{
@ -238,11 +238,11 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/delete
}
```
## Channels
## 通道管理
### Add a channel to a stream
### 向流添加通道
#### Request
#### 请求
`POST /stream/{STREAM_ID}/channel/{CHANNEL_ID}/add`
@ -260,7 +260,7 @@ curl \
http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/add
```
#### Response
#### 响应
```json
{
@ -269,9 +269,9 @@ curl \
}
```
### Update a stream channel
### 更新流通道
#### Request
#### 请求
`POST /stream/{STREAM_ID}/channel/{CHANNEL_ID}/edit`
@ -289,7 +289,7 @@ curl \
http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/edit
```
#### Response
#### 响应
```json
{
@ -298,9 +298,9 @@ curl \
}
```
### Reload a stream channel
### 重新加载流通道
#### Request
#### 请求
`GET /stream/{STREAM_ID}/channel/{CHANNEL_ID}/reload`
@ -308,7 +308,7 @@ curl \
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/reload
```
#### Response
#### 响应
```json
{
@ -317,9 +317,9 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/rel
}
```
### Get stream channel info
### 获取流通道信息
#### Request
#### 请求
`GET /stream/{STREAM_ID}/channel/{CHANNEL_ID}/info`
@ -327,7 +327,7 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/rel
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/info
```
#### Response
#### 响应
```json
{
@ -342,16 +342,16 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/inf
}
```
### Get stream channel codec
### 获取流通道编解码器
#### Request
#### 请求
`GET /stream/{STREAM_ID}/{CHANNEL_ID}/codec`
```bash
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/{CHANNEL_ID}/codec
```
#### Response
#### 响应
```json
{
"status": 1,
@ -387,9 +387,9 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/{CHANNEL_ID}/codec
}
```
### Delete a stream channel
### 删除流通道
#### Request
#### 请求
`GET /stream/{STREAM_ID}/channel/{CHANNEL_ID}/delete`
@ -397,7 +397,7 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/{CHANNEL_ID}/codec
curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/delete
```
#### Response
#### 响应
```json
{
"status": 1,
@ -405,7 +405,7 @@ curl http://demo:demo@127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/del
}
```
## Video endpoints
## 视频端点
### HLS
@ -439,7 +439,7 @@ ffplay http://127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/hlsll/live/
ws://127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/mse?uuid={STREAM_ID}&channel={CHANNEL_ID}
```
NOTE: Use `wss` for a secure connection.
注意:使用 `wss` 进行安全连接。
### WebRTC
@ -451,11 +451,11 @@ http://127.0.0.1:8083/stream/{STREAM_ID}/channel/{CHANNEL_ID}/webrtc
#### Request
The request is an HTTP `POST` with a FormData parameter `data` that is a base64 encoded SDP offer (e.g. `v=0...`) from a WebRTC client.
请求是一个 HTTP `POST`,包含 FormData 参数 `data`,该参数是来自 WebRTC 客户端的 base64 编码的 SDP offer例如 `v=0...`)。
#### Response
#### 响应
The response is a base64 encoded SDP Answer.
响应是一个 base64 编码的 SDP Answer。
### RTSP

@ -4,9 +4,12 @@ go 1.19
require (
github.com/deepch/vdk v0.0.20
github.com/denisenkom/go-mssqldb v0.12.3
github.com/gin-gonic/autotls v0.0.5
github.com/gin-gonic/gin v1.9.0
github.com/go-sql-driver/mysql v1.7.1
github.com/gobwas/ws v1.1.0
github.com/google/uuid v1.3.0
github.com/hashicorp/go-version v1.6.0
github.com/imdario/mergo v0.3.15
github.com/liip/sheriff v0.11.1
@ -23,7 +26,9 @@ require (
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.2 // indirect

@ -1,3 +1,6 @@
github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.3 h1:pf6fGl5eqWYKkx1RcD4qpuX+BIUaduv/wTm5ekWJ80M=
github.com/bytedance/sonic v1.8.3/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@ -9,6 +12,9 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepch/vdk v0.0.20 h1:GNQjfgapEEzaownzfRWYdQw+SqchW2yC1ha+/d0Bl24=
github.com/deepch/vdk v0.0.20/go.mod h1:774MjElr4PMgmXuTi9HXrK0nM0phMXE80W/g+vyTpAc=
github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=
github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@ -24,6 +30,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
@ -33,6 +41,10 @@ github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -42,6 +54,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@ -77,6 +91,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -126,6 +141,7 @@ github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
github.com/pion/webrtc/v3 v3.1.58 h1:husXqiKQuk6gbOqJlPHs185OskAyxUW6iAEgHghgCrc=
github.com/pion/webrtc/v3 v3.1.58/go.mod h1:jJdqoqGBlZiE3y8Z1tg1fjSkyEDCZLL+foypUBn0Lhk=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
@ -157,7 +173,9 @@ golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
@ -172,6 +190,7 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
@ -253,9 +272,11 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

@ -1,8 +1,6 @@
package main
import (
"io/ioutil"
"github.com/sirupsen/logrus"
)
@ -10,11 +8,20 @@ var log = logrus.New()
func init() {
//TODO: next add write to file
if !debug {
log.SetOutput(ioutil.Discard)
}
// Temporarily disable log output redirection for debugging
// if !debug {
// log.SetOutput(ioutil.Discard)
// }
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
// Set debug level temporarily - avoid circular dependency
log.SetLevel(logrus.DebugLevel)
}
// SetLogLevel sets the log level after Storage is initialized
func SetLogLevel() {
if Storage != nil {
log.SetLevel(Storage.ServerLogLevel())
}
}

@ -46,9 +46,35 @@ func NewStreamCore() *StorageST {
os.Exit(1)
}
debug = tmp.Server.Debug
// 初始化数据库连接
if tmp.Database.Enabled {
err = tmp.InitDatabase()
if err != nil {
log.WithFields(logrus.Fields{
"module": "config",
"func": "NewStreamCore",
"call": "InitDatabase",
}).Errorln(err.Error())
os.Exit(1)
}
// 从数据库加载流配置
err = tmp.LoadStreamsFromDatabase()
if err != nil {
log.WithFields(logrus.Fields{
"module": "config",
"func": "NewStreamCore",
"call": "LoadStreamsFromDatabase",
}).Errorln(err.Error())
os.Exit(1)
}
}
// 处理流配置(从配置文件或数据库)
for i, i2 := range tmp.Streams {
for i3, i4 := range i2.Channels {
channel := tmp.ChannelDefaults
var channel ChannelST
err = mergo.Merge(&channel, i4)
if err != nil {
log.WithFields(logrus.Fields{
@ -66,6 +92,7 @@ func NewStreamCore() *StorageST {
}
tmp.Streams[i] = i2
}
return &tmp
}

@ -0,0 +1,237 @@
package main
import (
"fmt"
"time"
"github.com/sirupsen/logrus"
)
// InitDatabase 初始化数据库连接
func (obj *StorageST) InitDatabase() error {
obj.mutex.Lock()
defer obj.mutex.Unlock()
if !obj.Database.Enabled {
log.WithFields(logrus.Fields{
"module": "database",
"func": "InitDatabase",
}).Infoln("Database is disabled")
return nil
}
dbManager, err := NewDatabaseManager(obj.Database)
if err != nil {
log.WithFields(logrus.Fields{
"module": "database",
"func": "InitDatabase",
"call": "NewDatabaseManager",
}).Errorln(err.Error())
return err
}
obj.dbManager = dbManager
log.WithFields(logrus.Fields{
"module": "database",
"func": "InitDatabase",
}).Infoln("Database initialized successfully")
return nil
}
// LoadStreamsFromDatabase 从数据库加载流配置
func (obj *StorageST) LoadStreamsFromDatabase() error {
obj.mutex.Lock()
defer obj.mutex.Unlock()
if obj.dbManager == nil {
return fmt.Errorf("database manager not initialized")
}
cameras, err := obj.dbManager.GetAllCameras()
if err != nil {
log.WithFields(logrus.Fields{
"module": "database",
"func": "LoadStreamsFromDatabase",
"call": "GetAllCameras",
}).Errorln(err.Error())
return err
}
// 清空现有流配置(如果启用数据库模式)
if obj.Server.DatabaseEnabled {
obj.Streams = make(map[string]StreamST)
}
// 将摄像头转换为流配置
for _, camera := range cameras {
stream := CameraToStream(camera)
obj.Streams[camera.ID] = stream
log.WithFields(logrus.Fields{
"module": "database",
"func": "LoadStreamsFromDatabase",
"camera": camera.Name,
"id": camera.ID,
}).Infoln("Loaded camera from database")
}
log.WithFields(logrus.Fields{
"module": "database",
"func": "LoadStreamsFromDatabase",
"count": len(cameras),
}).Infoln("Loaded cameras from database")
return nil
}
// SyncStreamToDatabase 同步流配置到数据库
func (obj *StorageST) SyncStreamToDatabase(streamID string, stream StreamST) error {
if obj.dbManager == nil || !obj.Server.DatabaseEnabled {
return nil // 数据库未启用,跳过同步
}
// 检查摄像头是否已存在
existingCamera, err := obj.dbManager.GetCameraByID(streamID)
if err != nil {
return err
}
camera := StreamToCamera(streamID, stream)
if existingCamera == nil {
// 创建新摄像头
err = obj.dbManager.CreateCamera(&camera)
if err != nil {
log.WithFields(logrus.Fields{
"module": "database",
"func": "SyncStreamToDatabase",
"call": "CreateCamera",
"id": streamID,
}).Errorln(err.Error())
return err
}
log.WithFields(logrus.Fields{
"module": "database",
"func": "SyncStreamToDatabase",
"id": streamID,
"name": camera.Name,
}).Infoln("Created camera in database")
} else {
// 更新现有摄像头
camera.CreatedAt = existingCamera.CreatedAt // 保持创建时间
err = obj.dbManager.UpdateCamera(&camera)
if err != nil {
log.WithFields(logrus.Fields{
"module": "database",
"func": "SyncStreamToDatabase",
"call": "UpdateCamera",
"id": streamID,
}).Errorln(err.Error())
return err
}
log.WithFields(logrus.Fields{
"module": "database",
"func": "SyncStreamToDatabase",
"id": streamID,
"name": camera.Name,
}).Infoln("Updated camera in database")
}
return nil
}
// DeleteStreamFromDatabase 从数据库删除流配置
func (obj *StorageST) DeleteStreamFromDatabase(streamID string) error {
if obj.dbManager == nil || !obj.Server.DatabaseEnabled {
return nil // 数据库未启用,跳过删除
}
err := obj.dbManager.DeleteCamera(streamID)
if err != nil {
log.WithFields(logrus.Fields{
"module": "database",
"func": "DeleteStreamFromDatabase",
"call": "DeleteCamera",
"id": streamID,
}).Errorln(err.Error())
return err
}
log.WithFields(logrus.Fields{
"module": "database",
"func": "DeleteStreamFromDatabase",
"id": streamID,
}).Infoln("Deleted camera from database")
return nil
}
// RefreshStreamsFromDatabase 刷新数据库中的流配置
func (obj *StorageST) RefreshStreamsFromDatabase() error {
if obj.dbManager == nil || !obj.Server.DatabaseEnabled {
return nil
}
// 停止所有现有流
obj.StopAll()
// 等待流停止
time.Sleep(2 * time.Second)
// 重新加载流配置
err := obj.LoadStreamsFromDatabase()
if err != nil {
return err
}
// 启动所有流
obj.StreamChannelRunAll()
log.WithFields(logrus.Fields{
"module": "database",
"func": "RefreshStreamsFromDatabase",
}).Infoln("Refreshed streams from database")
return nil
}
// GetDatabaseStatus 获取数据库状态
func (obj *StorageST) GetDatabaseStatus() map[string]interface{} {
status := map[string]interface{}{
"enabled": obj.Database.Enabled,
"type": obj.Database.Type,
"host": obj.Database.Host,
"port": obj.Database.Port,
"database": obj.Database.Database,
"connected": false,
"error": nil,
}
if obj.dbManager != nil && obj.dbManager.db != nil {
if err := obj.dbManager.db.Ping(); err == nil {
status["connected"] = true
} else {
status["error"] = err.Error()
}
}
return status
}
// CloseDatabase 关闭数据库连接
func (obj *StorageST) CloseDatabase() error {
obj.mutex.Lock()
defer obj.mutex.Unlock()
if obj.dbManager != nil {
err := obj.dbManager.Close()
obj.dbManager = nil
log.WithFields(logrus.Fields{
"module": "database",
"func": "CloseDatabase",
}).Infoln("Database connection closed")
return err
}
return nil
}

@ -42,10 +42,20 @@ func (obj *StorageST) StreamAdd(uuid string, val StreamST) error {
}
}
obj.Streams[uuid] = val
err := obj.SaveConfig()
// 同步到数据库
err := obj.SyncStreamToDatabase(uuid, val)
if err != nil {
return err
}
// 如果未启用数据库,保存到配置文件
if !obj.Server.DatabaseEnabled {
err = obj.SaveConfig()
if err != nil {
return err
}
}
return nil
}
@ -72,10 +82,20 @@ func (obj *StorageST) StreamEdit(uuid string, val StreamST) error {
}
}
obj.Streams[uuid] = val
err := obj.SaveConfig()
// 同步到数据库
err := obj.SyncStreamToDatabase(uuid, val)
if err != nil {
return err
}
// 如果未启用数据库,保存到配置文件
if !obj.Server.DatabaseEnabled {
err = obj.SaveConfig()
if err != nil {
return err
}
}
return nil
}
return ErrorStreamNotFound
@ -120,10 +140,20 @@ func (obj *StorageST) StreamDelete(uuid string) error {
}
}
delete(obj.Streams, uuid)
err := obj.SaveConfig()
// 从数据库删除
err := obj.DeleteStreamFromDatabase(uuid)
if err != nil {
return err
}
// 如果未启用数据库,保存到配置文件
if !obj.Server.DatabaseEnabled {
err = obj.SaveConfig()
if err != nil {
return err
}
}
return nil
}
return ErrorStreamNotFound

@ -49,6 +49,8 @@ type StorageST struct {
Server ServerST `json:"server" groups:"api,config"`
Streams map[string]StreamST `json:"streams,omitempty" groups:"api,config"`
ChannelDefaults ChannelST `json:"channel_defaults,omitempty" groups:"api,config"`
Database DatabaseConfig `json:"database,omitempty" groups:"api,config"`
dbManager *DatabaseManager // 数据库管理器,不序列化
}
// ServerST server storage section
@ -74,6 +76,7 @@ type ServerST struct {
Token Token `json:"token,omitempty" groups:"api,config"`
WebRTCPortMin uint16 `json:"webrtc_port_min" groups:"api,config"`
WebRTCPortMax uint16 `json:"webrtc_port_max" groups:"api,config"`
DatabaseEnabled bool `json:"database_enabled" groups:"api,config"`
}
// Token auth

Binary file not shown.

@ -3,12 +3,12 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Add stream</h1>
<h1 class="m-0 text-dark">添加流</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">Add stream</li>
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">添加流</li>
</ol>
</div>
</div>
@ -25,35 +25,35 @@
<div class="col-12">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Main channel<small> parameters</small></h3>
<h3 class="card-title">主通道<small> 参数</small></h3>
</div>
<div class="card-body">
<form class="stream-form main-form">
<div class="form-group">
<label for="exampleInputEmail1">Stream name</label>
<input type="text" class="form-control" name="stream-name" placeholder="Enter stream name" id="stream-name">
<small class="form-text text-muted">You can choose any name for the stream, for example "My room" or "Happy sausage"</small>
<label for="exampleInputEmail1">流名称</label>
<input type="text" class="form-control" name="stream-name" placeholder="输入流名称" id="stream-name">
<small class="form-text text-muted">您可以为流选择任何名称,例如"我的房间"或"客厅"</small>
</div>
<div class="form-group">
<label for="exampleInputPassword1">Stream url</label>
<input type="text" name="stream-url" class="form-control" placeholder="Enter stream url">
<small class="form-text text-muted">Enter rtsp address as instructed by your camera. Look like <code>rtsp://&lt;ip&gt;:&lt;port&gt;/path </code> </small>
<label for="exampleInputPassword1">流地址</label>
<input type="text" name="stream-url" class="form-control" placeholder="输入流地址">
<small class="form-text text-muted">按照摄像头说明输入rtsp地址。格式如 <code>rtsp://&lt;ip&gt;:&lt;port&gt;/path </code> </small>
</div>
<div class="form-group">
<label for="inputStatus">Stream type</label>
<label for="inputStatus">流类型</label>
<select class="form-control custom-select" name="stream-ondemand">
<option selected disabled><small>Select One</small></option>
<option value="1">On demand only</option>
<option value="0">Persistent connection</option>
<option selected disabled><small>请选择</small></option>
<option value="1">按需连接</option>
<option value="0">持续连接</option>
</select>
<small class="form-text text-muted">On persistent connection, the server get data from the camera continuously. On demand, the server get data from the camera only when you click play button </small>
<small class="form-text text-muted">持续连接时,服务器会持续从摄像头获取数据。按需连接时,服务器只在您点击播放按钮时才从摄像头获取数据 </small>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="debug" id="debug-switch">
<label class="custom-control-label" for="debug-switch">Enable debug</label>
<label class="custom-control-label" for="debug-switch">启用调试</label>
</div>
<small class="form-text text-muted">Select this options if you want get more data about the stream </small>
<small class="form-text text-muted">如果您想获取更多关于流的数据,请选择此选项 </small>
</div>
</form>
</div>
@ -63,8 +63,8 @@
<div class="row mb-3">
<div class="col-12">
<button type="button" onclick="addChannel()" class="btn btn-secondary">Add channel</button>
<button type="button" onclick="addStreamSubmit()" class="btn btn-primary">Save stream</button>
<button type="button" onclick="addChannel()" class="btn btn-secondary">添加通道</button>
<button type="button" onclick="addStreamSubmit()" class="btn btn-primary">保存流</button>
</div>
</div>
</div>

@ -0,0 +1,408 @@
<!DOCTYPE html>
<html>
<head>
<title>摄像头管理 - RTSPtoWeb</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.camera-card {
transition: transform 0.2s;
}
.camera-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.status-online {
color: #28a745;
}
.status-offline {
color: #dc3545;
}
.btn-group-actions {
gap: 5px;
}
.modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.form-floating {
margin-bottom: 1rem;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">
<i class="fas fa-video"></i> RTSPtoWeb
</a>
<div class="navbar-nav ms-auto">
<a class="nav-link" href="/">首页</a>
<a class="nav-link active" href="/pages/cameras">摄像头管理</a>
</div>
</div>
</nav>
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-camera"></i> 摄像头管理</h2>
<div>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addCameraModal">
<i class="fas fa-plus"></i> 添加摄像头
</button>
<button class="btn btn-info" onclick="refreshCameras()">
<i class="fas fa-sync-alt"></i> 刷新
</button>
</div>
</div>
<!-- 数据库状态 -->
<div class="alert alert-info" id="dbStatus">
<i class="fas fa-database"></i> 数据库状态: <span id="dbStatusText">检查中...</span>
</div>
<!-- 摄像头列表 -->
<div class="row" id="cameraList">
<!-- 摄像头卡片将在这里动态生成 -->
</div>
</div>
<!-- 添加摄像头模态框 -->
<div class="modal fade" id="addCameraModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-plus"></i> 添加摄像头</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addCameraForm">
<div class="form-floating">
<input type="text" class="form-control" id="addName" placeholder="摄像头名称" required>
<label for="addName">摄像头名称</label>
</div>
<div class="form-floating">
<input type="text" class="form-control" id="addIP" placeholder="IP地址" required>
<label for="addIP">IP地址</label>
</div>
<div class="form-floating">
<input type="text" class="form-control" id="addUsername" placeholder="用户名">
<label for="addUsername">用户名</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="addPassword" placeholder="密码">
<label for="addPassword">密码</label>
</div>
<div class="form-floating">
<input type="text" class="form-control" id="addRTSPURL" placeholder="RTSP URL" required>
<label for="addRTSPURL">RTSP URL</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="addEnabled" checked>
<label class="form-check-label" for="addEnabled">
启用摄像头
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-success" onclick="addCamera()">添加</button>
</div>
</div>
</div>
</div>
<!-- 编辑摄像头模态框 -->
<div class="modal fade" id="editCameraModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-edit"></i> 编辑摄像头</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editCameraForm">
<input type="hidden" id="editCameraId">
<div class="form-floating">
<input type="text" class="form-control" id="editName" placeholder="摄像头名称" required>
<label for="editName">摄像头名称</label>
</div>
<div class="form-floating">
<input type="text" class="form-control" id="editIP" placeholder="IP地址" required>
<label for="editIP">IP地址</label>
</div>
<div class="form-floating">
<input type="text" class="form-control" id="editUsername" placeholder="用户名">
<label for="editUsername">用户名</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="editPassword" placeholder="密码">
<label for="editPassword">密码</label>
</div>
<div class="form-floating">
<input type="text" class="form-control" id="editRTSPURL" placeholder="RTSP URL" required>
<label for="editRTSPURL">RTSP URL</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editEnabled">
<label class="form-check-label" for="editEnabled">
启用摄像头
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="updateCamera()">更新</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
checkDatabaseStatus();
loadCameras();
});
// 检查数据库状态
function checkDatabaseStatus() {
fetch('/database/status')
.then(response => response.json())
.then(data => {
const statusElement = document.getElementById('dbStatusText');
const alertElement = document.getElementById('dbStatus');
if (data.enabled && data.connected) {
statusElement.textContent = `已连接 (${data.type}://${data.host}:${data.port}/${data.database})`;
alertElement.className = 'alert alert-success';
} else if (data.enabled && !data.connected) {
statusElement.textContent = `连接失败: ${data.error || '未知错误'}`;
alertElement.className = 'alert alert-danger';
} else {
statusElement.textContent = '未启用';
alertElement.className = 'alert alert-warning';
}
})
.catch(error => {
console.error('检查数据库状态失败:', error);
document.getElementById('dbStatusText').textContent = '检查失败';
document.getElementById('dbStatus').className = 'alert alert-danger';
});
}
// 加载摄像头列表
function loadCameras() {
fetch('/cameras')
.then(response => response.json())
.then(data => {
if (data.cameras) {
displayCameras(data.cameras);
} else {
document.getElementById('cameraList').innerHTML =
'<div class="col-12"><div class="alert alert-info">暂无摄像头数据</div></div>';
}
})
.catch(error => {
console.error('加载摄像头失败:', error);
document.getElementById('cameraList').innerHTML =
'<div class="col-12"><div class="alert alert-danger">加载摄像头失败</div></div>';
});
}
// 显示摄像头列表
function displayCameras(cameras) {
const container = document.getElementById('cameraList');
container.innerHTML = '';
cameras.forEach(camera => {
const statusClass = camera.status === 'online' ? 'status-online' : 'status-offline';
const statusIcon = camera.status === 'online' ? 'fas fa-circle' : 'far fa-circle';
const enabledBadge = camera.enabled ?
'<span class="badge bg-success">已启用</span>' :
'<span class="badge bg-secondary">已禁用</span>';
const card = `
<div class="col-md-6 col-lg-4 mb-3">
<div class="card camera-card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">${camera.name}</h6>
${enabledBadge}
</div>
<div class="card-body">
<p class="card-text">
<strong>IP:</strong> ${camera.ip}<br>
<strong>用户名:</strong> ${camera.username || ''}<br>
<strong>状态:</strong>
<i class="${statusIcon} ${statusClass}"></i>
<span class="${statusClass}">${camera.status === 'online' ? '在线' : '离线'}</span>
</p>
<small class="text-muted">
创建时间: ${new Date(camera.created_at).toLocaleString()}
</small>
</div>
<div class="card-footer">
<div class="d-flex btn-group-actions">
<button class="btn btn-sm btn-primary" onclick="editCamera('${camera.id}')">
<i class="fas fa-edit"></i> 编辑
</button>
<button class="btn btn-sm btn-success" onclick="previewCamera('${camera.id}')">
<i class="fas fa-play"></i> 预览
</button>
<button class="btn btn-sm btn-danger" onclick="deleteCamera('${camera.id}', '${camera.name}')">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</div>
</div>
</div>
`;
container.innerHTML += card;
});
}
// 添加摄像头
function addCamera() {
const formData = {
name: document.getElementById('addName').value,
ip: document.getElementById('addIP').value,
username: document.getElementById('addUsername').value,
password: document.getElementById('addPassword').value,
rtsp_url: document.getElementById('addRTSPURL').value,
enabled: document.getElementById('addEnabled').checked
};
fetch('/camera/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
if (data.message) {
alert('摄像头添加成功!');
bootstrap.Modal.getInstance(document.getElementById('addCameraModal')).hide();
document.getElementById('addCameraForm').reset();
loadCameras();
} else {
alert('添加失败: ' + (data.error || '未知错误'));
}
})
.catch(error => {
console.error('添加摄像头失败:', error);
alert('添加失败: ' + error.message);
});
}
// 编辑摄像头
function editCamera(cameraId) {
fetch(`/camera/${cameraId}`)
.then(response => response.json())
.then(camera => {
document.getElementById('editCameraId').value = camera.id;
document.getElementById('editName').value = camera.name;
document.getElementById('editIP').value = camera.ip;
document.getElementById('editUsername').value = camera.username || '';
document.getElementById('editPassword').value = '';
document.getElementById('editRTSPURL').value = camera.rtsp_url;
document.getElementById('editEnabled').checked = camera.enabled;
new bootstrap.Modal(document.getElementById('editCameraModal')).show();
})
.catch(error => {
console.error('获取摄像头信息失败:', error);
alert('获取摄像头信息失败');
});
}
// 更新摄像头
function updateCamera() {
const cameraId = document.getElementById('editCameraId').value;
const formData = {
name: document.getElementById('editName').value,
ip: document.getElementById('editIP').value,
username: document.getElementById('editUsername').value,
password: document.getElementById('editPassword').value,
rtsp_url: document.getElementById('editRTSPURL').value,
enabled: document.getElementById('editEnabled').checked
};
fetch(`/camera/${cameraId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
if (data.message) {
alert('摄像头更新成功!');
bootstrap.Modal.getInstance(document.getElementById('editCameraModal')).hide();
loadCameras();
} else {
alert('更新失败: ' + (data.error || '未知错误'));
}
})
.catch(error => {
console.error('更新摄像头失败:', error);
alert('更新失败: ' + error.message);
});
}
// 删除摄像头
function deleteCamera(cameraId, cameraName) {
if (confirm(`确定要删除摄像头 "${cameraName}" 吗?`)) {
fetch(`/camera/${cameraId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.message) {
alert('摄像头删除成功!');
loadCameras();
} else {
alert('删除失败: ' + (data.error || '未知错误'));
}
})
.catch(error => {
console.error('删除摄像头失败:', error);
alert('删除失败: ' + error.message);
});
}
}
// 预览摄像头
function previewCamera(cameraId) {
window.open(`/pages/player/all/${cameraId}/0`, '_blank');
}
// 刷新摄像头列表
function refreshCameras() {
fetch('/cameras/refresh', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.message) {
alert('刷新成功!');
loadCameras();
} else {
alert('刷新失败: ' + (data.error || '未知错误'));
}
})
.catch(error => {
console.error('刷新失败:', error);
alert('刷新失败: ' + error.message);
});
}
</script>
</body>
</html>

@ -3,12 +3,12 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Documentation</h1>
<h1 class="m-0 text-dark">文档</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">Documentation</li>
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">文档</li>
</ol>
</div>
</div>
@ -20,10 +20,10 @@
<div class="row">
<div class="col-12">
<p>
<a href="https://github.com/deepch/RTSPtoWeb/tree/master/docs/api.md">API documentation</a> is available in the GitHub repository.
<a href="/docs/api.md">API 文档</a> 可在本地查看。
</p>
<p>
See the project <a href="https://github.com/deepch/RTSPtoWeb#readme">README</a> for installation and configuration instructions.
查看项目 <a href="https://github.com/deepch/RTSPtoWeb#readme">README</a> 获取安装和配置说明。
</p>
</div>
</div>

@ -3,12 +3,12 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Edit stream</h1>
<h1 class="m-0 text-dark">编辑流</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">Edit stream</li>
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">编辑流</li>
</ol>
</div>
</div>
@ -23,42 +23,42 @@
<div class="col-md-12">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">Parameters<small> main</small></h3>
<h3 class="card-title">参数<small> 主要</small></h3>
</div>
<form class="stream-form main-form">
<div class="card-body">
<div class="form-group">
<label for="exampleInputEmail1">Stream name</label>
<input type="text" class="form-control" name="stream-name" placeholder="Enter stream name" value="{{$stream.Name}}" id="stream-name">
<small class="form-text text-muted">You can choose any name for the stream, for example "My room" or "Happy sausage"</small>
<label for="exampleInputEmail1">流名称</label>
<input type="text" class="form-control" name="stream-name" placeholder="输入流名称" value="{{$stream.Name}}" id="stream-name">
<small class="form-text text-muted">您可以为流选择任何名称,例如"我的房间"或"客厅"</small>
</div>
<div class="form-group">
<label for="exampleInputPassword1">Stream url</label>
<input type="text" name="stream-url" class="form-control" placeholder="Enter stream url" value="{{$mainChannel.URL}}">
<small class="form-text text-muted">Enter rtsp address as instructed by your camera. Look like <code>rtsp://&lt;ip&gt;:&lt;port&gt;/path </code></small>
<label for="exampleInputPassword1">流地址</label>
<input type="text" name="stream-url" class="form-control" placeholder="输入流地址" value="{{$mainChannel.URL}}">
<small class="form-text text-muted">按照摄像头说明输入rtsp地址。格式如 <code>rtsp://&lt;ip&gt;:&lt;port&gt;/path </code></small>
</div>
<div class="form-group">
<label for="inputStatus">Stream type</label>
<label for="inputStatus">流类型</label>
<select class="form-control custom-select" name="stream-ondemand">
<option selected disabled><small>Select One</small></option>
<option selected disabled><small>请选择</small></option>
<option value="1"
{{ if eq $mainChannel.OnDemand true}}
selected
{{ end }}>On demand only</option>
{{ end }}>按需连接</option>
<option value="0" {{ if eq $mainChannel.OnDemand false}}
selected
{{ end }}>Persistent connection</option>
{{ end }}>持续连接</option>
</select>
<small class="form-text text-muted">On persistent connection, the server get data from the camera continuously. On demand, the server get data from the camera only when you click play button </small>
<small class="form-text text-muted">持续连接时,服务器会持续从摄像头获取数据。按需连接时,服务器只在您点击播放按钮时才从摄像头获取数据 </small>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="debug" id="debug-switch" {{ if eq $mainChannel.Debug true}}
checked
{{ end }}>
<label class="custom-control-label" for="debug-switch">Enable debug</label>
<label class="custom-control-label" for="debug-switch">启用调试</label>
</div>
<small class="form-text text-muted">Select this options if you want get more data about the stream </small>
<small class="form-text text-muted">如果您想获取更多关于流的数据,请选择此选项 </small>
</div>
</div>
</form>
@ -70,7 +70,7 @@
<div class="col-12">
<div class="card card-secondary">
<div class="card-header">
<h3 class="card-title">Sub channel<small> parameters</small></h3>
<h3 class="card-title">子通道<small> 参数</small></h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" onclick="removeChannelDiv(this)"><i class="fas fa-times"></i></button>
</div>
@ -78,34 +78,34 @@
<div class="card-body">
<form class="stream-form">
<div class="form-group">
<label for="exampleInputPassword1">Substream url</label>
<input type="text" name="stream-url" class="form-control" placeholder="Enter stream url" value="{{$value.URL}}" >
<small class="form-text text-muted">Enter rtsp address as instructed by your camera. Look like <code>rtsp://&lt;ip&gt;:&lt;port&gt;/path </code> </small>
<label for="exampleInputPassword1">子流地址</label>
<input type="text" name="stream-url" class="form-control" placeholder="输入流地址" value="{{$value.URL}}" >
<small class="form-text text-muted">按照摄像头说明输入rtsp地址。格式如 <code>rtsp://&lt;ip&gt;:&lt;port&gt;/path </code> </small>
</div>
<div class="form-group">
<label for="inputStatus">Substream type</label>
<label for="inputStatus">子流类型</label>
<select class="form-control custom-select" name="stream-ondemand">
<option selected disabled><small>Select One</small></option>
<option selected disabled><small>请选择</small></option>
<option value="1"
{{ if eq $value.OnDemand true}}
selected
{{ end }}>On demand only</option>
{{ end }}>按需连接</option>
<option value="0"
{{ if eq $value.OnDemand false}}
selected
{{ end }}>Persistent connection</option>
{{ end }}>持续连接</option>
</select>
<small class="form-text text-muted">On persistent connection, the server get data from the camera continuously. On demand, the server get data from the camera only when you click play button </small>
<small class="form-text text-muted">持续连接时,服务器会持续从摄像头获取数据。按需连接时,服务器只在您点击播放按钮时才从摄像头获取数据 </small>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="debug" id="substream-debug-switch-{{$key}}" {{ if eq $value.Debug true}}
checked
{{ end }}>
<label class="custom-control-label" for="substream-debug-switch-{{$key}}">Enable debug</label>
<label class="custom-control-label" for="substream-debug-switch-{{$key}}">启用调试</label>
</div>
<small class="form-text text-muted">Select this options if you want get more data about the stream </small>
<small class="form-text text-muted">如果您想获取更多关于流的数据,请选择此选项 </small>
</div>
</form>
</div>
@ -117,8 +117,8 @@
</div>
<div class="row mb-3">
<div class="col-12">
<button type="button" onclick="addChannel()" class="btn btn-secondary">Add channel</button>
<button type="button" onclick="editStreamSubmit()" class="btn btn-primary">Save stream</button>
<button type="button" onclick="addChannel()" class="btn btn-secondary">添加通道</button>
<button type="button" onclick="editStreamSubmit()" class="btn btn-primary">保存流</button>
</div>
</div>

@ -25,12 +25,12 @@
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
</li>
<li class="nav-item d-none d-sm-inline-block">
<a href="/" class="nav-link">Home</a>
<a href="/" class="nav-link">首页</a>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/pages/stream/add" role="button"><i class="fas fa-plus-square"></i> Add stream</a>
<a class="nav-link" href="/pages/stream/add" role="button"><i class="fas fa-plus-square"></i> 添加流</a>
</li>
</ul>
</nav>
@ -54,17 +54,17 @@
">
<i class="nav-icon fas fa-tachometer-alt"></i>
<p>
Dashboard
仪表板
</p>
</a>
</li>
<li class="nav-header">NAVIGATION</li>
<li class="nav-header">导航</li>
<li class="nav-item ">
<a href="/pages/stream/list" class="nav-link {{ if (eq .page "stream_list") }}
active
{{end}}">
<i class="fas fa-list-alt nav-icon"></i>
<p>Streams list</p>
<p>流列表</p>
</a>
</li>
<li class="nav-item">
@ -72,7 +72,7 @@
active
{{end}}">
<i class="fas fa-plus-square nav-icon"></i>
<p>Add stream</p>
<p>添加流</p>
</a>
</li>
@ -81,17 +81,17 @@
active
{{end}}">
<i class="fas fa-th-large nav-icon"></i>
<p>Full multiview</p>
<p>全屏多视图</p>
</a>
</li>
<li class="nav-header">MISCELLANEOUS</li>
<li class="nav-header">其他</li>
<li class="nav-item">
<a href="/pages/documentation" class="nav-link
{{ if eq .page "documentation"}}
active
{{end}}">
<i class="nav-icon fas fa-file"></i>
<p>Documentation</p>
<p>文档</p>
</a>
</li>
</ul>

@ -3,12 +3,12 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Dashboard</h1>
<h1 class="m-0 text-dark">仪表板</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">Dashboard</li>
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">仪表板</li>
</ol>
</div>
</div>
@ -19,7 +19,17 @@
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h5 class="mt-4 mb-2">Streams ({{ len .streams}})</h5>
<div class="d-flex justify-content-between align-items-center">
<h5 class="mt-4 mb-2">视频流 ({{ len .streams}})</h5>
<div class="mt-4 mb-2">
<a href="/pages/cameras" class="btn btn-primary btn-sm">
<i class="fas fa-camera"></i> 摄像头管理
</a>
<a href="/pages/stream/add" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> 添加流
</a>
</div>
</div>
</div>
</div>
@ -31,7 +41,7 @@
<div class="card-header">
<h3 class="card-title one-line-header">{{.Name}}</h3>
<div class="card-tools">
<span data-toggle="tooltip" title="avaliable channels" class="badge badge-success">{{len .Channels }}</span>
<span data-toggle="tooltip" title="可用通道" class="badge badge-success">{{len .Channels }}</span>
</div>
</div>
<div class="card-body p-0">
@ -47,18 +57,18 @@
<div class="carousel-item {{ if eq $k "0"}} active {{end}}">
<img class="d-block w-100 stream-img fix-height" channel="{{$k}}" src="/../static/img/noimage.svg">
<div class="carousel-caption d-none d-md-block">
<h5>Channel: {{$k}}</h5>
<h5>通道: {{$k}}</h5>
</div>
</div>
{{end}}
</div>
<a class="carousel-control-prev" href="#carousel_{{$key}}" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
<span class="sr-only">上一个</span>
</a>
<a class="carousel-control-next" href="#carousel_{{$key}}" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
<span class="sr-only">下一个</span>
</a>
</div>
@ -70,7 +80,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> MSE</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/mse/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/mse/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -78,7 +88,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> HLS</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/hls/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/hls/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -86,7 +96,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> WebRTC</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/webrtc/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/webrtc/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -94,7 +104,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> ALL</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/all/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/all/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -107,8 +117,8 @@
<a class="btn btn-secondary btn-flat btn-xs" href="/pages/stream/edit/{{$key}}"><i class="fas fa-edit"></i> Edit</a>
<a class="btn btn-danger btn-flat btn-xs" onclick="deleteStream('{{ $key }}')" href="#"><i class="fas fa-times"></i> Delete</a>
<a class="btn btn-secondary btn-flat btn-xs" href="/pages/stream/edit/{{$key}}"><i class="fas fa-edit"></i> 编辑</a>
<a class="btn btn-danger btn-flat btn-xs" onclick="deleteStream('{{ $key }}')" href="#"><i class="fas fa-times"></i> 删除</a>
</div>
</div>
</div>

@ -3,12 +3,12 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">Streams list</h1>
<h1 class="m-0 text-dark">流列表</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">Streams list</li>
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">流列表</li>
</ol>
</div>
</div>
@ -24,7 +24,7 @@
<div class="card-header">
<h3 class="card-title one-line-header">{{.Name}}</h3>
<div class="card-tools">
<span data-toggle="tooltip" title="avaliable channels" class="badge badge-success">{{len .Channels }}</span>
<span data-toggle="tooltip" title="可用通道" class="badge badge-success">{{len .Channels }}</span>
</div>
</div>
<div class="card-body p-0">
@ -40,18 +40,18 @@
<div class="carousel-item {{ if eq $k "0"}} active {{end}}">
<img class="d-block w-100 stream-img fix-height" channel="{{$k}}" src="/../static/img/noimage.svg">
<div class="carousel-caption d-none d-md-block">
<h5>Channel: {{$k}}</h5>
<h5>通道: {{$k}}</h5>
</div>
</div>
{{end}}
</div>
<a class="carousel-control-prev" href="#carousel_{{$key}}" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
<span class="sr-only">上一个</span>
</a>
<a class="carousel-control-next" href="#carousel_{{$key}}" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
<span class="sr-only">下一个</span>
</a>
</div>
@ -63,7 +63,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> MSE</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/mse/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/mse/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -71,7 +71,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> HLS</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/hls/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/hls/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -79,7 +79,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> WebRTC</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/webrtc/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/webrtc/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -87,7 +87,7 @@
<a class="btn btn-info btn-flat btn-xs" data-toggle="dropdown" href="#"><i class="fas fa-play"></i> ALL</a>
<div class="dropdown-menu">
{{ range $k, $v := .Channels }}
<a class="dropdown-item" href="/pages/player/all/{{$key}}/{{$k}}">Channel {{$k}}</a>
<a class="dropdown-item" href="/pages/player/all/{{$key}}/{{$k}}">通道 {{$k}}</a>
{{end}}
</div>
</div>
@ -100,8 +100,8 @@
<a class="btn btn-secondary btn-flat btn-xs" href="/pages/stream/edit/{{$key}}"><i class="fas fa-edit"></i> Edit</a>
<a class="btn btn-danger btn-flat btn-xs" onclick="deleteStream('{{ $key }}')" href="#"><i class="fas fa-times"></i> Delete</a>
<a class="btn btn-secondary btn-flat btn-xs" href="/pages/stream/edit/{{$key}}"><i class="fas fa-edit"></i> 编辑</a>
<a class="btn btn-danger btn-flat btn-xs" onclick="deleteStream('{{ $key }}')" href="#"><i class="fas fa-times"></i> 删除</a>
</div>
</div>
</div>

Loading…
Cancel
Save