mirror of
https://github.com/gowvp/gb28181.git
synced 2026-04-22 23:17:19 +08:00
246 lines
8.3 KiB
Go
Executable File
246 lines
8.3 KiB
Go
Executable File
package api
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gowvp/owl/internal/conf"
|
||
"github.com/gowvp/owl/internal/core/recording"
|
||
"github.com/gowvp/owl/internal/core/recording/store/recordingdb"
|
||
"github.com/grafov/m3u8"
|
||
"github.com/ixugo/goddd/pkg/orm"
|
||
"github.com/ixugo/goddd/pkg/web"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// RecordingAPI 为 http 提供业务方法
|
||
type RecordingAPI struct {
|
||
recordingCore recording.Core
|
||
conf *conf.Bootstrap
|
||
}
|
||
|
||
// NewRecordingStore 创建录像存储层
|
||
func NewRecordingStore(db *gorm.DB) recording.Storer {
|
||
return recordingdb.NewDB(db).AutoMigrate(orm.GetEnabledAutoMigrate())
|
||
}
|
||
|
||
// NewRecordingCore 创建录像管理核心服务
|
||
// 依赖 recording.SMSProvider 接口而非 sms.Core,避免循环依赖
|
||
func NewRecordingCore(store recording.Storer, cfg *conf.Bootstrap, provider recording.SMSProvider) recording.Core {
|
||
core := recording.NewCore(store,
|
||
recording.WithConfig(&cfg.Server.Recording),
|
||
recording.WithSMSProvider(provider),
|
||
)
|
||
|
||
// 启动清理协程
|
||
go core.StartCleanupWorker()
|
||
|
||
return core
|
||
}
|
||
|
||
func NewRecordingAPI(core recording.Core, conf *conf.Bootstrap) RecordingAPI {
|
||
return RecordingAPI{recordingCore: core, conf: conf}
|
||
}
|
||
|
||
func RegisterRecording(g gin.IRouter, api RecordingAPI, handler ...gin.HandlerFunc) {
|
||
{
|
||
group := g.Group("/recordings", handler...)
|
||
group.GET("", web.WrapH(api.findRecordings))
|
||
group.GET("/timeline", web.WrapH(api.getTimeline))
|
||
group.GET("/monthly", web.WrapH(api.getMonthlyStats))
|
||
// HLS 播放列表(根据通道 ID 和时间范围生成 m3u8)
|
||
group.GET("/channels/:cid/index.m3u8", api.channelPlaylist)
|
||
group.GET("/:id", web.WrapH(api.getRecording))
|
||
group.PUT("/:id", web.WrapH(api.editRecording))
|
||
group.DELETE("/:id", web.WrapH(api.delRecording))
|
||
group.GET("/:id/download", api.downloadRecording)
|
||
}
|
||
|
||
// 静态文件服务,用于访问录像 MP4 文件
|
||
// 路径格式: /static/recordings/xxx.mp4?token=xxx
|
||
// Gin Static 支持 HTTP Range 请求,实现边下载边播放(秒播)
|
||
if api.conf != nil && api.conf.Server.Recording.StorageDir != "" {
|
||
slog.Info("注册录像静态文件服务", "path", "/static/recordings", "dir", api.conf.Server.Recording.StorageDir)
|
||
g.Group("/static", handler...).Static("/recordings", api.conf.Server.Recording.StorageDir)
|
||
}
|
||
}
|
||
|
||
// findRecordings 分页查询录像列表
|
||
func (a RecordingAPI) findRecordings(c *gin.Context, in *recording.FindRecordingInput) (any, error) {
|
||
ctx := web.WithContext(c.Request)
|
||
items, total, err := a.recordingCore.FindRecordings(ctx, in)
|
||
return gin.H{"items": items, "total": total}, err
|
||
}
|
||
|
||
// getTimeline 获取时间轴数据
|
||
func (a RecordingAPI) getTimeline(c *gin.Context, in *recording.TimelineInput) (any, error) {
|
||
items, err := a.recordingCore.GetTimeline(c.Request.Context(), in)
|
||
return gin.H{"items": items}, err
|
||
}
|
||
|
||
func (a RecordingAPI) getRecording(c *gin.Context, _ *struct{}) (*recording.Recording, error) {
|
||
recordingID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
return a.recordingCore.GetRecording(c.Request.Context(), recordingID)
|
||
}
|
||
|
||
func (a RecordingAPI) editRecording(c *gin.Context, in *recording.EditRecordingInput) (*recording.Recording, error) {
|
||
recordingID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
return a.recordingCore.EditRecording(c.Request.Context(), in, recordingID)
|
||
}
|
||
|
||
func (a RecordingAPI) delRecording(c *gin.Context, _ *struct{}) (*recording.Recording, error) {
|
||
recordingID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
return a.recordingCore.DelRecording(c.Request.Context(), recordingID)
|
||
}
|
||
|
||
// getMonthlyStats 获取月度录像统计
|
||
func (a RecordingAPI) getMonthlyStats(c *gin.Context, in *recording.MonthlyStatsInput) (*recording.MonthlyStatsOutput, error) {
|
||
return a.recordingCore.GetMonthlyStats(c.Request.Context(), in)
|
||
}
|
||
|
||
// downloadRecording 下载录像文件
|
||
func (a RecordingAPI) downloadRecording(c *gin.Context) {
|
||
recordingID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "invalid recording id"})
|
||
return
|
||
}
|
||
|
||
rec, err := a.recordingCore.GetRecording(c.Request.Context(), recordingID)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 构建文件完整路径
|
||
filePath := a.recordingCore.GetFullPath(rec.Path)
|
||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "recording file not found"})
|
||
return
|
||
}
|
||
|
||
// 设置下载文件名
|
||
fileName := filepath.Base(filePath)
|
||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||
c.File(filePath)
|
||
}
|
||
|
||
// channelPlaylist 生成 HLS m3u8 播放列表
|
||
// 根据通道 ID 和时间范围,动态生成包含多个 MP4 片段的 m3u8 文件
|
||
// 路径: /recordings/channels/:cid/index.m3u8?start_ms=xxx&end_ms=xxx&token=xxx
|
||
func (a RecordingAPI) channelPlaylist(c *gin.Context) {
|
||
cid := c.Param("cid")
|
||
if cid == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "cid is required"})
|
||
return
|
||
}
|
||
|
||
startMs, _ := strconv.ParseInt(c.Query("start_ms"), 10, 64)
|
||
endMs, _ := strconv.ParseInt(c.Query("end_ms"), 10, 64)
|
||
token := c.Query("token")
|
||
|
||
if startMs <= 0 || endMs <= 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "start_ms and end_ms are required"})
|
||
return
|
||
}
|
||
|
||
// 获取时间范围内的录像列表(需要完整路径信息)
|
||
ctx := web.WithContext(c.Request)
|
||
recordings, _, err := a.recordingCore.FindRecordings(ctx, &recording.FindRecordingInput{
|
||
CID: cid,
|
||
PagerFilter: web.PagerFilter{Page: 1, Size: 10000},
|
||
DateFilter: web.DateFilter{StartMs: startMs, EndMs: endMs},
|
||
})
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": err.Error()})
|
||
return
|
||
}
|
||
|
||
if len(recordings) == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "no recordings found in time range"})
|
||
return
|
||
}
|
||
|
||
// 构建请求的 base URL
|
||
scheme := "http"
|
||
if c.Request.TLS != nil {
|
||
scheme = "https"
|
||
}
|
||
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||
|
||
// 生成 m3u8 内容(带 token)
|
||
m3u8Content := a.generateM3U8WithToken(recordings, baseURL, token)
|
||
|
||
c.Header("Content-Type", "application/vnd.apple.mpegurl")
|
||
c.Header("Cache-Control", "no-cache")
|
||
c.String(http.StatusOK, m3u8Content)
|
||
}
|
||
|
||
// generateM3U8WithToken 根据录像列表生成 m3u8 播放列表(每个 MP4 URL 带 token)
|
||
func (a RecordingAPI) generateM3U8WithToken(recordings []*recording.Recording, baseURL, token string) string {
|
||
count := len(recordings)
|
||
if count == 0 {
|
||
return ""
|
||
}
|
||
|
||
// 创建媒体播放列表 (winSize=0 表示 VOD,不使用滑动窗口)
|
||
pl, err := m3u8.NewMediaPlaylist(0, uint(count))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
|
||
// 设置为 VOD 类型
|
||
pl.MediaType = m3u8.VOD
|
||
|
||
// 录像按时间升序排列
|
||
sortedRecs := make([]*recording.Recording, len(recordings))
|
||
copy(sortedRecs, recordings)
|
||
// 按开始时间升序排序
|
||
for i := 0; i < len(sortedRecs)-1; i++ {
|
||
for j := i + 1; j < len(sortedRecs); j++ {
|
||
if sortedRecs[i].StartedAt.After(sortedRecs[j].StartedAt.Time) {
|
||
sortedRecs[i], sortedRecs[j] = sortedRecs[j], sortedRecs[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
// 添加每个录像片段
|
||
// URL 格式: /static/recordings/{path}?token=xxx
|
||
// 使用相对路径(以 / 开头),让浏览器相对于当前域名访问
|
||
// 这样无论通过代理还是直接访问都能正常工作
|
||
// ZLM 录制的 fMP4 每个文件 DTS 都从 0 开始,必须在每个片段间添加 DISCONTINUITY
|
||
// 告诉 HLS.js 重置解码器,避免 DTS 不连续导致的解析错误
|
||
for i, rec := range sortedRecs {
|
||
// 每个片段之间都添加 EXT-X-DISCONTINUITY 标签
|
||
// ZLM 每个录像文件都是独立的 fMP4,DTS 从 0 开始,必须重置解码器
|
||
if i > 0 {
|
||
pl.SetDiscontinuity()
|
||
}
|
||
|
||
// 构建相对路径,去掉前导斜杠
|
||
relativePath := strings.TrimPrefix(rec.Path, "/")
|
||
|
||
// 使用相对路径(不带域名),让浏览器根据当前页面域名访问
|
||
// 这样开发时通过 Vite 代理、生产时通过后端都能正常访问
|
||
var uri string
|
||
if token != "" {
|
||
uri = fmt.Sprintf("/static/recordings/%s?token=%s", relativePath, token)
|
||
} else {
|
||
uri = fmt.Sprintf("/static/recordings/%s", relativePath)
|
||
}
|
||
_ = pl.Append(uri, rec.Duration, "")
|
||
}
|
||
|
||
// 关闭播放列表,添加 #EXT-X-ENDLIST 标签
|
||
pl.Close()
|
||
|
||
// 编码为字符串
|
||
return pl.String()
|
||
}
|