Files
silk/server-go/internal/handler/video_record.go
T

289 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"silk-server-go/internal/config"
"silk-server-go/internal/middleware"
"silk-server-go/internal/model"
"silk-server-go/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ActiveRecording 活跃录制记录(内存维护)
type ActiveRecording struct {
CameraID string `json:"cameraId"`
DeviceID string `json:"deviceId"`
ChannelID string `json:"channelId"`
Stream string `json:"stream"`
App string `json:"app"`
StartedAt time.Time `json:"startedAt"`
}
// 活跃录制内存表(cameraId -> recording
var (
activeRecordings = make(map[string]*ActiveRecording)
activeMu sync.Mutex
)
// RegisterVideoRecordRoutes 注册录制管理路由
func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.MediaService, cfg *config.Config) {
recordPerm := middleware.RequirePermission(db, "video:record")
readPerm := middleware.RequirePermission(db, "video:read")
rg.POST("/video/cameras/:id/record/start", recordPerm, startRecording(db, media, cfg))
rg.POST("/video/cameras/:id/record/stop", recordPerm, stopRecording(db, media, cfg))
rg.GET("/video/recordings/active", readPerm, listActiveRecordings(cfg))
rg.POST("/video/recordings/internal/end", endRecordingInternal(media, cfg)) // 白名单接口,无需权限
}
// startRecording 开始录制:查摄像头 → media.StartPlay → POST recorderApiBase/record/start
func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
cameraId := c.Param("id")
var camera model.Camera
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
return
}
if camera.GbDeviceID == nil || camera.GbChannelID == nil ||
*camera.GbDeviceID == "" || *camera.GbChannelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "摄像头未配置 GB28181 或不存在"})
return
}
// 检查是否已在录制
activeMu.Lock()
if _, ok := activeRecordings[cameraId]; ok {
activeMu.Unlock()
c.JSON(http.StatusConflict, gin.H{"error": "该摄像头正在录制中"})
return
}
activeMu.Unlock()
// 1. 确保视频流在线(WVP play/start
_, err := media.StartPlay(*camera.GbDeviceID, *camera.GbChannelID)
if err != nil {
slog.Warn("开始播放失败,跳过录制", "cameraId", cameraId, "error", err)
// StartPlay 失败,标记摄像头离线
db.Model(&model.Camera{}).Where("id = ?", cameraId).Update("is_online", false)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "摄像头可能离线: " + err.Error()})
return
}
// 2. 通知 Python 录制服务开始录制
stream := *camera.GbDeviceID + "_" + *camera.GbChannelID
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
payload, _ := json.Marshal(map[string]string{
"stream": stream,
"cameraId": cameraId,
"deviceId": *camera.GbDeviceID,
"channelId": *camera.GbChannelID,
})
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(recorderBase+"/record/start", "application/json", bytes.NewReader(payload))
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "录制服务不可用: " + err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": fmt.Sprintf("录制服务返回 %d: %s", resp.StatusCode, string(respBody))})
return
}
// 3. 记录活跃录制
rec := &ActiveRecording{
CameraID: cameraId,
DeviceID: *camera.GbDeviceID,
ChannelID: *camera.GbChannelID,
Stream: stream,
App: "rtp",
StartedAt: time.Now(),
}
activeMu.Lock()
activeRecordings[cameraId] = rec
activeMu.Unlock()
slog.Info("录制已开始", "cameraId", cameraId, "stream", stream)
c.JSON(http.StatusOK, gin.H{
"cameraId": cameraId,
"stream": stream,
"startedAt": rec.StartedAt,
})
}
}
// stopRecording 停止录制:通知录制服务 + WVP play/stop 清理 session
func stopRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
cameraId := c.Param("id")
activeMu.Lock()
rec, ok := activeRecordings[cameraId]
if ok {
delete(activeRecordings, cameraId)
}
activeMu.Unlock()
// 即使没有活跃录制,也尝试用摄像头的 GB 信息停止 WVP play
var deviceID, channelID string
if ok {
deviceID = rec.DeviceID
channelID = rec.ChannelID
} else {
var camera model.Camera
if db.Where("id = ?", cameraId).First(&camera).Error == nil &&
camera.GbDeviceID != nil && camera.GbChannelID != nil {
deviceID = *camera.GbDeviceID
channelID = *camera.GbChannelID
}
}
// 1. 通知录制服务停止
if ok {
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
payload, _ := json.Marshal(map[string]string{
"stream": rec.Stream,
})
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(recorderBase+"/record/stop", "application/json", bytes.NewReader(payload))
if err != nil {
slog.Warn("停止录制服务失败", "cameraId", cameraId, "error", err)
} else {
resp.Body.Close()
}
}
// 2. 停止 WVP play session(清理 ZLMediaKit 流)
if deviceID != "" && channelID != "" {
media.StopPlay(deviceID, channelID)
}
slog.Info("录制已停止", "cameraId", cameraId, "hadActiveRecording", ok)
c.JSON(http.StatusOK, gin.H{
"cameraId": cameraId,
"stopped": true,
})
}
}
// endRecordingInternal 录制服务内部接口:通知录制已结束(流 EOF / 错误 / 停止)
// 清理 activeRecordings 并停止 WVP play session
func endRecordingInternal(media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := c.GetHeader("x-api-key")
if apiKey != cfg.InternalAPIKey {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
return
}
var body struct {
Stream string `json:"stream"`
CameraID string `json:"cameraId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
activeMu.Lock()
rec, ok := activeRecordings[body.CameraID]
if ok {
delete(activeRecordings, body.CameraID)
}
activeMu.Unlock()
if ok {
slog.Info("录制服务通知录制结束", "cameraId", body.CameraID, "stream", body.Stream)
// 停止 WVP play session,清理 ZLMediaKit 流
media.StopPlay(rec.DeviceID, rec.ChannelID)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// listActiveRecordings 返回活跃录制列表
// 优先以 recorder-go 服务的实际状态为准(Go 后端重启后内存 map 会丢失,但 recorder-go 仍在录制)
func listActiveRecordings(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 查询 recorder-go 实际录制状态
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(recorderBase + "/record/status")
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var statusResp struct {
Recordings []struct {
Stream string `json:"stream"`
CameraID string `json:"cameraId"`
StartedAt string `json:"startedAt"`
TotalBytes int64 `json:"totalBytes"`
SegmentCount int `json:"segmentCount"`
} `json:"recordings"`
}
if err := json.NewDecoder(resp.Body).Decode(&statusResp); err == nil && len(statusResp.Recordings) > 0 {
list := make([]*ActiveRecording, 0, len(statusResp.Recordings))
for _, r := range statusResp.Recordings {
startedAt, _ := time.Parse(time.RFC3339, r.StartedAt)
if startedAt.IsZero() {
startedAt = time.Now()
}
// 拆分 stream 得到 deviceId/channelId
deviceID, channelID := "", ""
parts := strings.SplitN(r.Stream, "_", 2)
if len(parts) == 2 {
deviceID, channelID = parts[0], parts[1]
}
list = append(list, &ActiveRecording{
CameraID: r.CameraID,
DeviceID: deviceID,
ChannelID: channelID,
Stream: r.Stream,
App: "rtp",
StartedAt: startedAt,
})
// 同步更新内存 map(自愈:Go 后端重启后内存丢失,从 recorder-go 恢复)
activeMu.Lock()
if _, ok := activeRecordings[r.CameraID]; !ok {
activeRecordings[r.CameraID] = &ActiveRecording{
CameraID: r.CameraID,
DeviceID: deviceID,
ChannelID: channelID,
Stream: r.Stream,
App: "rtp",
StartedAt: startedAt,
}
}
activeMu.Unlock()
}
c.JSON(http.StatusOK, list)
return
}
}
}
// 2. recorder-go 不可用时回退到内存 map
activeMu.Lock()
list := make([]*ActiveRecording, 0, len(activeRecordings))
for _, rec := range activeRecordings {
list = append(list, rec)
}
activeMu.Unlock()
c.JSON(http.StatusOK, list)
}
}