mirror of
https://github.com/langhuihui/monibuca.git
synced 2025-12-24 13:48:04 +08:00
Compare commits
8 Commits
dev
...
feat-mp42t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77613e52a8 | ||
|
|
ec56bba75a | ||
|
|
b2b511d755 | ||
|
|
42acf47250 | ||
|
|
6206ee847d | ||
|
|
6cfdc03e4a | ||
|
|
b425b8da1f | ||
|
|
e105243cd5 |
6
api.go
6
api.go
@@ -184,7 +184,7 @@ func (s *Server) StreamInfo(ctx context.Context, req *pb.StreamSnapRequest) (res
|
||||
if record.StreamPath == req.StreamPath {
|
||||
recordings = append(recordings, &pb.RecordingDetail{
|
||||
FilePath: record.RecConf.FilePath,
|
||||
Mode: record.Mode,
|
||||
Mode: record.RecConf.Mode,
|
||||
Fragment: durationpb.New(record.RecConf.Fragment),
|
||||
Append: record.RecConf.Append,
|
||||
PluginName: record.Plugin.Meta.Name,
|
||||
@@ -554,7 +554,7 @@ func (s *Server) StreamList(_ context.Context, req *pb.StreamListRequest) (res *
|
||||
for record := range s.Records.SafeRange {
|
||||
recordingMap[record.StreamPath] = append(recordingMap[record.StreamPath], &pb.RecordingDetail{
|
||||
FilePath: record.RecConf.FilePath,
|
||||
Mode: record.Mode,
|
||||
Mode: record.RecConf.Mode,
|
||||
Fragment: durationpb.New(record.RecConf.Fragment),
|
||||
Append: record.RecConf.Append,
|
||||
PluginName: record.Plugin.Meta.Name,
|
||||
@@ -750,7 +750,7 @@ func (s *Server) GetRecordList(ctx context.Context, req *pb.ReqRecordList) (resp
|
||||
offset := (req.PageNum - 1) * req.PageSize // 计算偏移量
|
||||
var totalCount int64 //总条数
|
||||
|
||||
var result []*RecordStream
|
||||
var result []*EventRecordStream
|
||||
query := s.DB.Model(&RecordStream{})
|
||||
if strings.Contains(req.StreamPath, "*") {
|
||||
query = query.Where("stream_path like ?", strings.ReplaceAll(req.StreamPath, "*", "%"))
|
||||
|
||||
@@ -664,7 +664,7 @@ message ReqRecordList {
|
||||
string end = 4;
|
||||
uint32 pageNum = 5;
|
||||
uint32 pageSize = 6;
|
||||
string mode = 7;
|
||||
string eventId = 7;
|
||||
string type = 8;
|
||||
string eventLevel = 9;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ const (
|
||||
RelayModeRelay = "relay"
|
||||
RelayModeMix = "mix"
|
||||
|
||||
RecordModeAuto RecordMode = "auto"
|
||||
RecordModeEvent RecordMode = "event"
|
||||
|
||||
HookOnServerKeepAlive HookType = "server_keep_alive"
|
||||
HookOnPublishStart HookType = "publish_start"
|
||||
HookOnPublishEnd HookType = "publish_end"
|
||||
@@ -29,11 +32,16 @@ const (
|
||||
HookOnRecordEnd HookType = "record_end"
|
||||
HookOnTransformStart HookType = "transform_start"
|
||||
HookOnTransformEnd HookType = "transform_end"
|
||||
|
||||
EventLevelLow EventLevel = "low"
|
||||
EventLevelHigh EventLevel = "high"
|
||||
)
|
||||
|
||||
type (
|
||||
HookType string
|
||||
Publish struct {
|
||||
EventLevel = string
|
||||
RecordMode = string
|
||||
HookType string
|
||||
Publish struct {
|
||||
MaxCount int `default:"0" desc:"最大发布者数量"` // 最大发布者数量
|
||||
PubAudio bool `default:"true" desc:"是否发布音频"`
|
||||
PubVideo bool `default:"true" desc:"是否发布视频"`
|
||||
@@ -84,11 +92,21 @@ type (
|
||||
Proxy string `desc:"代理地址"` // 代理地址
|
||||
Header HTTPValues
|
||||
}
|
||||
RecordEvent struct {
|
||||
EventId string
|
||||
BeforeDuration uint32 `json:"beforeDuration" desc:"事件前缓存时长" gorm:"comment:事件前缓存时长;default:30000"`
|
||||
AfterDuration uint32 `json:"afterDuration" desc:"事件后缓存时长" gorm:"comment:事件后缓存时长;default:30000"`
|
||||
EventDesc string `json:"eventDesc" desc:"事件描述" gorm:"type:varchar(255);comment:事件描述"`
|
||||
EventLevel EventLevel `json:"eventLevel" desc:"事件级别" gorm:"type:varchar(255);comment:事件级别,high表示重要事件,无法删除且表示无需自动删除,low表示非重要事件,达到自动删除时间后,自动删除;default:'low'"`
|
||||
EventName string `json:"eventName" desc:"事件名称" gorm:"type:varchar(255);comment:事件名称"`
|
||||
}
|
||||
Record struct {
|
||||
Type string `desc:"录制类型"` // 录制类型 mp4、flv、hls、hlsv7
|
||||
FilePath string `desc:"录制文件路径"` // 录制文件路径
|
||||
Fragment time.Duration `desc:"分片时长"` // 分片时长
|
||||
Append bool `desc:"是否追加录制"` // 是否追加录制
|
||||
Mode RecordMode `json:"mode" desc:"事件类型,auto=连续录像模式,event=事件录像模式" gorm:"type:varchar(255);comment:事件类型,auto=连续录像模式,event=事件录像模式;default:'auto'"`
|
||||
Type string `desc:"录制类型"` // 录制类型 mp4、flv、hls、hlsv7
|
||||
FilePath string `desc:"录制文件路径"` // 录制文件路径
|
||||
Fragment time.Duration `desc:"分片时长"` // 分片时长
|
||||
Append bool `desc:"是否追加录制"` // 是否追加录制
|
||||
Event *RecordEvent `json:"event" desc:"事件录像配置" gorm:"-"` // 事件录像配置
|
||||
}
|
||||
TransfromOutput struct {
|
||||
Target string `desc:"转码目标"` // 转码目标
|
||||
|
||||
@@ -9,14 +9,11 @@ import (
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Username string `gorm:"uniqueIndex;size:64"`
|
||||
Password string `gorm:"size:60"` // bcrypt hash
|
||||
Role string `gorm:"size:20;default:'user'"` // admin or user
|
||||
LastLogin time.Time `gorm:"type:datetime;default:CURRENT_TIMESTAMP"`
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;size:64"`
|
||||
Password string `gorm:"size:60"` // bcrypt hash
|
||||
Role string `gorm:"size:20;default:'user'"` // admin or user
|
||||
LastLogin time.Time `gorm:"type:timestamp;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to hash password before saving
|
||||
|
||||
@@ -156,6 +156,10 @@ func (plugin *PluginMeta) Init(s *Server, userConfig map[string]any) (p *Plugin)
|
||||
p.disable(fmt.Sprintf("auto migrate record stream failed %v", err))
|
||||
return
|
||||
}
|
||||
if err = p.DB.AutoMigrate(&EventRecordStream{}); err != nil {
|
||||
p.disable(fmt.Sprintf("auto migrate event record stream failed %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.AddTask(instance).WaitStarted(); err != nil {
|
||||
p.disable(instance.StopReason().Error())
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"m7s.live/v5"
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/config"
|
||||
@@ -144,7 +143,6 @@ func NewRecorder(conf config.Record) m7s.IRecorder {
|
||||
|
||||
type Recorder struct {
|
||||
m7s.DefaultRecorder
|
||||
stream m7s.RecordStream
|
||||
}
|
||||
|
||||
var CustomFileName = func(job *m7s.RecordJob) string {
|
||||
@@ -155,48 +153,21 @@ var CustomFileName = func(job *m7s.RecordJob) string {
|
||||
}
|
||||
|
||||
func (r *Recorder) createStream(start time.Time) (err error) {
|
||||
recordJob := &r.RecordJob
|
||||
sub := recordJob.Subscriber
|
||||
r.stream = m7s.RecordStream{
|
||||
StartTime: start,
|
||||
StreamPath: sub.StreamPath,
|
||||
FilePath: CustomFileName(&r.RecordJob),
|
||||
EventId: recordJob.EventId,
|
||||
EventDesc: recordJob.EventDesc,
|
||||
EventName: recordJob.EventName,
|
||||
EventLevel: recordJob.EventLevel,
|
||||
BeforeDuration: recordJob.BeforeDuration,
|
||||
AfterDuration: recordJob.AfterDuration,
|
||||
Mode: recordJob.Mode,
|
||||
Type: "flv",
|
||||
}
|
||||
dir := filepath.Dir(r.stream.FilePath)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
if sub.Publisher.HasAudioTrack() {
|
||||
r.stream.AudioCodec = sub.Publisher.AudioTrack.ICodecCtx.String()
|
||||
}
|
||||
if sub.Publisher.HasVideoTrack() {
|
||||
r.stream.VideoCodec = sub.Publisher.VideoTrack.ICodecCtx.String()
|
||||
}
|
||||
if recordJob.Plugin.DB != nil {
|
||||
recordJob.Plugin.DB.Save(&r.stream)
|
||||
}
|
||||
return
|
||||
return r.CreateStream(start, CustomFileName)
|
||||
}
|
||||
|
||||
func (r *Recorder) writeTailer(end time.Time) {
|
||||
if r.stream.EndTime.After(r.stream.StartTime) {
|
||||
if r.Event.EndTime.After(r.Event.StartTime) {
|
||||
return
|
||||
}
|
||||
r.stream.EndTime = end
|
||||
r.Event.EndTime = end
|
||||
if r.RecordJob.Plugin.DB != nil {
|
||||
r.RecordJob.Plugin.DB.Save(&r.stream)
|
||||
writeMetaTagQueueTask.AddTask(&eventRecordCheck{
|
||||
DB: r.RecordJob.Plugin.DB,
|
||||
streamPath: r.stream.StreamPath,
|
||||
})
|
||||
if r.RecordJob.Event != nil {
|
||||
r.RecordJob.Plugin.DB.Save(&r.Event)
|
||||
} else {
|
||||
r.RecordJob.Plugin.DB.Save(&r.Event.RecordStream)
|
||||
}
|
||||
writeMetaTagQueueTask.AddTask(m7s.NewEventRecordCheck(r.Event.Type, r.Event.StreamPath, r.RecordJob.Plugin.DB))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,40 +175,6 @@ func (r *Recorder) Dispose() {
|
||||
r.writeTailer(time.Now())
|
||||
}
|
||||
|
||||
type eventRecordCheck struct {
|
||||
task.Task
|
||||
DB *gorm.DB
|
||||
streamPath string
|
||||
}
|
||||
|
||||
func (t *eventRecordCheck) Run() (err error) {
|
||||
var eventRecordStreams []m7s.RecordStream
|
||||
queryRecord := m7s.RecordStream{
|
||||
EventLevel: m7s.EventLevelHigh,
|
||||
Mode: m7s.RecordModeEvent,
|
||||
Type: "flv",
|
||||
}
|
||||
t.DB.Where(&queryRecord).Find(&eventRecordStreams, "stream_path=?", t.streamPath) //搜索事件录像,且为重要事件(无法自动删除)
|
||||
if len(eventRecordStreams) > 0 {
|
||||
for _, recordStream := range eventRecordStreams {
|
||||
var unimportantEventRecordStreams []m7s.RecordStream
|
||||
queryRecord.EventLevel = m7s.EventLevelLow
|
||||
query := `(start_time BETWEEN ? AND ?)
|
||||
OR (end_time BETWEEN ? AND ?)
|
||||
OR (? BETWEEN start_time AND end_time)
|
||||
OR (? BETWEEN start_time AND end_time) AND stream_path=? `
|
||||
t.DB.Where(&queryRecord).Where(query, recordStream.StartTime, recordStream.EndTime, recordStream.StartTime, recordStream.EndTime, recordStream.StartTime, recordStream.EndTime, recordStream.StreamPath).Find(&unimportantEventRecordStreams)
|
||||
if len(unimportantEventRecordStreams) > 0 {
|
||||
for _, unimportantEventRecordStream := range unimportantEventRecordStreams {
|
||||
unimportantEventRecordStream.EventLevel = m7s.EventLevelHigh
|
||||
t.DB.Save(&unimportantEventRecordStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Recorder) Run() (err error) {
|
||||
var file *os.File
|
||||
var filepositions []uint64
|
||||
@@ -248,14 +185,14 @@ func (r *Recorder) Run() (err error) {
|
||||
suber := ctx.Subscriber
|
||||
noFragment := ctx.RecConf.Fragment == 0 || ctx.RecConf.Append
|
||||
startTime := time.Now()
|
||||
if ctx.BeforeDuration > 0 {
|
||||
startTime = startTime.Add(-ctx.BeforeDuration)
|
||||
if ctx.Event.BeforeDuration > 0 {
|
||||
startTime = startTime.Add(-time.Duration(ctx.Event.BeforeDuration) * time.Millisecond)
|
||||
}
|
||||
if err = r.createStream(startTime); err != nil {
|
||||
return
|
||||
}
|
||||
if noFragment {
|
||||
file, err = os.OpenFile(r.stream.FilePath, os.O_CREATE|os.O_RDWR|util.Conditional(ctx.RecConf.Append, os.O_APPEND, os.O_TRUNC), 0666)
|
||||
file, err = os.OpenFile(r.Event.FilePath, os.O_CREATE|os.O_RDWR|util.Conditional(ctx.RecConf.Append, os.O_APPEND, os.O_TRUNC), 0666)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -291,7 +228,7 @@ func (r *Recorder) Run() (err error) {
|
||||
} else if ctx.RecConf.Fragment == 0 {
|
||||
_, err = file.Write(FLVHead)
|
||||
} else {
|
||||
if file, err = os.OpenFile(r.stream.FilePath, os.O_CREATE|os.O_RDWR, 0666); err != nil {
|
||||
if file, err = os.OpenFile(r.Event.FilePath, os.O_CREATE|os.O_RDWR, 0666); err != nil {
|
||||
return
|
||||
}
|
||||
_, err = file.Write(FLVHead)
|
||||
@@ -307,7 +244,7 @@ func (r *Recorder) Run() (err error) {
|
||||
if err = r.createStream(time.Now()); err != nil {
|
||||
return
|
||||
}
|
||||
if file, err = os.OpenFile(r.stream.FilePath, os.O_CREATE|os.O_RDWR, 0666); err != nil {
|
||||
if file, err = os.OpenFile(r.Event.FilePath, os.O_CREATE|os.O_RDWR, 0666); err != nil {
|
||||
return
|
||||
}
|
||||
_, err = file.Write(FLVHead)
|
||||
|
||||
@@ -1873,8 +1873,8 @@ func (gb *GB28181Plugin) GetGroupChannels(ctx context.Context, req *pb.GetGroupC
|
||||
Select(`
|
||||
IFNULL(gc.id, 0) AS id,
|
||||
dc.channel_id,
|
||||
dc.device_id,
|
||||
dc.name AS channel_name,
|
||||
d.device_id AS device_id,
|
||||
d.name AS device_name,
|
||||
dc.status AS status,
|
||||
CASE
|
||||
@@ -1883,11 +1883,11 @@ func (gb *GB28181Plugin) GetGroupChannels(ctx context.Context, req *pb.GetGroupC
|
||||
END AS in_group
|
||||
`).
|
||||
Joins("LEFT JOIN "+deviceTable+" AS d ON dc.device_id = d.device_id").
|
||||
Joins("LEFT JOIN "+groupsChannelTable+" AS gc ON dc.channel_id = gc.channel_id AND gc.group_id = ?", req.GroupId)
|
||||
Joins("LEFT JOIN "+groupsChannelTable+" AS gc ON dc.channel_id = gc.channel_id AND dc.device_id = gc.device_id AND gc.group_id = ?", req.GroupId)
|
||||
|
||||
// 如果有设备ID过滤条件
|
||||
if req.DeviceId != "" {
|
||||
baseQuery = baseQuery.Where("d.device_id = ?", req.DeviceId)
|
||||
baseQuery = baseQuery.Where("dc.device_id = ?", req.DeviceId)
|
||||
}
|
||||
|
||||
// 统计符合条件的通道总数
|
||||
@@ -1903,7 +1903,7 @@ func (gb *GB28181Plugin) GetGroupChannels(ctx context.Context, req *pb.GetGroupC
|
||||
query := baseQuery
|
||||
|
||||
// 添加排序
|
||||
query = query.Order("channel_id ASC")
|
||||
query = query.Order("dc.device_id ASC, dc.channel_id ASC")
|
||||
|
||||
// 如果指定了分页参数,则应用分页
|
||||
if req.Page > 0 && req.Count > 0 {
|
||||
@@ -1922,12 +1922,14 @@ func (gb *GB28181Plugin) GetGroupChannels(ctx context.Context, req *pb.GetGroupC
|
||||
var pbGroupChannels []*pb.GroupChannel
|
||||
for _, result := range results {
|
||||
channelInfo := &pb.GroupChannel{
|
||||
Id: int32(result.ID),
|
||||
GroupId: req.GroupId,
|
||||
ChannelId: result.ChannelID,
|
||||
DeviceId: result.DeviceID,
|
||||
ChannelName: result.ChannelName,
|
||||
DeviceName: result.DeviceName,
|
||||
Status: result.Status,
|
||||
InGroup: result.InGroup, // 设置inGroup字段
|
||||
InGroup: result.InGroup,
|
||||
}
|
||||
|
||||
// 从内存中获取设备信息以获取传输协议
|
||||
@@ -1935,13 +1937,6 @@ func (gb *GB28181Plugin) GetGroupChannels(ctx context.Context, req *pb.GetGroupC
|
||||
channelInfo.StreamMode = device.StreamMode
|
||||
}
|
||||
|
||||
if result.InGroup {
|
||||
channelInfo.Id = int32(result.ID)
|
||||
channelInfo.GroupId = int32(req.GroupId)
|
||||
} else {
|
||||
channelInfo.Id = 0
|
||||
}
|
||||
|
||||
pbGroupChannels = append(pbGroupChannels, channelInfo)
|
||||
}
|
||||
|
||||
@@ -2082,19 +2077,19 @@ func (gb *GB28181Plugin) getGroupChannels(groupId int32) ([]*pb.GroupChannel, er
|
||||
InGroup bool `gorm:"column:in_group"`
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
// 构建优化后的查询
|
||||
query := gb.DB.Table(groupsChannelTable+" AS gc").
|
||||
Select(`
|
||||
gc.id AS id,
|
||||
gc.channel_id AS channel_id,
|
||||
gc.device_id AS device_id,
|
||||
dc.name AS channel_name,
|
||||
d.name AS device_name,
|
||||
dc.status AS status,
|
||||
ch.name AS channel_name,
|
||||
dev.name AS device_name,
|
||||
ch.status AS status,
|
||||
true AS in_group
|
||||
`).
|
||||
Joins("LEFT JOIN "+deviceChannelTable+" AS dc ON gc.channel_id = dc.channel_id").
|
||||
Joins("LEFT JOIN "+deviceTable+" AS d ON gc.device_id = d.device_id").
|
||||
Joins("LEFT JOIN "+deviceChannelTable+" AS ch ON gc.device_id = ch.device_id AND gc.channel_id = ch.channel_id").
|
||||
Joins("LEFT JOIN "+deviceTable+" AS dev ON ch.device_id = dev.device_id").
|
||||
Where("gc.group_id = ?", groupId)
|
||||
|
||||
var results []Result
|
||||
@@ -2107,7 +2102,7 @@ func (gb *GB28181Plugin) getGroupChannels(groupId int32) ([]*pb.GroupChannel, er
|
||||
for _, result := range results {
|
||||
channelInfo := &pb.GroupChannel{
|
||||
Id: int32(result.ID),
|
||||
GroupId: groupId,
|
||||
GroupId: groupId, // 使用函数参数 groupId
|
||||
ChannelId: result.ChannelID,
|
||||
DeviceId: result.DeviceID,
|
||||
ChannelName: result.ChannelName,
|
||||
@@ -2868,7 +2863,7 @@ func (gb *GB28181Plugin) RemoveDevice(ctx context.Context, req *pb.RemoveDeviceR
|
||||
}
|
||||
|
||||
// 删除设备关联的通道
|
||||
if err := tx.Delete(&gb28181.DeviceChannel{DeviceID: req.Id}).Error; err != nil {
|
||||
if err := tx.Where("device_id = ?", req.Id).Delete(&gb28181.DeviceChannel{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
resp.Code = 500
|
||||
resp.Message = "删除设备通道失败"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -99,15 +100,20 @@ func (d *Dialog) Start() (err error) {
|
||||
|
||||
d.gb.dialogs.Set(d)
|
||||
//defer d.gb.dialogs.Remove(d)
|
||||
if d.gb.MediaPort.Valid() {
|
||||
select {
|
||||
case d.MediaPort = <-d.gb.tcpPorts:
|
||||
default:
|
||||
return fmt.Errorf("no available tcp port")
|
||||
}
|
||||
if d.gb.tcpPort > 0 {
|
||||
d.MediaPort = d.gb.tcpPort
|
||||
} else {
|
||||
d.MediaPort = d.gb.MediaPort[0]
|
||||
if d.gb.MediaPort.Valid() {
|
||||
select {
|
||||
case d.MediaPort = <-d.gb.tcpPorts:
|
||||
default:
|
||||
return fmt.Errorf("no available tcp port")
|
||||
}
|
||||
} else {
|
||||
d.MediaPort = d.gb.MediaPort[0]
|
||||
}
|
||||
}
|
||||
|
||||
ssrc := d.CreateSSRC(d.gb.Serial)
|
||||
d.Info("MediaIp is ", device.MediaIp)
|
||||
|
||||
@@ -266,7 +272,7 @@ func (d *Dialog) Run() (err error) {
|
||||
if _ssrc, err := strconv.ParseInt(ls[1], 10, 0); err == nil {
|
||||
d.SSRC = uint32(_ssrc)
|
||||
} else {
|
||||
d.gb.Error("read invite response y ", "err", err)
|
||||
return errors.New("read invite respose y error" + err.Error())
|
||||
}
|
||||
}
|
||||
case "c":
|
||||
@@ -299,6 +305,18 @@ func (d *Dialog) Run() (err error) {
|
||||
if d.StreamMode == "TCP-ACTIVE" {
|
||||
pub.Receiver.ListenAddr = fmt.Sprintf("%s:%d", d.targetIP, d.targetPort)
|
||||
} else {
|
||||
if d.gb.tcpPort > 0 {
|
||||
d.Info("into single port mode,use gb.tcpPort", d.gb.tcpPort)
|
||||
if d.gb.netListener != nil {
|
||||
d.Info("use gb.netListener", d.gb.netListener.Addr())
|
||||
pub.Receiver.Listener = d.gb.netListener
|
||||
} else {
|
||||
d.Info("listen tcp4", fmt.Sprintf(":%d", d.gb.tcpPort))
|
||||
pub.Receiver.Listener, _ = net.Listen("tcp4", fmt.Sprintf(":%d", d.gb.tcpPort))
|
||||
d.gb.netListener = pub.Receiver.Listener
|
||||
}
|
||||
pub.Receiver.SSRC = d.SSRC
|
||||
}
|
||||
pub.Receiver.ListenAddr = fmt.Sprintf(":%d", d.MediaPort)
|
||||
}
|
||||
pub.Receiver.StreamMode = d.StreamMode
|
||||
@@ -316,7 +334,11 @@ func (d *Dialog) GetKey() uint32 {
|
||||
}
|
||||
|
||||
func (d *Dialog) Dispose() {
|
||||
d.gb.tcpPorts <- d.MediaPort
|
||||
if d.gb.tcpPort == 0 {
|
||||
// 如果没有设置tcp端口,则将MediaPort设置为0,表示不再使用
|
||||
d.gb.tcpPorts <- d.MediaPort
|
||||
}
|
||||
d.Info("dialog dispose", "ssrc", d.SSRC, "mediaPort", d.MediaPort, "streamMode", d.StreamMode, "deviceId", d.Channel.DeviceID, "channelId", d.Channel.ChannelID)
|
||||
if d.session != nil {
|
||||
err := d.session.Bye(d)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,9 +3,9 @@ package plugin_gb28181pro
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -41,7 +41,7 @@ type GB28181Plugin struct {
|
||||
pb.UnimplementedApiServer
|
||||
m7s.Plugin
|
||||
Serial string `default:"34020000002000000001" desc:"sip 服务 id"` //sip 服务器 id, 默认 34020000002000000001
|
||||
Realm string `default:"3402000000" desc:"sip 服务域"` //sip 服务器域,默认 3402000000
|
||||
Realm string `default:"3402000000" desc:"sip 服务域"` //sip 服务器域,默认 3402000000
|
||||
Password string
|
||||
Sip SipConfig
|
||||
MediaPort util.Range[uint16] `default:"10001-20000" desc:"媒体端口范围"` //媒体端口范围
|
||||
@@ -55,12 +55,14 @@ type GB28181Plugin struct {
|
||||
forwardDialogs util.Collection[uint32, *ForwardDialog]
|
||||
platforms util.Collection[string, *Platform]
|
||||
tcpPorts chan uint16
|
||||
tcpPort uint16
|
||||
sipPorts []int
|
||||
SipIP string `desc:"sip发送命令的IP,一般是本地IP,多网卡时需要配置正确的IP"`
|
||||
MediaIP string `desc:"流媒体IP,用于接收流"`
|
||||
deviceManager task.Manager[string, *DeviceRegisterQueueTask]
|
||||
Platforms []*gb28181.PlatformModel
|
||||
channels util.Collection[string, *gb28181.DeviceChannel]
|
||||
netListener net.Listener
|
||||
}
|
||||
|
||||
var _ = m7s.InstallPlugin[GB28181Plugin](m7s.PluginMeta{
|
||||
@@ -75,6 +77,18 @@ var _ = m7s.InstallPlugin[GB28181Plugin](m7s.PluginMeta{
|
||||
NewPullProxy: NewPullProxy,
|
||||
})
|
||||
|
||||
func (gb *GB28181Plugin) Dispose() {
|
||||
if gb.netListener != nil {
|
||||
gb.Info("gb28181 plugin dispose")
|
||||
err := gb.netListener.Close()
|
||||
if err != nil {
|
||||
gb.Error("Close netListener error", "error", err)
|
||||
} else {
|
||||
gb.Info("netListener closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
sip.SIPDebug = true
|
||||
}
|
||||
@@ -153,8 +167,16 @@ func (gb *GB28181Plugin) OnInit() (err error) {
|
||||
if gb.MediaPort.Valid() {
|
||||
gb.SetDescription("tcp", fmt.Sprintf("%d-%d", gb.MediaPort[0], gb.MediaPort[1]))
|
||||
gb.tcpPorts = make(chan uint16, gb.MediaPort.Size())
|
||||
for i := range gb.MediaPort.Size() {
|
||||
gb.tcpPorts <- gb.MediaPort[0] + i
|
||||
if gb.MediaPort.Size() == 0 {
|
||||
gb.tcpPort = gb.MediaPort[0]
|
||||
gb.netListener, _ = net.Listen("tcp4", fmt.Sprintf(":%d", gb.tcpPort))
|
||||
} else if gb.MediaPort.Size() == 1 {
|
||||
gb.tcpPort = gb.MediaPort[0] + 1
|
||||
gb.netListener, _ = net.Listen("tcp4", fmt.Sprintf(":%d", gb.tcpPort))
|
||||
} else {
|
||||
for i := range gb.MediaPort.Size() {
|
||||
gb.tcpPorts <- gb.MediaPort[0] + i
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gb.SetDescription("tcp", fmt.Sprintf("%d", gb.MediaPort[0]))
|
||||
@@ -438,22 +460,9 @@ func (gb *GB28181Plugin) OnRegister(req *sip.Request, tx sip.ServerTransaction)
|
||||
from := req.From()
|
||||
if from == nil || from.Address.User == "" {
|
||||
gb.Error("OnRegister", "error", "no user")
|
||||
response := sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Invalid sip from format", nil)
|
||||
if err := tx.Respond(response); err != nil {
|
||||
gb.Error("respond BadRequest", "error", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
deviceId := from.Address.User
|
||||
// 验证设备ID是否符合GB28181规范(20位数字)
|
||||
if match, _ := regexp.MatchString(`^\d{20}$`, deviceId); !match {
|
||||
gb.Error("OnRegister", "error", "invalid device id format, must be 20 digits", "deviceId", deviceId)
|
||||
response := sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Invalid device ID format", nil)
|
||||
if err := tx.Respond(response); err != nil {
|
||||
gb.Error("respond BadRequest", "error", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
registerHandlerTask := registerHandlerTask{
|
||||
gb: gb,
|
||||
req: req,
|
||||
|
||||
@@ -44,8 +44,9 @@ type Receiver struct {
|
||||
psAudio PSAudio
|
||||
RTPReader *rtp2.TCP
|
||||
ListenAddr string
|
||||
listener net.Listener
|
||||
Listener net.Listener
|
||||
StreamMode string // 数据流传输模式(UDP:udp传输/TCP-ACTIVE:tcp主动模式/TCP-PASSIVE:tcp被动模式)
|
||||
SSRC uint32 // RTP SSRC
|
||||
}
|
||||
|
||||
func NewPSPublisher(puber *m7s.Publisher) *PSPublisher {
|
||||
@@ -147,9 +148,19 @@ func (p *Receiver) ReadRTP(rtp util.Buffer) (err error) {
|
||||
p.Error("unmarshal error", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果设置了SSRC过滤,只处理匹配的SSRC
|
||||
if p.SSRC != 0 && p.SSRC != p.Packet.SSRC {
|
||||
p.Info("into single port mode, ssrc mismatch", "expected", p.SSRC, "actual", p.Packet.SSRC)
|
||||
if p.TraceEnabled() {
|
||||
p.Trace("rtp ssrc mismatch, skip", "expected", p.SSRC, "actual", p.Packet.SSRC)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastSeq == 0 || p.SequenceNumber == lastSeq+1 {
|
||||
if p.TraceEnabled() {
|
||||
p.Trace("rtp", "len", rtp.Len(), "seq", p.SequenceNumber, "payloadType", p.PayloadType, "ssrc", p.SSRC)
|
||||
p.Trace("rtp", "len", rtp.Len(), "seq", p.SequenceNumber, "payloadType", p.PayloadType, "ssrc", p.Packet.SSRC)
|
||||
}
|
||||
copyData := make([]byte, len(p.Payload))
|
||||
copy(copyData, p.Payload)
|
||||
@@ -172,18 +183,24 @@ func (p *Receiver) Start() (err error) {
|
||||
return nil
|
||||
}
|
||||
// TCP被动模式
|
||||
p.listener, err = net.Listen("tcp4", p.ListenAddr)
|
||||
if err != nil {
|
||||
p.Error("start listen", "err", err)
|
||||
return errors.New("start listen,err" + err.Error())
|
||||
if p.Listener == nil {
|
||||
p.Info("start new listener", "addr", p.ListenAddr)
|
||||
p.Listener, err = net.Listen("tcp4", p.ListenAddr)
|
||||
if err != nil {
|
||||
p.Error("start listen", "err", err)
|
||||
return errors.New("start listen,err" + err.Error())
|
||||
}
|
||||
}
|
||||
p.Info("start listen", "addr", p.ListenAddr)
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Receiver) Dispose() {
|
||||
if p.listener != nil {
|
||||
p.listener.Close()
|
||||
if p.SSRC == 0 {
|
||||
p.Info("into multiport mode ,close listener ", p.SSRC)
|
||||
if p.Listener != nil {
|
||||
p.Listener.Close()
|
||||
}
|
||||
}
|
||||
if p.RTPReader != nil {
|
||||
p.RTPReader.Close()
|
||||
@@ -216,7 +233,7 @@ func (p *Receiver) Go() error {
|
||||
}
|
||||
// TCP被动模式
|
||||
p.Info("start accept")
|
||||
conn, err := p.listener.Accept()
|
||||
conn, err := p.Listener.Accept()
|
||||
if err != nil {
|
||||
p.Error("accept", "err", err)
|
||||
return err
|
||||
|
||||
682
plugin/hls/download.go
Normal file
682
plugin/hls/download.go
Normal file
@@ -0,0 +1,682 @@
|
||||
package plugin_hls
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
m7s "m7s.live/v5"
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/util"
|
||||
hls "m7s.live/v5/plugin/hls/pkg"
|
||||
mpegts "m7s.live/v5/plugin/hls/pkg/ts"
|
||||
mp4 "m7s.live/v5/plugin/mp4/pkg"
|
||||
"m7s.live/v5/plugin/mp4/pkg/box"
|
||||
)
|
||||
|
||||
// requestParams 包含请求解析后的参数
|
||||
type requestParams struct {
|
||||
streamPath string
|
||||
startTime time.Time
|
||||
endTime time.Time
|
||||
timeRange time.Duration
|
||||
}
|
||||
|
||||
// fileInfo 包含文件信息
|
||||
type fileInfo struct {
|
||||
filePath string
|
||||
startTime time.Time
|
||||
endTime time.Time
|
||||
startOffsetTime time.Duration
|
||||
recordType string // "ts", "mp4", "fmp4"
|
||||
}
|
||||
|
||||
// parseRequestParams 解析请求参数
|
||||
func (plugin *HLSPlugin) parseRequestParams(r *http.Request) (*requestParams, error) {
|
||||
// 从URL路径中提取流路径,去除前缀 "/download/" 和后缀 ".ts"
|
||||
streamPath := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/download/"), ".ts")
|
||||
|
||||
// 解析URL查询参数中的时间范围(start和end参数)
|
||||
startTime, endTime, err := util.TimeRangeQueryParse(r.URL.Query())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &requestParams{
|
||||
streamPath: streamPath,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
timeRange: endTime.Sub(startTime),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryRecordStreams 从数据库查询录像记录
|
||||
func (plugin *HLSPlugin) queryRecordStreams(params *requestParams) ([]m7s.RecordStream, error) {
|
||||
// 检查数据库是否可用
|
||||
if plugin.DB == nil {
|
||||
return nil, fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
var recordStreams []m7s.RecordStream
|
||||
|
||||
// 首先查询HLS记录 (ts)
|
||||
query := plugin.DB.Model(&m7s.RecordStream{}).Where("stream_path = ? AND type = ?", params.streamPath, "hls")
|
||||
|
||||
// 添加时间范围查询条件
|
||||
if !params.startTime.IsZero() && !params.endTime.IsZero() {
|
||||
query = query.Where("(start_time <= ? AND end_time >= ?) OR (start_time >= ? AND start_time <= ?)",
|
||||
params.endTime, params.startTime, params.startTime, params.endTime)
|
||||
}
|
||||
|
||||
err := query.Order("start_time ASC").Find(&recordStreams).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果没有找到HLS记录,尝试查询MP4记录
|
||||
if len(recordStreams) == 0 {
|
||||
query = plugin.DB.Model(&m7s.RecordStream{}).Where("stream_path = ? AND type IN (?)", params.streamPath, []string{"mp4", "fmp4"})
|
||||
|
||||
if !params.startTime.IsZero() && !params.endTime.IsZero() {
|
||||
query = query.Where("(start_time <= ? AND end_time >= ?) OR (start_time >= ? AND start_time <= ?)",
|
||||
params.endTime, params.startTime, params.startTime, params.endTime)
|
||||
}
|
||||
|
||||
err = query.Order("start_time ASC").Find(&recordStreams).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return recordStreams, nil
|
||||
}
|
||||
|
||||
// buildFileInfoList 构建文件信息列表
|
||||
func (plugin *HLSPlugin) buildFileInfoList(recordStreams []m7s.RecordStream, startTime, endTime time.Time) ([]*fileInfo, bool) {
|
||||
var fileInfoList []*fileInfo
|
||||
var found bool
|
||||
|
||||
for _, record := range recordStreams {
|
||||
// 检查文件是否存在
|
||||
if !util.Exist(record.FilePath) {
|
||||
plugin.Warn("Record file not found", "filePath", record.FilePath)
|
||||
continue
|
||||
}
|
||||
|
||||
var startOffsetTime time.Duration
|
||||
recordStartTime := record.StartTime
|
||||
recordEndTime := record.EndTime
|
||||
|
||||
// 计算文件内的偏移时间
|
||||
if startTime.After(recordStartTime) {
|
||||
startOffsetTime = startTime.Sub(recordStartTime)
|
||||
}
|
||||
|
||||
// 检查是否在时间范围内
|
||||
if recordEndTime.Before(startTime) || recordStartTime.After(endTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
fileInfoList = append(fileInfoList, &fileInfo{
|
||||
filePath: record.FilePath,
|
||||
startTime: recordStartTime,
|
||||
endTime: recordEndTime,
|
||||
startOffsetTime: startOffsetTime,
|
||||
recordType: record.Type,
|
||||
})
|
||||
|
||||
found = true
|
||||
}
|
||||
|
||||
return fileInfoList, found
|
||||
}
|
||||
|
||||
// hasOnlyMp4Records 检查是否只有MP4记录
|
||||
func (plugin *HLSPlugin) hasOnlyMp4Records(fileInfoList []*fileInfo) bool {
|
||||
if len(fileInfoList) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, info := range fileInfoList {
|
||||
if info.recordType == "hls" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// filterTsFiles 过滤HLS TS文件
|
||||
func (plugin *HLSPlugin) filterTsFiles(fileInfoList []*fileInfo) []*fileInfo {
|
||||
var filteredList []*fileInfo
|
||||
|
||||
for _, info := range fileInfoList {
|
||||
if info.recordType == "hls" {
|
||||
filteredList = append(filteredList, info)
|
||||
}
|
||||
}
|
||||
|
||||
plugin.Debug("TS files filtered", "original", len(fileInfoList), "filtered", len(filteredList))
|
||||
return filteredList
|
||||
}
|
||||
|
||||
// filterMp4Files 过滤MP4文件
|
||||
func (plugin *HLSPlugin) filterMp4Files(fileInfoList []*fileInfo) []*fileInfo {
|
||||
var filteredList []*fileInfo
|
||||
|
||||
for _, info := range fileInfoList {
|
||||
if info.recordType == "mp4" || info.recordType == "fmp4" {
|
||||
filteredList = append(filteredList, info)
|
||||
}
|
||||
}
|
||||
|
||||
plugin.Debug("MP4 files filtered", "original", len(fileInfoList), "filtered", len(filteredList))
|
||||
return filteredList
|
||||
}
|
||||
|
||||
// processMp4ToTs 将MP4记录转换为TS输出
|
||||
func (plugin *HLSPlugin) processMp4ToTs(w http.ResponseWriter, r *http.Request, fileInfoList []*fileInfo, params *requestParams) {
|
||||
plugin.Info("Converting MP4 records to TS", "count", len(fileInfoList))
|
||||
|
||||
// 设置HTTP响应头
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
|
||||
// 创建一个TS写入器,在循环外面,所有MP4文件共享同一个TsInMemory
|
||||
tsWriter := &simpleTsWriter{
|
||||
TsInMemory: &hls.TsInMemory{},
|
||||
plugin: plugin,
|
||||
}
|
||||
|
||||
// 对于MP4到TS的转换,我们采用简化的方法
|
||||
// 直接将每个MP4文件转换输出
|
||||
for _, info := range fileInfoList {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plugin.Debug("Converting MP4 file to TS", "path", info.filePath)
|
||||
|
||||
// 创建MP4解复用器
|
||||
demuxer := &mp4.DemuxerRange{
|
||||
StartTime: params.startTime,
|
||||
EndTime: params.endTime,
|
||||
Streams: []m7s.RecordStream{{
|
||||
FilePath: info.filePath,
|
||||
StartTime: info.startTime,
|
||||
EndTime: info.endTime,
|
||||
Type: info.recordType,
|
||||
}},
|
||||
}
|
||||
|
||||
// 设置回调函数
|
||||
demuxer.OnVideoExtraData = tsWriter.onVideoExtraData
|
||||
demuxer.OnAudioExtraData = tsWriter.onAudioExtraData
|
||||
demuxer.OnVideoSample = tsWriter.onVideoSample
|
||||
demuxer.OnAudioSample = tsWriter.onAudioSample
|
||||
|
||||
// 执行解复用和转换
|
||||
err := demuxer.Demux(r.Context())
|
||||
if err != nil {
|
||||
plugin.Error("MP4 to TS conversion failed", "err", err, "file", info.filePath)
|
||||
if !tsWriter.hasWritten {
|
||||
http.Error(w, "Conversion failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 将所有累积的 TsInMemory 内容写入到响应
|
||||
_, err := tsWriter.WriteTo(w)
|
||||
if err != nil {
|
||||
plugin.Error("Failed to write TS data to response", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
plugin.Info("MP4 to TS conversion completed")
|
||||
}
|
||||
|
||||
// simpleTsWriter 简化的TS写入器
|
||||
type simpleTsWriter struct {
|
||||
*hls.TsInMemory
|
||||
plugin *HLSPlugin
|
||||
hasWritten bool
|
||||
spsData []byte
|
||||
ppsData []byte
|
||||
videoCodec box.MP4_CODEC_TYPE
|
||||
audioCodec box.MP4_CODEC_TYPE
|
||||
}
|
||||
|
||||
func (w *simpleTsWriter) WritePMT() {
|
||||
// 初始化 TsInMemory 的 PMT
|
||||
var videoCodec, audioCodec [4]byte
|
||||
switch w.videoCodec {
|
||||
case box.MP4_CODEC_H264:
|
||||
copy(videoCodec[:], []byte("H264"))
|
||||
case box.MP4_CODEC_H265:
|
||||
copy(videoCodec[:], []byte("H265"))
|
||||
}
|
||||
switch w.audioCodec {
|
||||
case box.MP4_CODEC_AAC:
|
||||
copy(audioCodec[:], []byte("MP4A"))
|
||||
|
||||
}
|
||||
w.WritePMTPacket(audioCodec, videoCodec)
|
||||
w.hasWritten = true
|
||||
}
|
||||
|
||||
// onVideoExtraData 处理视频序列头
|
||||
func (w *simpleTsWriter) onVideoExtraData(codecType box.MP4_CODEC_TYPE, data []byte) error {
|
||||
w.videoCodec = codecType
|
||||
// 解析并存储SPS/PPS数据
|
||||
if codecType == box.MP4_CODEC_H264 && len(data) > 0 {
|
||||
if w.plugin != nil {
|
||||
w.plugin.Debug("Processing H264 extra data", "size", len(data))
|
||||
}
|
||||
|
||||
// 解析AVCC格式的extra data
|
||||
if len(data) >= 8 {
|
||||
// AVCC格式: configurationVersion(1) + AVCProfileIndication(1) + profile_compatibility(1) + AVCLevelIndication(1) +
|
||||
// lengthSizeMinusOne(1) + numOfSequenceParameterSets(1) + ...
|
||||
|
||||
offset := 5 // 跳过前5个字节
|
||||
if offset < len(data) {
|
||||
// 读取SPS数量
|
||||
numSPS := data[offset] & 0x1f
|
||||
offset++
|
||||
|
||||
// 解析SPS
|
||||
for i := 0; i < int(numSPS) && offset < len(data)-1; i++ {
|
||||
if offset+1 >= len(data) {
|
||||
break
|
||||
}
|
||||
spsLength := int(data[offset])<<8 | int(data[offset+1])
|
||||
offset += 2
|
||||
|
||||
if offset+spsLength <= len(data) {
|
||||
// 添加起始码并存储SPS
|
||||
w.spsData = make([]byte, 4+spsLength)
|
||||
copy(w.spsData[0:4], []byte{0x00, 0x00, 0x00, 0x01})
|
||||
copy(w.spsData[4:], data[offset:offset+spsLength])
|
||||
offset += spsLength
|
||||
|
||||
if w.plugin != nil {
|
||||
w.plugin.Debug("Extracted SPS", "length", spsLength)
|
||||
}
|
||||
break // 只取第一个SPS
|
||||
}
|
||||
}
|
||||
|
||||
// 读取PPS数量
|
||||
if offset < len(data) {
|
||||
numPPS := data[offset]
|
||||
offset++
|
||||
|
||||
// 解析PPS
|
||||
for i := 0; i < int(numPPS) && offset < len(data)-1; i++ {
|
||||
if offset+1 >= len(data) {
|
||||
break
|
||||
}
|
||||
ppsLength := int(data[offset])<<8 | int(data[offset+1])
|
||||
offset += 2
|
||||
|
||||
if offset+ppsLength <= len(data) {
|
||||
// 添加起始码并存储PPS
|
||||
w.ppsData = make([]byte, 4+ppsLength)
|
||||
copy(w.ppsData[0:4], []byte{0x00, 0x00, 0x00, 0x01})
|
||||
copy(w.ppsData[4:], data[offset:offset+ppsLength])
|
||||
|
||||
if w.plugin != nil {
|
||||
w.plugin.Debug("Extracted PPS", "length", ppsLength)
|
||||
}
|
||||
break // 只取第一个PPS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// onAudioExtraData 处理音频序列头
|
||||
func (w *simpleTsWriter) onAudioExtraData(codecType box.MP4_CODEC_TYPE, data []byte) error {
|
||||
w.audioCodec = codecType
|
||||
w.plugin.Debug("Processing audio extra data", "codec", codecType, "size", len(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// onVideoSample 处理视频样本
|
||||
func (w *simpleTsWriter) onVideoSample(codecType box.MP4_CODEC_TYPE, sample box.Sample) error {
|
||||
if !w.hasWritten {
|
||||
w.WritePMT()
|
||||
}
|
||||
|
||||
w.plugin.Debug("Processing video sample", "size", len(sample.Data), "keyFrame", sample.KeyFrame, "timestamp", sample.Timestamp)
|
||||
|
||||
// 转换AVCC格式到Annex-B格式
|
||||
annexBData, err := w.convertAVCCToAnnexB(sample.Data, sample.KeyFrame)
|
||||
if err != nil {
|
||||
w.plugin.Error("Failed to convert AVCC to Annex-B", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(annexBData) == 0 {
|
||||
w.plugin.Warn("Empty Annex-B data after conversion")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建视频帧结构
|
||||
videoFrame := mpegts.MpegtsPESFrame{
|
||||
Pid: mpegts.PID_VIDEO,
|
||||
IsKeyFrame: sample.KeyFrame,
|
||||
}
|
||||
|
||||
// 创建 AnnexB 帧
|
||||
annexBFrame := &pkg.AnnexB{
|
||||
PTS: (time.Duration(sample.Timestamp) + time.Duration(sample.CTS)) * 90,
|
||||
DTS: time.Duration(sample.Timestamp) * 90, // 对于MP4转换,假设PTS=DTS
|
||||
}
|
||||
|
||||
// 根据编解码器类型设置 Hevc 标志
|
||||
if codecType == box.MP4_CODEC_H265 {
|
||||
annexBFrame.Hevc = true
|
||||
}
|
||||
|
||||
annexBFrame.AppendOne(annexBData)
|
||||
|
||||
// 使用 WriteVideoFrame 写入TS包
|
||||
err = w.WriteVideoFrame(annexBFrame, &videoFrame)
|
||||
if err != nil {
|
||||
w.plugin.Error("Failed to write video frame", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertAVCCToAnnexB 将AVCC格式转换为Annex-B格式
|
||||
func (w *simpleTsWriter) convertAVCCToAnnexB(avccData []byte, isKeyFrame bool) ([]byte, error) {
|
||||
if len(avccData) == 0 {
|
||||
return nil, fmt.Errorf("empty AVCC data")
|
||||
}
|
||||
|
||||
var annexBBuffer []byte
|
||||
|
||||
// 如果是关键帧,先添加SPS和PPS
|
||||
if isKeyFrame {
|
||||
if len(w.spsData) > 0 {
|
||||
annexBBuffer = append(annexBBuffer, w.spsData...)
|
||||
w.plugin.Debug("Added SPS to key frame", "spsSize", len(w.spsData))
|
||||
}
|
||||
if len(w.ppsData) > 0 {
|
||||
annexBBuffer = append(annexBBuffer, w.ppsData...)
|
||||
w.plugin.Debug("Added PPS to key frame", "ppsSize", len(w.ppsData))
|
||||
}
|
||||
}
|
||||
|
||||
// 解析AVCC格式的NAL单元
|
||||
offset := 0
|
||||
nalCount := 0
|
||||
|
||||
for offset < len(avccData) {
|
||||
// AVCC格式:4字节长度 + NAL数据
|
||||
if offset+4 > len(avccData) {
|
||||
break
|
||||
}
|
||||
|
||||
// 读取NAL单元长度(大端序)
|
||||
nalLength := int(avccData[offset])<<24 |
|
||||
int(avccData[offset+1])<<16 |
|
||||
int(avccData[offset+2])<<8 |
|
||||
int(avccData[offset+3])
|
||||
offset += 4
|
||||
|
||||
if nalLength <= 0 || offset+nalLength > len(avccData) {
|
||||
w.plugin.Warn("Invalid NAL length", "length", nalLength, "remaining", len(avccData)-offset)
|
||||
break
|
||||
}
|
||||
|
||||
nalData := avccData[offset : offset+nalLength]
|
||||
offset += nalLength
|
||||
nalCount++
|
||||
|
||||
if len(nalData) > 0 {
|
||||
nalType := nalData[0] & 0x1f
|
||||
w.plugin.Debug("Converting NAL unit", "type", nalType, "length", nalLength)
|
||||
|
||||
// 添加起始码前缀
|
||||
annexBBuffer = append(annexBBuffer, []byte{0x00, 0x00, 0x00, 0x01}...)
|
||||
annexBBuffer = append(annexBBuffer, nalData...)
|
||||
}
|
||||
}
|
||||
|
||||
if nalCount == 0 {
|
||||
return nil, fmt.Errorf("no NAL units found in AVCC data")
|
||||
}
|
||||
|
||||
w.plugin.Debug("AVCC to Annex-B conversion completed",
|
||||
"inputSize", len(avccData),
|
||||
"outputSize", len(annexBBuffer),
|
||||
"nalUnits", nalCount)
|
||||
|
||||
return annexBBuffer, nil
|
||||
}
|
||||
|
||||
// onAudioSample 处理音频样本
|
||||
func (w *simpleTsWriter) onAudioSample(codecType box.MP4_CODEC_TYPE, sample box.Sample) error {
|
||||
if !w.hasWritten {
|
||||
w.WritePMT()
|
||||
}
|
||||
|
||||
w.plugin.Debug("Processing audio sample", "codec", codecType, "size", len(sample.Data), "timestamp", sample.Timestamp)
|
||||
|
||||
// 创建音频帧结构
|
||||
audioFrame := mpegts.MpegtsPESFrame{
|
||||
Pid: mpegts.PID_AUDIO,
|
||||
}
|
||||
|
||||
// 根据编解码器类型处理音频数据
|
||||
switch codecType {
|
||||
case box.MP4_CODEC_AAC: // AAC
|
||||
// 创建 ADTS 帧
|
||||
adtsFrame := &pkg.ADTS{
|
||||
DTS: time.Duration(sample.Timestamp) * 90,
|
||||
}
|
||||
|
||||
// 将音频数据添加到帧中
|
||||
copy(adtsFrame.NextN(len(sample.Data)), sample.Data)
|
||||
|
||||
// 使用 WriteAudioFrame 写入TS包
|
||||
err := w.WriteAudioFrame(adtsFrame, &audioFrame)
|
||||
if err != nil {
|
||||
w.plugin.Error("Failed to write audio frame", "error", err)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// 对于非AAC音频,暂时使用原来的PES包方式
|
||||
pesPacket := mpegts.MpegTsPESPacket{
|
||||
Header: mpegts.MpegTsPESHeader{
|
||||
PacketStartCodePrefix: 0x000001,
|
||||
StreamID: mpegts.STREAM_ID_AUDIO,
|
||||
},
|
||||
}
|
||||
// 设置可选字段
|
||||
pesPacket.Header.ConstTen = 0x80
|
||||
pesPacket.Header.PtsDtsFlags = 0x80 // 只有PTS
|
||||
pesPacket.Header.PesHeaderDataLength = 5
|
||||
pesPacket.Header.Pts = uint64(sample.Timestamp)
|
||||
|
||||
pesPacket.Buffers = append(pesPacket.Buffers, sample.Data)
|
||||
|
||||
// 写入TS包
|
||||
err := w.WritePESPacket(&audioFrame, pesPacket)
|
||||
if err != nil {
|
||||
w.plugin.Error("Failed to write audio PES packet", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processTsFiles 处理原生TS文件拼接
|
||||
func (plugin *HLSPlugin) processTsFiles(w http.ResponseWriter, r *http.Request, fileInfoList []*fileInfo, params *requestParams) {
|
||||
plugin.Info("Processing TS files", "count", len(fileInfoList))
|
||||
|
||||
// 设置HTTP响应头
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
|
||||
var writer io.Writer = w
|
||||
var totalSize uint64
|
||||
|
||||
// 第一次遍历:计算总大小
|
||||
for _, info := range fileInfoList {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(info.filePath)
|
||||
if err != nil {
|
||||
plugin.Error("Failed to stat file", "path", info.filePath, "err", err)
|
||||
continue
|
||||
}
|
||||
totalSize += uint64(fileInfo.Size())
|
||||
}
|
||||
|
||||
// 设置内容长度
|
||||
w.Header().Set("Content-Length", strconv.FormatUint(totalSize, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// 第二次遍历:写入数据
|
||||
for i, info := range fileInfoList {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plugin.Debug("Processing TS file", "path", info.filePath)
|
||||
file, err := os.Open(info.filePath)
|
||||
if err != nil {
|
||||
plugin.Error("Failed to open file", "path", info.filePath, "err", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
|
||||
if i == 0 {
|
||||
// 第一个文件,直接拷贝
|
||||
_, err = io.Copy(writer, reader)
|
||||
} else {
|
||||
// 后续文件,跳过PAT/PMT包,只拷贝媒体数据
|
||||
err = plugin.copyTsFileSkipHeaders(writer, reader)
|
||||
}
|
||||
|
||||
file.Close()
|
||||
|
||||
if err != nil {
|
||||
plugin.Error("Failed to copy file", "path", info.filePath, "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
plugin.Info("TS download completed")
|
||||
}
|
||||
|
||||
// copyTsFileSkipHeaders 拷贝TS文件,跳过PAT/PMT包
|
||||
func (plugin *HLSPlugin) copyTsFileSkipHeaders(writer io.Writer, reader *bufio.Reader) error {
|
||||
buffer := make([]byte, mpegts.TS_PACKET_SIZE)
|
||||
|
||||
for {
|
||||
n, err := io.ReadFull(reader, buffer)
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if n != mpegts.TS_PACKET_SIZE {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查同步字节
|
||||
if buffer[0] != 0x47 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取PID
|
||||
pid := uint16(buffer[1]&0x1f)<<8 | uint16(buffer[2])
|
||||
|
||||
// 跳过PAT(PID=0)和PMT(PID=256)包
|
||||
if pid == mpegts.PID_PAT || pid == mpegts.PID_PMT {
|
||||
continue
|
||||
}
|
||||
|
||||
// 写入媒体数据包
|
||||
_, err = writer.Write(buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// download 下载处理函数
|
||||
func (plugin *HLSPlugin) download(w http.ResponseWriter, r *http.Request) {
|
||||
// 解析请求参数
|
||||
params, err := plugin.parseRequestParams(r)
|
||||
if err != nil {
|
||||
plugin.Error("Failed to parse request params", "err", err)
|
||||
http.Error(w, "Invalid parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
plugin.Info("TS download request", "streamPath", params.streamPath, "timeRange", params.timeRange)
|
||||
|
||||
// 查询录像记录
|
||||
recordStreams, err := plugin.queryRecordStreams(params)
|
||||
if err != nil {
|
||||
plugin.Error("Failed to query record streams", "err", err)
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(recordStreams) == 0 {
|
||||
plugin.Warn("No records found", "streamPath", params.streamPath)
|
||||
http.Error(w, "No records found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建文件信息列表
|
||||
fileInfoList, found := plugin.buildFileInfoList(recordStreams, params.startTime, params.endTime)
|
||||
if !found {
|
||||
plugin.Warn("No valid files found", "streamPath", params.streamPath)
|
||||
http.Error(w, "No valid files found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件类型并处理
|
||||
if plugin.hasOnlyMp4Records(fileInfoList) {
|
||||
// 只有MP4记录,转换为TS
|
||||
mp4Files := plugin.filterMp4Files(fileInfoList)
|
||||
plugin.processMp4ToTs(w, r, mp4Files, params)
|
||||
} else {
|
||||
// 有TS记录,优先使用TS文件
|
||||
tsFiles := plugin.filterTsFiles(fileInfoList)
|
||||
if len(tsFiles) > 0 {
|
||||
plugin.processTsFiles(w, r, tsFiles, params)
|
||||
} else {
|
||||
// 没有TS文件,使用MP4转换
|
||||
mp4Files := plugin.filterMp4Files(fileInfoList)
|
||||
plugin.processMp4ToTs(w, r, mp4Files, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ func (p *HLSPlugin) OnInit() (err error) {
|
||||
func (p *HLSPlugin) RegisterHandler() map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/vod/{streamPath...}": p.vod,
|
||||
"/download/{streamPath...}": p.download,
|
||||
"/api/record/start/{streamPath...}": p.API_record_start,
|
||||
"/api/record/stop/{id}": p.API_record_stop,
|
||||
}
|
||||
|
||||
@@ -2,16 +2,13 @@ package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"m7s.live/v5"
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/codec"
|
||||
"m7s.live/v5/pkg/config"
|
||||
"m7s.live/v5/pkg/task"
|
||||
"m7s.live/v5/pkg/util"
|
||||
mpegts "m7s.live/v5/plugin/hls/pkg/ts"
|
||||
)
|
||||
@@ -22,7 +19,6 @@ func NewRecorder(conf config.Record) m7s.IRecorder {
|
||||
|
||||
type Recorder struct {
|
||||
m7s.DefaultRecorder
|
||||
stream m7s.RecordStream
|
||||
ts *TsInFile
|
||||
pesAudio *mpegts.MpegtsPESFrame
|
||||
pesVideo *mpegts.MpegtsPESFrame
|
||||
@@ -39,81 +35,11 @@ var CustomFileName = func(job *m7s.RecordJob) string {
|
||||
}
|
||||
|
||||
func (r *Recorder) createStream(start time.Time) (err error) {
|
||||
recordJob := &r.RecordJob
|
||||
sub := recordJob.Subscriber
|
||||
r.stream = m7s.RecordStream{
|
||||
StartTime: start,
|
||||
StreamPath: sub.StreamPath,
|
||||
FilePath: CustomFileName(&r.RecordJob),
|
||||
EventId: recordJob.EventId,
|
||||
EventDesc: recordJob.EventDesc,
|
||||
EventName: recordJob.EventName,
|
||||
EventLevel: recordJob.EventLevel,
|
||||
BeforeDuration: recordJob.BeforeDuration,
|
||||
AfterDuration: recordJob.AfterDuration,
|
||||
Mode: recordJob.Mode,
|
||||
Type: "hls",
|
||||
}
|
||||
dir := filepath.Dir(r.stream.FilePath)
|
||||
dir = filepath.Clean(dir)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
r.Error("create directory failed", "err", err, "dir", dir)
|
||||
return
|
||||
}
|
||||
if sub.Publisher.HasAudioTrack() {
|
||||
r.stream.AudioCodec = sub.Publisher.AudioTrack.ICodecCtx.String()
|
||||
}
|
||||
if sub.Publisher.HasVideoTrack() {
|
||||
r.stream.VideoCodec = sub.Publisher.VideoTrack.ICodecCtx.String()
|
||||
}
|
||||
if recordJob.Plugin.DB != nil {
|
||||
recordJob.Plugin.DB.Save(&r.stream)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type eventRecordCheck struct {
|
||||
task.Task
|
||||
DB *gorm.DB
|
||||
streamPath string
|
||||
}
|
||||
|
||||
func (t *eventRecordCheck) Run() (err error) {
|
||||
var eventRecordStreams []m7s.RecordStream
|
||||
queryRecord := m7s.RecordStream{
|
||||
EventLevel: m7s.EventLevelHigh,
|
||||
Mode: m7s.RecordModeEvent,
|
||||
Type: "hls",
|
||||
}
|
||||
t.DB.Where(&queryRecord).Find(&eventRecordStreams, "stream_path=?", t.streamPath) //搜索事件录像,且为重要事件(无法自动删除)
|
||||
if len(eventRecordStreams) > 0 {
|
||||
for _, recordStream := range eventRecordStreams {
|
||||
var unimportantEventRecordStreams []m7s.RecordStream
|
||||
queryRecord.EventLevel = m7s.EventLevelLow
|
||||
query := `(start_time BETWEEN ? AND ?)
|
||||
OR (end_time BETWEEN ? AND ?)
|
||||
OR (? BETWEEN start_time AND end_time)
|
||||
OR (? BETWEEN start_time AND end_time) AND stream_path=? `
|
||||
t.DB.Where(&queryRecord).Where(query, recordStream.StartTime, recordStream.EndTime, recordStream.StartTime, recordStream.EndTime, recordStream.StartTime, recordStream.EndTime, recordStream.StreamPath).Find(&unimportantEventRecordStreams)
|
||||
if len(unimportantEventRecordStreams) > 0 {
|
||||
for _, unimportantEventRecordStream := range unimportantEventRecordStreams {
|
||||
unimportantEventRecordStream.EventLevel = m7s.EventLevelHigh
|
||||
t.DB.Save(&unimportantEventRecordStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
return r.CreateStream(start, CustomFileName)
|
||||
}
|
||||
|
||||
func (r *Recorder) writeTailer(end time.Time) {
|
||||
if r.stream.EndTime.After(r.stream.StartTime) {
|
||||
return
|
||||
}
|
||||
r.stream.EndTime = end
|
||||
if r.RecordJob.Plugin.DB != nil {
|
||||
r.RecordJob.Plugin.DB.Save(&r.stream)
|
||||
}
|
||||
r.WriteTail(end, nil)
|
||||
}
|
||||
|
||||
func (r *Recorder) Dispose() {
|
||||
@@ -131,9 +57,9 @@ func (r *Recorder) createNewTs() {
|
||||
r.ts.Close()
|
||||
}
|
||||
var err error
|
||||
r.ts, err = NewTsInFile(r.stream.FilePath)
|
||||
r.ts, err = NewTsInFile(r.Event.FilePath)
|
||||
if err != nil {
|
||||
r.Error("create ts file failed", "err", err, "path", r.stream.FilePath)
|
||||
r.Error("create ts file failed", "err", err, "path", r.Event.FilePath)
|
||||
return
|
||||
}
|
||||
if oldPMT.Len() > 0 {
|
||||
@@ -175,8 +101,8 @@ func (r *Recorder) Run() (err error) {
|
||||
ctx := &r.RecordJob
|
||||
suber := ctx.Subscriber
|
||||
startTime := time.Now()
|
||||
if ctx.BeforeDuration > 0 {
|
||||
startTime = startTime.Add(-ctx.BeforeDuration)
|
||||
if ctx.Event.BeforeDuration > 0 {
|
||||
startTime = startTime.Add(-time.Duration(ctx.Event.BeforeDuration) * time.Millisecond)
|
||||
}
|
||||
|
||||
// 创建第一个片段记录
|
||||
|
||||
@@ -165,10 +165,9 @@ func (p *MP4Plugin) download(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 构建查询条件,查找指定时间范围内的录制记录
|
||||
queryRecord := m7s.RecordStream{
|
||||
Mode: m7s.RecordModeAuto,
|
||||
Type: "mp4",
|
||||
}
|
||||
p.DB.Where(&queryRecord).Find(&streams, "end_time>? AND start_time<? AND stream_path=?", startTime, endTime, streamPath)
|
||||
p.DB.Where(&queryRecord).Find(&streams, "event_id=0 AND end_time>? AND start_time<? AND stream_path=?", startTime, endTime, streamPath)
|
||||
|
||||
// 创建 MP4 混合器
|
||||
muxer := mp4.NewMuxer(flag)
|
||||
@@ -533,42 +532,44 @@ func (p *MP4Plugin) EventStart(ctx context.Context, req *mp4pb.ReqEventRecord) (
|
||||
Append: false,
|
||||
Fragment: 0,
|
||||
FilePath: filepath.Join(p.EventRecordFilePath, stream.StreamPath, time.Now().Local().Format("2006-01-02-15-04-05")),
|
||||
Mode: config.RecordModeEvent,
|
||||
Event: &config.RecordEvent{
|
||||
EventId: req.EventId,
|
||||
EventLevel: req.EventLevel,
|
||||
EventName: req.EventName,
|
||||
EventDesc: req.EventDesc,
|
||||
BeforeDuration: uint32(beforeDuration / time.Millisecond),
|
||||
AfterDuration: uint32(afterDuration / time.Millisecond),
|
||||
},
|
||||
}
|
||||
//recordJob := recorder.GetRecordJob()
|
||||
var subconfig config.Subscribe
|
||||
defaults.SetDefaults(&subconfig)
|
||||
subconfig.BufferTime = beforeDuration
|
||||
recordJob := p.Record(stream, recordConf, &subconfig)
|
||||
recordJob.EventId = req.EventId
|
||||
recordJob.EventLevel = req.EventLevel
|
||||
recordJob.EventName = req.EventName
|
||||
recordJob.EventDesc = req.EventDesc
|
||||
recordJob.AfterDuration = afterDuration
|
||||
recordJob.BeforeDuration = beforeDuration
|
||||
recordJob.Mode = m7s.RecordModeEvent
|
||||
p.Record(stream, recordConf, &subconfig)
|
||||
}
|
||||
} else {
|
||||
if tmpJob.AfterDuration != 0 { //当前有事件录像正在录制,则更新该录像的结束时间
|
||||
tmpJob.AfterDuration = time.Duration(tmpJob.Subscriber.VideoReader.AbsTime)*time.Millisecond + afterDuration
|
||||
if tmpJob.Event != nil { //当前有事件录像正在录制,则更新该录像的结束时间
|
||||
tmpJob.Event.AfterDuration = tmpJob.Subscriber.VideoReader.AbsTime + uint32(afterDuration/time.Millisecond)
|
||||
if p.DB != nil {
|
||||
p.DB.Save(&tmpJob.Event)
|
||||
}
|
||||
} else { //当前有自动录像正在录制,则生成事件录像的记录,而不去生成事件录像的文件
|
||||
recordStream := &m7s.RecordStream{
|
||||
StreamPath: req.StreamPath,
|
||||
newEvent := &config.RecordEvent{
|
||||
EventId: req.EventId,
|
||||
EventLevel: req.EventLevel,
|
||||
EventDesc: req.EventDesc,
|
||||
EventName: req.EventName,
|
||||
Mode: m7s.RecordModeEvent,
|
||||
BeforeDuration: beforeDuration,
|
||||
AfterDuration: afterDuration,
|
||||
Type: "mp4",
|
||||
EventDesc: req.EventDesc,
|
||||
BeforeDuration: uint32(beforeDuration / time.Millisecond),
|
||||
AfterDuration: uint32(afterDuration / time.Millisecond),
|
||||
}
|
||||
now := time.Now()
|
||||
startTime := now.Add(-beforeDuration)
|
||||
endTime := now.Add(afterDuration)
|
||||
recordStream.StartTime = startTime
|
||||
recordStream.EndTime = endTime
|
||||
if p.DB != nil {
|
||||
p.DB.Save(&recordStream)
|
||||
p.DB.Save(&m7s.EventRecordStream{
|
||||
RecordEvent: newEvent,
|
||||
RecordStream: m7s.RecordStream{
|
||||
StreamPath: req.StreamPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1209
plugin/mp4/api_extract.go
Normal file
1209
plugin/mp4/api_extract.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -91,9 +91,6 @@ func (p *DeleteRecordTask) deleteOldestFile() {
|
||||
}
|
||||
for _, filePath := range filePaths {
|
||||
for p.getDiskOutOfSpace(filePath) {
|
||||
queryRecord := m7s.RecordStream{
|
||||
EventLevel: m7s.EventLevelLow, // 查询条件:event_level = 1,非重要事件
|
||||
}
|
||||
var eventRecords []m7s.RecordStream
|
||||
// 使用不同的方法进行路径匹配,避免ESCAPE语法问题
|
||||
// 解决方案:用MySQL能理解的简单方式匹配路径前缀
|
||||
@@ -103,7 +100,7 @@ func (p *DeleteRecordTask) deleteOldestFile() {
|
||||
searchPattern := basePath + "%"
|
||||
p.Info("deleteOldestFile", "searching with path pattern", searchPattern)
|
||||
|
||||
err := p.DB.Where(&queryRecord).Where("end_time IS NOT NULL").
|
||||
err := p.DB.Where("event_id=0 AND end_time IS NOT NULL").
|
||||
Where("file_path LIKE ?", searchPattern).
|
||||
Order("end_time ASC").Find(&eventRecords).Error
|
||||
if err == nil {
|
||||
@@ -149,14 +146,11 @@ func (t *DeleteRecordTask) Tick(any) {
|
||||
if t.RecordFileExpireDays <= 0 {
|
||||
return
|
||||
}
|
||||
//搜索event_records表中event_level值为1的(非重要)数据,并将其create_time与当前时间比对,大于RecordFileExpireDays则进行删除,数据库标记is_delete为1,磁盘上删除录像文件
|
||||
//搜索event_records表中event_id值为0的(非事件)录像,并将其create_time与当前时间比对,大于RecordFileExpireDays则进行删除,数据库标记is_delete为1,磁盘上删除录像文件
|
||||
var eventRecords []m7s.RecordStream
|
||||
expireTime := time.Now().AddDate(0, 0, -t.RecordFileExpireDays)
|
||||
t.Debug("RecordFileExpireDays is set to auto delete oldestfile", "expireTime", expireTime.Format("2006-01-02 15:04:05"))
|
||||
queryRecord := m7s.RecordStream{
|
||||
EventLevel: m7s.EventLevelLow, // 查询条件:event_level = low,非重要事件
|
||||
}
|
||||
err := t.DB.Where(&queryRecord).Find(&eventRecords, "end_time < ? AND end_time IS NOT NULL", expireTime).Error
|
||||
err := t.DB.Find(&eventRecords, "event_id=0 AND end_time < ? AND end_time IS NOT NULL", expireTime).Error
|
||||
if err == nil {
|
||||
for _, record := range eventRecords {
|
||||
t.Info("RecordFileExpireDays is set to auto delete oldestfile", "ID", record.ID, "create time", record.EndTime, "filepath", record.FilePath)
|
||||
|
||||
@@ -76,7 +76,11 @@ var _ = m7s.InstallPlugin[MP4Plugin](m7s.PluginMeta{
|
||||
|
||||
func (p *MP4Plugin) RegisterHandler() map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/download/{streamPath...}": p.download,
|
||||
"/download/{streamPath...}": p.download,
|
||||
"/extractClip/{streamPath...}": p.extractClipToFileHandel,
|
||||
"/extractCompressed/{streamPath...}": p.extractCompressedVideoHandel,
|
||||
"/extractGop/{streamPath...}": p.extractGopVideoHandel,
|
||||
"/snap/{streamPath...}": p.snapHandel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,16 @@ func (t *TrakBox) Unmarshal(buf []byte) (b IBox, err error) {
|
||||
return t, err
|
||||
}
|
||||
|
||||
// SampleCallback 定义样本处理回调函数类型
|
||||
type SampleCallback func(sample *Sample, sampleIndex int) error
|
||||
|
||||
// ParseSamples parses the sample table and builds the sample list
|
||||
func (t *TrakBox) ParseSamples() (samplelist []Sample) {
|
||||
return t.ParseSamplesWithCallback(nil)
|
||||
}
|
||||
|
||||
// ParseSamplesWithCallback parses the sample table and builds the sample list with optional callback
|
||||
func (t *TrakBox) ParseSamplesWithCallback(callback SampleCallback) (samplelist []Sample) {
|
||||
stbl := t.MDIA.MINF.STBL
|
||||
var chunkOffsets []uint64
|
||||
if stbl.STCO != nil {
|
||||
@@ -150,6 +158,17 @@ func (t *TrakBox) ParseSamples() (samplelist []Sample) {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用回调函数处理每个样本
|
||||
if callback != nil {
|
||||
for i := range samplelist {
|
||||
if err := callback(&samplelist[i], i); err != nil {
|
||||
// 如果回调返回错误,可以选择记录或处理,但不中断解析
|
||||
// 这里为了保持向后兼容性,我们继续处理
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return samplelist
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"slices"
|
||||
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/codec"
|
||||
"m7s.live/v5/pkg/util"
|
||||
"m7s.live/v5/plugin/mp4/pkg/box"
|
||||
. "m7s.live/v5/plugin/mp4/pkg/box"
|
||||
rtmp "m7s.live/v5/plugin/rtmp/pkg"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -30,7 +32,7 @@ type (
|
||||
Number uint32
|
||||
CryptByteBlock uint8
|
||||
SkipByteBlock uint8
|
||||
PsshBoxes []*PsshBox
|
||||
PsshBoxes []*box.PsshBox
|
||||
}
|
||||
SubSamplePattern struct {
|
||||
BytesClear uint16
|
||||
@@ -43,16 +45,28 @@ type (
|
||||
chunkoffset uint64
|
||||
}
|
||||
|
||||
RTMPFrame struct {
|
||||
Frame any // 可以是 *rtmp.RTMPVideo 或 *rtmp.RTMPAudio
|
||||
}
|
||||
|
||||
Demuxer struct {
|
||||
reader io.ReadSeeker
|
||||
Tracks []*Track
|
||||
ReadSampleIdx []uint32
|
||||
IsFragment bool
|
||||
// pssh []*PsshBox
|
||||
moov *MoovBox
|
||||
mdat *MediaDataBox
|
||||
// pssh []*box.PsshBox
|
||||
moov *box.MoovBox
|
||||
mdat *box.MediaDataBox
|
||||
mdatOffset uint64
|
||||
QuicTime bool
|
||||
|
||||
// 预生成的 RTMP 帧
|
||||
RTMPVideoSequence *rtmp.RTMPVideo
|
||||
RTMPAudioSequence *rtmp.RTMPAudio
|
||||
RTMPFrames []RTMPFrame
|
||||
|
||||
// RTMP 帧生成配置
|
||||
RTMPAllocator *util.ScalableMemoryAllocator
|
||||
}
|
||||
)
|
||||
|
||||
@@ -63,6 +77,10 @@ func NewDemuxer(r io.ReadSeeker) *Demuxer {
|
||||
}
|
||||
|
||||
func (d *Demuxer) Demux() (err error) {
|
||||
return d.DemuxWithAllocator(nil)
|
||||
}
|
||||
|
||||
func (d *Demuxer) DemuxWithAllocator(allocator *util.ScalableMemoryAllocator) (err error) {
|
||||
|
||||
// decodeVisualSampleEntry := func() (offset int, err error) {
|
||||
// var encv VisualSampleEntry
|
||||
@@ -96,7 +114,7 @@ func (d *Demuxer) Demux() (err error) {
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
var b IBox
|
||||
var b box.IBox
|
||||
var offset uint64
|
||||
for {
|
||||
b, err = box.ReadFrom(d.reader)
|
||||
@@ -107,53 +125,59 @@ func (d *Demuxer) Demux() (err error) {
|
||||
return err
|
||||
}
|
||||
offset += b.Size()
|
||||
switch box := b.(type) {
|
||||
case *FileTypeBox:
|
||||
if slices.Contains(box.CompatibleBrands, [4]byte{'q', 't', ' ', ' '}) {
|
||||
switch boxData := b.(type) {
|
||||
case *box.FileTypeBox:
|
||||
if slices.Contains(boxData.CompatibleBrands, [4]byte{'q', 't', ' ', ' '}) {
|
||||
d.QuicTime = true
|
||||
}
|
||||
case *FreeBox:
|
||||
case *MediaDataBox:
|
||||
d.mdat = box
|
||||
d.mdatOffset = offset - b.Size() + uint64(box.HeaderSize())
|
||||
case *MoovBox:
|
||||
if box.MVEX != nil {
|
||||
case *box.FreeBox:
|
||||
case *box.MediaDataBox:
|
||||
d.mdat = boxData
|
||||
d.mdatOffset = offset - b.Size() + uint64(boxData.HeaderSize())
|
||||
case *box.MoovBox:
|
||||
if boxData.MVEX != nil {
|
||||
d.IsFragment = true
|
||||
}
|
||||
for _, trak := range box.Tracks {
|
||||
for _, trak := range boxData.Tracks {
|
||||
track := &Track{}
|
||||
track.TrackId = trak.TKHD.TrackID
|
||||
track.Duration = uint32(trak.TKHD.Duration)
|
||||
track.Timescale = trak.MDIA.MDHD.Timescale
|
||||
track.Samplelist = trak.ParseSamples()
|
||||
// 创建RTMP样本处理回调
|
||||
var sampleCallback box.SampleCallback
|
||||
if d.RTMPAllocator != nil {
|
||||
sampleCallback = d.createRTMPSampleCallback(track, trak)
|
||||
}
|
||||
|
||||
track.Samplelist = trak.ParseSamplesWithCallback(sampleCallback)
|
||||
if len(trak.MDIA.MINF.STBL.STSD.Entries) > 0 {
|
||||
entryBox := trak.MDIA.MINF.STBL.STSD.Entries[0]
|
||||
switch entry := entryBox.(type) {
|
||||
case *AudioSampleEntry:
|
||||
case *box.AudioSampleEntry:
|
||||
switch entry.Type() {
|
||||
case TypeMP4A:
|
||||
track.Cid = MP4_CODEC_AAC
|
||||
case TypeALAW:
|
||||
track.Cid = MP4_CODEC_G711A
|
||||
case TypeULAW:
|
||||
track.Cid = MP4_CODEC_G711U
|
||||
case TypeOPUS:
|
||||
track.Cid = MP4_CODEC_OPUS
|
||||
case box.TypeMP4A:
|
||||
track.Cid = box.MP4_CODEC_AAC
|
||||
case box.TypeALAW:
|
||||
track.Cid = box.MP4_CODEC_G711A
|
||||
case box.TypeULAW:
|
||||
track.Cid = box.MP4_CODEC_G711U
|
||||
case box.TypeOPUS:
|
||||
track.Cid = box.MP4_CODEC_OPUS
|
||||
}
|
||||
track.SampleRate = entry.Samplerate
|
||||
track.ChannelCount = uint8(entry.ChannelCount)
|
||||
track.SampleSize = entry.SampleSize
|
||||
switch extra := entry.ExtraData.(type) {
|
||||
case *ESDSBox:
|
||||
track.Cid, track.ExtraData = DecodeESDescriptor(extra.Data)
|
||||
case *box.ESDSBox:
|
||||
track.Cid, track.ExtraData = box.DecodeESDescriptor(extra.Data)
|
||||
}
|
||||
case *VisualSampleEntry:
|
||||
track.ExtraData = entry.ExtraData.(*DataBox).Data
|
||||
case *box.VisualSampleEntry:
|
||||
track.ExtraData = entry.ExtraData.(*box.DataBox).Data
|
||||
switch entry.Type() {
|
||||
case TypeAVC1:
|
||||
track.Cid = MP4_CODEC_H264
|
||||
case TypeHVC1, TypeHEV1:
|
||||
track.Cid = MP4_CODEC_H265
|
||||
case box.TypeAVC1:
|
||||
track.Cid = box.MP4_CODEC_H264
|
||||
case box.TypeHVC1, box.TypeHEV1:
|
||||
track.Cid = box.MP4_CODEC_H265
|
||||
}
|
||||
track.Width = uint32(entry.Width)
|
||||
track.Height = uint32(entry.Height)
|
||||
@@ -161,9 +185,9 @@ func (d *Demuxer) Demux() (err error) {
|
||||
}
|
||||
d.Tracks = append(d.Tracks, track)
|
||||
}
|
||||
d.moov = box
|
||||
case *MovieFragmentBox:
|
||||
for _, traf := range box.TRAFs {
|
||||
d.moov = boxData
|
||||
case *box.MovieFragmentBox:
|
||||
for _, traf := range boxData.TRAFs {
|
||||
track := d.Tracks[traf.TFHD.TrackID-1]
|
||||
track.defaultSize = traf.TFHD.DefaultSampleSize
|
||||
track.defaultDuration = traf.TFHD.DefaultSampleDuration
|
||||
@@ -171,6 +195,7 @@ func (d *Demuxer) Demux() (err error) {
|
||||
}
|
||||
}
|
||||
d.ReadSampleIdx = make([]uint32, len(d.Tracks))
|
||||
|
||||
// for _, track := range d.Tracks {
|
||||
// if len(track.Samplelist) > 0 {
|
||||
// track.StartDts = uint64(track.Samplelist[0].DTS) * 1000 / uint64(track.Timescale)
|
||||
@@ -180,7 +205,7 @@ func (d *Demuxer) Demux() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Demuxer) SeekTime(dts uint64) (sample *Sample, err error) {
|
||||
func (d *Demuxer) SeekTime(dts uint64) (sample *box.Sample, err error) {
|
||||
var audioTrack, videoTrack *Track
|
||||
for _, track := range d.Tracks {
|
||||
if track.Cid.IsAudio() {
|
||||
@@ -218,6 +243,54 @@ func (d *Demuxer) SeekTime(dts uint64) (sample *Sample, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 函数跳帧到dts 前面的第一个关键帧位置
|
||||
*
|
||||
* @param 参数名dts 跳帧位置
|
||||
*
|
||||
* @todo 待实现的功能或改进点 audioTrack 没有同步改进
|
||||
* @author erroot
|
||||
* @date 250614
|
||||
*
|
||||
**/
|
||||
func (d *Demuxer) SeekTimePreIDR(dts uint64) (sample *Sample, err error) {
|
||||
var audioTrack, videoTrack *Track
|
||||
for _, track := range d.Tracks {
|
||||
if track.Cid.IsAudio() {
|
||||
audioTrack = track
|
||||
} else if track.Cid.IsVideo() {
|
||||
videoTrack = track
|
||||
}
|
||||
}
|
||||
if videoTrack != nil {
|
||||
idx := videoTrack.SeekPreIDR(dts)
|
||||
if idx == -1 {
|
||||
return nil, errors.New("seek failed")
|
||||
}
|
||||
d.ReadSampleIdx[videoTrack.TrackId-1] = uint32(idx)
|
||||
sample = &videoTrack.Samplelist[idx]
|
||||
if audioTrack != nil {
|
||||
for i, sample := range audioTrack.Samplelist {
|
||||
if sample.Offset < int64(videoTrack.Samplelist[idx].Offset) {
|
||||
continue
|
||||
}
|
||||
d.ReadSampleIdx[audioTrack.TrackId-1] = uint32(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if audioTrack != nil {
|
||||
idx := audioTrack.Seek(dts)
|
||||
if idx == -1 {
|
||||
return nil, errors.New("seek failed")
|
||||
}
|
||||
d.ReadSampleIdx[audioTrack.TrackId-1] = uint32(idx)
|
||||
sample = &audioTrack.Samplelist[idx]
|
||||
} else {
|
||||
return nil, pkg.ErrNoTrack
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// func (d *Demuxer) decodeTRUN(trun *TrackRunBox) {
|
||||
// dataOffset := trun.Dataoffset
|
||||
// nextDts := d.currentTrack.StartDts
|
||||
@@ -377,10 +450,10 @@ func (d *Demuxer) SeekTime(dts uint64) (sample *Sample, err error) {
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func (d *Demuxer) ReadSample(yield func(*Track, Sample) bool) {
|
||||
func (d *Demuxer) ReadSample(yield func(*Track, box.Sample) bool) {
|
||||
for {
|
||||
maxdts := int64(-1)
|
||||
minTsSample := Sample{Timestamp: uint32(maxdts)}
|
||||
minTsSample := box.Sample{Timestamp: uint32(maxdts)}
|
||||
var whichTrack *Track
|
||||
whichTracki := 0
|
||||
for i, track := range d.Tracks {
|
||||
@@ -414,9 +487,9 @@ func (d *Demuxer) ReadSample(yield func(*Track, Sample) bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Demuxer) RangeSample(yield func(*Track, *Sample) bool) {
|
||||
func (d *Demuxer) RangeSample(yield func(*Track, *box.Sample) bool) {
|
||||
for {
|
||||
var minTsSample *Sample
|
||||
var minTsSample *box.Sample
|
||||
var whichTrack *Track
|
||||
whichTracki := 0
|
||||
for i, track := range d.Tracks {
|
||||
@@ -448,6 +521,244 @@ func (d *Demuxer) RangeSample(yield func(*Track, *Sample) bool) {
|
||||
}
|
||||
|
||||
// GetMoovBox returns the Movie Box from the demuxer
|
||||
func (d *Demuxer) GetMoovBox() *MoovBox {
|
||||
func (d *Demuxer) GetMoovBox() *box.MoovBox {
|
||||
return d.moov
|
||||
}
|
||||
|
||||
// CreateRTMPSequenceFrame 创建 RTMP 序列帧
|
||||
func (d *Demuxer) CreateRTMPSequenceFrame(track *Track, allocator *util.ScalableMemoryAllocator) (videoSeq *rtmp.RTMPVideo, audioSeq *rtmp.RTMPAudio, err error) {
|
||||
switch track.Cid {
|
||||
case box.MP4_CODEC_H264:
|
||||
videoSeq = &rtmp.RTMPVideo{}
|
||||
videoSeq.SetAllocator(allocator)
|
||||
videoSeq.Append([]byte{0x17, 0x00, 0x00, 0x00, 0x00}, track.ExtraData)
|
||||
case box.MP4_CODEC_H265:
|
||||
videoSeq = &rtmp.RTMPVideo{}
|
||||
videoSeq.SetAllocator(allocator)
|
||||
videoSeq.Append([]byte{0b1001_0000 | rtmp.PacketTypeSequenceStart}, codec.FourCC_H265[:], track.ExtraData)
|
||||
case box.MP4_CODEC_AAC:
|
||||
audioSeq = &rtmp.RTMPAudio{}
|
||||
audioSeq.SetAllocator(allocator)
|
||||
audioSeq.Append([]byte{0xaf, 0x00}, track.ExtraData)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ConvertSampleToRTMP 将 MP4 sample 转换为 RTMP 格式
|
||||
func (d *Demuxer) ConvertSampleToRTMP(track *Track, sample box.Sample, allocator *util.ScalableMemoryAllocator, timestampOffset uint64) (videoFrame *rtmp.RTMPVideo, audioFrame *rtmp.RTMPAudio, err error) {
|
||||
switch track.Cid {
|
||||
case box.MP4_CODEC_H264:
|
||||
videoFrame = &rtmp.RTMPVideo{}
|
||||
videoFrame.SetAllocator(allocator)
|
||||
videoFrame.CTS = sample.CTS
|
||||
videoFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
videoFrame.AppendOne([]byte{util.Conditional[byte](sample.KeyFrame, 0x17, 0x27), 0x01, byte(videoFrame.CTS >> 24), byte(videoFrame.CTS >> 8), byte(videoFrame.CTS)})
|
||||
videoFrame.AddRecycleBytes(sample.Data)
|
||||
case box.MP4_CODEC_H265:
|
||||
videoFrame = &rtmp.RTMPVideo{}
|
||||
videoFrame.SetAllocator(allocator)
|
||||
videoFrame.CTS = uint32(sample.CTS)
|
||||
videoFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
var head []byte
|
||||
var b0 byte = 0b1010_0000
|
||||
if sample.KeyFrame {
|
||||
b0 = 0b1001_0000
|
||||
}
|
||||
if videoFrame.CTS == 0 {
|
||||
head = videoFrame.NextN(5)
|
||||
head[0] = b0 | rtmp.PacketTypeCodedFramesX
|
||||
} else {
|
||||
head = videoFrame.NextN(8)
|
||||
head[0] = b0 | rtmp.PacketTypeCodedFrames
|
||||
util.PutBE(head[5:8], videoFrame.CTS) // cts
|
||||
}
|
||||
copy(head[1:], codec.FourCC_H265[:])
|
||||
videoFrame.AddRecycleBytes(sample.Data)
|
||||
case box.MP4_CODEC_AAC:
|
||||
audioFrame = &rtmp.RTMPAudio{}
|
||||
audioFrame.SetAllocator(allocator)
|
||||
audioFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
audioFrame.AppendOne([]byte{0xaf, 0x01})
|
||||
audioFrame.AddRecycleBytes(sample.Data)
|
||||
case box.MP4_CODEC_G711A:
|
||||
audioFrame = &rtmp.RTMPAudio{}
|
||||
audioFrame.SetAllocator(allocator)
|
||||
audioFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
audioFrame.AppendOne([]byte{0x72})
|
||||
audioFrame.AddRecycleBytes(sample.Data)
|
||||
case box.MP4_CODEC_G711U:
|
||||
audioFrame = &rtmp.RTMPAudio{}
|
||||
audioFrame.SetAllocator(allocator)
|
||||
audioFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
audioFrame.AppendOne([]byte{0x82})
|
||||
audioFrame.AddRecycleBytes(sample.Data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetRTMPSequenceFrames 获取预生成的 RTMP 序列帧
|
||||
func (d *Demuxer) GetRTMPSequenceFrames() (videoSeq *rtmp.RTMPVideo, audioSeq *rtmp.RTMPAudio) {
|
||||
return d.RTMPVideoSequence, d.RTMPAudioSequence
|
||||
}
|
||||
|
||||
// IterateRTMPFrames 迭代预生成的 RTMP 帧
|
||||
func (d *Demuxer) IterateRTMPFrames(timestampOffset uint64, yield func(*RTMPFrame) bool) {
|
||||
for i := range d.RTMPFrames {
|
||||
frame := &d.RTMPFrames[i]
|
||||
|
||||
// 应用时间戳偏移
|
||||
switch f := frame.Frame.(type) {
|
||||
case *rtmp.RTMPVideo:
|
||||
f.Timestamp += uint32(timestampOffset)
|
||||
case *rtmp.RTMPAudio:
|
||||
f.Timestamp += uint32(timestampOffset)
|
||||
}
|
||||
|
||||
if !yield(frame) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetMaxTimestamp 获取所有帧中的最大时间戳
|
||||
func (d *Demuxer) GetMaxTimestamp() uint64 {
|
||||
var maxTimestamp uint64
|
||||
for _, frame := range d.RTMPFrames {
|
||||
var timestamp uint64
|
||||
switch f := frame.Frame.(type) {
|
||||
case *rtmp.RTMPVideo:
|
||||
timestamp = uint64(f.Timestamp)
|
||||
case *rtmp.RTMPAudio:
|
||||
timestamp = uint64(f.Timestamp)
|
||||
}
|
||||
if timestamp > maxTimestamp {
|
||||
maxTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return maxTimestamp
|
||||
}
|
||||
|
||||
// generateRTMPFrames 生成RTMP序列帧和所有帧数据
|
||||
func (d *Demuxer) generateRTMPFrames(allocator *util.ScalableMemoryAllocator) (err error) {
|
||||
// 生成序列帧
|
||||
for _, track := range d.Tracks {
|
||||
if track.Cid.IsVideo() && d.RTMPVideoSequence == nil {
|
||||
d.RTMPVideoSequence, _, err = d.CreateRTMPSequenceFrame(track, allocator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if track.Cid.IsAudio() && d.RTMPAudioSequence == nil {
|
||||
_, d.RTMPAudioSequence, err = d.CreateRTMPSequenceFrame(track, allocator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预生成所有 RTMP 帧
|
||||
d.RTMPFrames = make([]RTMPFrame, 0)
|
||||
|
||||
// 收集所有样本并按时间戳排序
|
||||
type sampleInfo struct {
|
||||
track *Track
|
||||
sample box.Sample
|
||||
sampleIndex uint32
|
||||
trackIndex int
|
||||
}
|
||||
|
||||
var allSamples []sampleInfo
|
||||
for trackIdx, track := range d.Tracks {
|
||||
for sampleIdx, sample := range track.Samplelist {
|
||||
// 读取样本数据
|
||||
if _, err = d.reader.Seek(sample.Offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
sample.Data = allocator.Malloc(sample.Size)
|
||||
if _, err = io.ReadFull(d.reader, sample.Data); err != nil {
|
||||
allocator.Free(sample.Data)
|
||||
return err
|
||||
}
|
||||
|
||||
allSamples = append(allSamples, sampleInfo{
|
||||
track: track,
|
||||
sample: sample,
|
||||
sampleIndex: uint32(sampleIdx),
|
||||
trackIndex: trackIdx,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间戳排序样本
|
||||
slices.SortFunc(allSamples, func(a, b sampleInfo) int {
|
||||
timeA := uint64(a.sample.Timestamp) * uint64(d.moov.MVHD.Timescale) / uint64(a.track.Timescale)
|
||||
timeB := uint64(b.sample.Timestamp) * uint64(d.moov.MVHD.Timescale) / uint64(b.track.Timescale)
|
||||
if timeA < timeB {
|
||||
return -1
|
||||
} else if timeA > timeB {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// 预生成 RTMP 帧
|
||||
for _, sampleInfo := range allSamples {
|
||||
videoFrame, audioFrame, err := d.ConvertSampleToRTMP(sampleInfo.track, sampleInfo.sample, allocator, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if videoFrame != nil {
|
||||
d.RTMPFrames = append(d.RTMPFrames, RTMPFrame{Frame: videoFrame})
|
||||
}
|
||||
|
||||
if audioFrame != nil {
|
||||
d.RTMPFrames = append(d.RTMPFrames, RTMPFrame{Frame: audioFrame})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRTMPSampleCallback 创建RTMP样本处理回调函数
|
||||
func (d *Demuxer) createRTMPSampleCallback(track *Track, trak *box.TrakBox) box.SampleCallback {
|
||||
// 首先生成序列帧
|
||||
if track.Cid.IsVideo() && d.RTMPVideoSequence == nil {
|
||||
videoSeq, _, err := d.CreateRTMPSequenceFrame(track, d.RTMPAllocator)
|
||||
if err == nil {
|
||||
d.RTMPVideoSequence = videoSeq
|
||||
}
|
||||
} else if track.Cid.IsAudio() && d.RTMPAudioSequence == nil {
|
||||
_, audioSeq, err := d.CreateRTMPSequenceFrame(track, d.RTMPAllocator)
|
||||
if err == nil {
|
||||
d.RTMPAudioSequence = audioSeq
|
||||
}
|
||||
}
|
||||
|
||||
return func(sample *box.Sample, sampleIndex int) error {
|
||||
// 读取样本数据
|
||||
if _, err := d.reader.Seek(sample.Offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
sample.Data = d.RTMPAllocator.Malloc(sample.Size)
|
||||
if _, err := io.ReadFull(d.reader, sample.Data); err != nil {
|
||||
d.RTMPAllocator.Free(sample.Data)
|
||||
return err
|
||||
}
|
||||
|
||||
// 转换为 RTMP 格式
|
||||
videoFrame, audioFrame, err := d.ConvertSampleToRTMP(track, *sample, d.RTMPAllocator, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 内部收集RTMP帧
|
||||
if videoFrame != nil {
|
||||
d.RTMPFrames = append(d.RTMPFrames, RTMPFrame{Frame: videoFrame})
|
||||
}
|
||||
if audioFrame != nil {
|
||||
d.RTMPFrames = append(d.RTMPFrames, RTMPFrame{Frame: audioFrame})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ package mp4
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
m7s "m7s.live/v5"
|
||||
"m7s.live/v5/pkg/codec"
|
||||
"m7s.live/v5/pkg/util"
|
||||
"m7s.live/v5/plugin/mp4/pkg/box"
|
||||
rtmp "m7s.live/v5/plugin/rtmp/pkg"
|
||||
)
|
||||
|
||||
@@ -35,9 +34,40 @@ func (p *HTTPReader) Run() (err error) {
|
||||
content, err = io.ReadAll(p.ReadCloser)
|
||||
demuxer = NewDemuxer(strings.NewReader(string(content)))
|
||||
}
|
||||
if err = demuxer.Demux(); err != nil {
|
||||
|
||||
// 设置RTMP分配器以启用RTMP帧收集
|
||||
demuxer.RTMPAllocator = allocator
|
||||
|
||||
if err = demuxer.DemuxWithAllocator(allocator); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取demuxer内部收集的RTMP帧
|
||||
rtmpFrames := demuxer.RTMPFrames
|
||||
|
||||
// 按时间戳排序所有帧
|
||||
slices.SortFunc(rtmpFrames, func(a, b RTMPFrame) int {
|
||||
var timeA, timeB uint64
|
||||
switch f := a.Frame.(type) {
|
||||
case *rtmp.RTMPVideo:
|
||||
timeA = uint64(f.Timestamp)
|
||||
case *rtmp.RTMPAudio:
|
||||
timeA = uint64(f.Timestamp)
|
||||
}
|
||||
switch f := b.Frame.(type) {
|
||||
case *rtmp.RTMPVideo:
|
||||
timeB = uint64(f.Timestamp)
|
||||
case *rtmp.RTMPAudio:
|
||||
timeB = uint64(f.Timestamp)
|
||||
}
|
||||
if timeA < timeB {
|
||||
return -1
|
||||
} else if timeA > timeB {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
publisher.OnSeek = func(seekTime time.Time) {
|
||||
p.Stop(errors.New("seek"))
|
||||
pullJob.Connection.Args.Set(util.StartKey, seekTime.Local().Format(util.LocalTimeFormat))
|
||||
@@ -48,103 +78,61 @@ func (p *HTTPReader) Run() (err error) {
|
||||
seekTime, _ := time.Parse(util.LocalTimeFormat, pullJob.Connection.Args.Get(util.StartKey))
|
||||
demuxer.SeekTime(uint64(seekTime.UnixMilli()))
|
||||
}
|
||||
for _, track := range demuxer.Tracks {
|
||||
switch track.Cid {
|
||||
case box.MP4_CODEC_H264:
|
||||
var sequence rtmp.RTMPVideo
|
||||
sequence.SetAllocator(allocator)
|
||||
sequence.Append([]byte{0x17, 0x00, 0x00, 0x00, 0x00}, track.ExtraData)
|
||||
err = publisher.WriteVideo(&sequence)
|
||||
case box.MP4_CODEC_H265:
|
||||
var sequence rtmp.RTMPVideo
|
||||
sequence.SetAllocator(allocator)
|
||||
sequence.Append([]byte{0b1001_0000 | rtmp.PacketTypeSequenceStart}, codec.FourCC_H265[:], track.ExtraData)
|
||||
err = publisher.WriteVideo(&sequence)
|
||||
case box.MP4_CODEC_AAC:
|
||||
var sequence rtmp.RTMPAudio
|
||||
sequence.SetAllocator(allocator)
|
||||
sequence.Append([]byte{0xaf, 0x00}, track.ExtraData)
|
||||
err = publisher.WriteAudio(&sequence)
|
||||
|
||||
// 读取预生成的 RTMP 序列帧
|
||||
videoSeq, audioSeq := demuxer.GetRTMPSequenceFrames()
|
||||
if videoSeq != nil {
|
||||
err = publisher.WriteVideo(videoSeq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if audioSeq != nil {
|
||||
err = publisher.WriteAudio(audioSeq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算最大时间戳用于累计偏移
|
||||
var maxTimestamp uint64
|
||||
for track, sample := range demuxer.ReadSample {
|
||||
timestamp := uint64(sample.Timestamp) * 1000 / uint64(track.Timescale)
|
||||
for _, frame := range rtmpFrames {
|
||||
var timestamp uint64
|
||||
switch f := frame.Frame.(type) {
|
||||
case *rtmp.RTMPVideo:
|
||||
timestamp = uint64(f.Timestamp)
|
||||
case *rtmp.RTMPAudio:
|
||||
timestamp = uint64(f.Timestamp)
|
||||
}
|
||||
if timestamp > maxTimestamp {
|
||||
maxTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
var timestampOffset uint64
|
||||
loop := p.PullJob.Loop
|
||||
for {
|
||||
demuxer.ReadSampleIdx = make([]uint32, len(demuxer.Tracks))
|
||||
for track, sample := range demuxer.ReadSample {
|
||||
// 使用预生成的 RTMP 帧进行播放
|
||||
for _, frame := range rtmpFrames {
|
||||
if p.IsStopped() {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if _, err = demuxer.reader.Seek(sample.Offset, io.SeekStart); err != nil {
|
||||
return
|
||||
|
||||
// 应用时间戳偏移
|
||||
switch f := frame.Frame.(type) {
|
||||
case *rtmp.RTMPVideo:
|
||||
f.Timestamp += uint32(timestampOffset)
|
||||
err = publisher.WriteVideo(f)
|
||||
case *rtmp.RTMPAudio:
|
||||
f.Timestamp += uint32(timestampOffset)
|
||||
err = publisher.WriteAudio(f)
|
||||
}
|
||||
sample.Data = allocator.Malloc(sample.Size)
|
||||
if _, err = io.ReadFull(demuxer.reader, sample.Data); err != nil {
|
||||
allocator.Free(sample.Data)
|
||||
return
|
||||
}
|
||||
switch track.Cid {
|
||||
case box.MP4_CODEC_H264:
|
||||
var videoFrame rtmp.RTMPVideo
|
||||
videoFrame.SetAllocator(allocator)
|
||||
videoFrame.CTS = sample.CTS
|
||||
videoFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
videoFrame.AppendOne([]byte{util.Conditional[byte](sample.KeyFrame, 0x17, 0x27), 0x01, byte(videoFrame.CTS >> 24), byte(videoFrame.CTS >> 8), byte(videoFrame.CTS)})
|
||||
videoFrame.AddRecycleBytes(sample.Data)
|
||||
err = publisher.WriteVideo(&videoFrame)
|
||||
case box.MP4_CODEC_H265:
|
||||
var videoFrame rtmp.RTMPVideo
|
||||
videoFrame.SetAllocator(allocator)
|
||||
videoFrame.CTS = uint32(sample.CTS)
|
||||
videoFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
var head []byte
|
||||
var b0 byte = 0b1010_0000
|
||||
if sample.KeyFrame {
|
||||
b0 = 0b1001_0000
|
||||
}
|
||||
if videoFrame.CTS == 0 {
|
||||
head = videoFrame.NextN(5)
|
||||
head[0] = b0 | rtmp.PacketTypeCodedFramesX
|
||||
} else {
|
||||
head = videoFrame.NextN(8)
|
||||
head[0] = b0 | rtmp.PacketTypeCodedFrames
|
||||
util.PutBE(head[5:8], videoFrame.CTS) // cts
|
||||
}
|
||||
copy(head[1:], codec.FourCC_H265[:])
|
||||
videoFrame.AddRecycleBytes(sample.Data)
|
||||
err = publisher.WriteVideo(&videoFrame)
|
||||
case box.MP4_CODEC_AAC:
|
||||
var audioFrame rtmp.RTMPAudio
|
||||
audioFrame.SetAllocator(allocator)
|
||||
audioFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
audioFrame.AppendOne([]byte{0xaf, 0x01})
|
||||
audioFrame.AddRecycleBytes(sample.Data)
|
||||
err = publisher.WriteAudio(&audioFrame)
|
||||
case box.MP4_CODEC_G711A:
|
||||
var audioFrame rtmp.RTMPAudio
|
||||
audioFrame.SetAllocator(allocator)
|
||||
audioFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
audioFrame.AppendOne([]byte{0x72})
|
||||
audioFrame.AddRecycleBytes(sample.Data)
|
||||
err = publisher.WriteAudio(&audioFrame)
|
||||
case box.MP4_CODEC_G711U:
|
||||
var audioFrame rtmp.RTMPAudio
|
||||
audioFrame.SetAllocator(allocator)
|
||||
audioFrame.Timestamp = uint32(uint64(sample.Timestamp)*1000/uint64(track.Timescale) + timestampOffset)
|
||||
audioFrame.AppendOne([]byte{0x82})
|
||||
audioFrame.AddRecycleBytes(sample.Data)
|
||||
err = publisher.WriteAudio(&audioFrame)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if loop >= 0 {
|
||||
loop--
|
||||
if loop == -1 {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
m7s "m7s.live/v5"
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/codec"
|
||||
@@ -107,39 +106,6 @@ func (t *writeTrailerTask) Run() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
type eventRecordCheck struct {
|
||||
task.Task
|
||||
DB *gorm.DB
|
||||
streamPath string
|
||||
}
|
||||
|
||||
func (t *eventRecordCheck) Run() (err error) {
|
||||
var eventRecordStreams []m7s.RecordStream
|
||||
queryRecord := m7s.RecordStream{
|
||||
EventLevel: m7s.EventLevelHigh,
|
||||
Mode: m7s.RecordModeEvent,
|
||||
Type: "mp4",
|
||||
StreamPath: t.streamPath,
|
||||
}
|
||||
t.DB.Where(&queryRecord).Find(&eventRecordStreams) //搜索事件录像,且为重要事件(无法自动删除)
|
||||
if len(eventRecordStreams) > 0 {
|
||||
for _, recordStream := range eventRecordStreams {
|
||||
var unimportantEventRecordStreams []m7s.RecordStream
|
||||
queryRecord.EventLevel = m7s.EventLevelLow
|
||||
queryRecord.Mode = m7s.RecordModeAuto
|
||||
query := `start_time <= ? and end_time >= ?`
|
||||
t.DB.Where(&queryRecord).Where(query, recordStream.EndTime, recordStream.StartTime).Find(&unimportantEventRecordStreams)
|
||||
if len(unimportantEventRecordStreams) > 0 {
|
||||
for _, unimportantEventRecordStream := range unimportantEventRecordStreams {
|
||||
unimportantEventRecordStream.EventLevel = m7s.EventLevelHigh
|
||||
t.DB.Save(&unimportantEventRecordStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func init() {
|
||||
m7s.Servers.AddTask(&writeTrailerQueueTask)
|
||||
}
|
||||
@@ -150,20 +116,12 @@ func NewRecorder(conf config.Record) m7s.IRecorder {
|
||||
|
||||
type Recorder struct {
|
||||
m7s.DefaultRecorder
|
||||
muxer *Muxer
|
||||
file *os.File
|
||||
stream m7s.RecordStream
|
||||
muxer *Muxer
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (r *Recorder) writeTailer(end time.Time) {
|
||||
r.stream.EndTime = end
|
||||
if r.RecordJob.Plugin.DB != nil {
|
||||
r.RecordJob.Plugin.DB.Save(&r.stream)
|
||||
writeTrailerQueueTask.AddTask(&eventRecordCheck{
|
||||
DB: r.RecordJob.Plugin.DB,
|
||||
streamPath: r.stream.StreamPath,
|
||||
})
|
||||
}
|
||||
r.WriteTail(end, &writeTrailerQueueTask)
|
||||
writeTrailerQueueTask.AddTask(&writeTrailerTask{
|
||||
muxer: r.muxer,
|
||||
file: r.file,
|
||||
@@ -178,46 +136,7 @@ var CustomFileName = func(job *m7s.RecordJob) string {
|
||||
}
|
||||
|
||||
func (r *Recorder) createStream(start time.Time) (err error) {
|
||||
recordJob := &r.RecordJob
|
||||
sub := recordJob.Subscriber
|
||||
r.stream = m7s.RecordStream{
|
||||
StartTime: start,
|
||||
StreamPath: sub.StreamPath,
|
||||
FilePath: CustomFileName(&r.RecordJob),
|
||||
EventId: recordJob.EventId,
|
||||
EventDesc: recordJob.EventDesc,
|
||||
EventName: recordJob.EventName,
|
||||
EventLevel: recordJob.EventLevel,
|
||||
BeforeDuration: recordJob.BeforeDuration,
|
||||
AfterDuration: recordJob.AfterDuration,
|
||||
Mode: recordJob.Mode,
|
||||
Type: "mp4",
|
||||
}
|
||||
dir := filepath.Dir(r.stream.FilePath)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
r.file, err = os.Create(r.stream.FilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if recordJob.RecConf.Type == "fmp4" {
|
||||
r.stream.Type = "fmp4"
|
||||
r.muxer = NewMuxerWithStreamPath(FLAG_FRAGMENT, r.stream.StreamPath)
|
||||
} else {
|
||||
r.muxer = NewMuxerWithStreamPath(0, r.stream.StreamPath)
|
||||
}
|
||||
r.muxer.WriteInitSegment(r.file)
|
||||
if sub.Publisher.HasAudioTrack() {
|
||||
r.stream.AudioCodec = sub.Publisher.AudioTrack.ICodecCtx.String()
|
||||
}
|
||||
if sub.Publisher.HasVideoTrack() {
|
||||
r.stream.VideoCodec = sub.Publisher.VideoTrack.ICodecCtx.String()
|
||||
}
|
||||
if recordJob.Plugin.DB != nil {
|
||||
recordJob.Plugin.DB.Save(&r.stream)
|
||||
}
|
||||
return
|
||||
return r.CreateStream(start, CustomFileName)
|
||||
}
|
||||
|
||||
func (r *Recorder) Dispose() {
|
||||
@@ -231,17 +150,28 @@ func (r *Recorder) Run() (err error) {
|
||||
sub := recordJob.Subscriber
|
||||
var audioTrack, videoTrack *Track
|
||||
startTime := time.Now()
|
||||
if recordJob.BeforeDuration > 0 {
|
||||
startTime = startTime.Add(-recordJob.BeforeDuration)
|
||||
if recordJob.Event != nil {
|
||||
startTime = startTime.Add(-time.Duration(recordJob.Event.BeforeDuration) * time.Millisecond)
|
||||
}
|
||||
err = r.createStream(startTime)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.file, err = os.Create(r.Event.FilePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if recordJob.RecConf.Type == "fmp4" {
|
||||
r.Event.Type = "fmp4"
|
||||
r.muxer = NewMuxerWithStreamPath(FLAG_FRAGMENT, r.Event.StreamPath)
|
||||
} else {
|
||||
r.muxer = NewMuxerWithStreamPath(0, r.Event.StreamPath)
|
||||
}
|
||||
r.muxer.WriteInitSegment(r.file)
|
||||
var at, vt *pkg.AVTrack
|
||||
|
||||
checkEventRecordStop := func(absTime uint32) (err error) {
|
||||
if duration := int64(absTime); time.Duration(duration)*time.Millisecond >= recordJob.AfterDuration+recordJob.BeforeDuration {
|
||||
if absTime >= recordJob.Event.AfterDuration+recordJob.Event.BeforeDuration {
|
||||
r.RecordJob.Stop(task.ErrStopByUser)
|
||||
}
|
||||
return
|
||||
@@ -269,9 +199,9 @@ func (r *Recorder) Run() (err error) {
|
||||
}
|
||||
|
||||
return m7s.PlayBlock(sub, func(audio *pkg.RawAudio) error {
|
||||
r.stream.Duration = sub.AudioReader.AbsTime
|
||||
r.Event.Duration = sub.AudioReader.AbsTime
|
||||
if sub.VideoReader == nil {
|
||||
if recordJob.AfterDuration != 0 {
|
||||
if recordJob.Event != nil {
|
||||
err := checkEventRecordStop(sub.VideoReader.AbsTime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -314,9 +244,9 @@ func (r *Recorder) Run() (err error) {
|
||||
Timestamp: uint32(dts),
|
||||
})
|
||||
}, func(video *rtmp.RTMPVideo) error {
|
||||
r.stream.Duration = sub.VideoReader.AbsTime
|
||||
r.Event.Duration = sub.VideoReader.AbsTime
|
||||
if sub.VideoReader.Value.IDR {
|
||||
if recordJob.AfterDuration != 0 {
|
||||
if recordJob.Event != nil {
|
||||
err := checkEventRecordStop(sub.VideoReader.AbsTime)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -102,6 +102,28 @@ func (track *Track) Seek(dts uint64) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 函数跳帧到dts 前面的第一个关键帧位置
|
||||
*
|
||||
* @param 参数名dts 跳帧位置
|
||||
*
|
||||
* @author erroot
|
||||
* @date 250614
|
||||
*
|
||||
**/
|
||||
func (track *Track) SeekPreIDR(dts uint64) int {
|
||||
idx := 0
|
||||
for i, sample := range track.Samplelist {
|
||||
if track.Cid.IsVideo() && sample.KeyFrame {
|
||||
idx = i
|
||||
}
|
||||
if sample.Timestamp*1000/uint32(track.Timescale) > uint32(dts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func (track *Track) makeEdtsBox() *ContainerBox {
|
||||
return CreateContainerBox(TypeEDTS, track.makeElstBox())
|
||||
}
|
||||
|
||||
@@ -185,8 +185,6 @@ func (t *RecordRecoveryTask) recoverRecordFromFile(filePath string) error {
|
||||
FilePath: filePath,
|
||||
StreamPath: streamPath,
|
||||
Type: "mp4",
|
||||
Mode: m7s.RecordModeAuto, // 默认为自动录制模式
|
||||
EventLevel: m7s.EventLevelLow, // 默认为低级别事件
|
||||
}
|
||||
|
||||
// 设置开始和结束时间
|
||||
|
||||
338
plugin/mp4/util.go
Normal file
338
plugin/mp4/util.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package plugin_mp4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
mp4 "m7s.live/v5/plugin/mp4/pkg"
|
||||
"m7s.live/v5/plugin/mp4/pkg/box"
|
||||
)
|
||||
|
||||
func saveAsJPG(img image.Image, path string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
opt := jpeg.Options{Quality: 90}
|
||||
return jpeg.Encode(file, img, &opt)
|
||||
}
|
||||
|
||||
func ExtractH264SPSPPS(extraData []byte) (sps, pps []byte, err error) {
|
||||
if len(extraData) < 7 {
|
||||
return nil, nil, fmt.Errorf("extradata too short")
|
||||
}
|
||||
|
||||
// 解析 SPS 数量 (第6字节低5位)
|
||||
spsCount := int(extraData[5] & 0x1F)
|
||||
offset := 6 // 当前解析位置
|
||||
|
||||
// 提取 SPS
|
||||
for i := 0; i < spsCount; i++ {
|
||||
if offset+2 > len(extraData) {
|
||||
return nil, nil, fmt.Errorf("invalid sps length")
|
||||
}
|
||||
spsLen := int(binary.BigEndian.Uint16(extraData[offset : offset+2]))
|
||||
offset += 2
|
||||
if offset+spsLen > len(extraData) {
|
||||
return nil, nil, fmt.Errorf("sps data overflow")
|
||||
}
|
||||
sps = extraData[offset : offset+spsLen]
|
||||
offset += spsLen
|
||||
}
|
||||
|
||||
// 提取 PPS 数量
|
||||
if offset >= len(extraData) {
|
||||
return nil, nil, fmt.Errorf("missing pps count")
|
||||
}
|
||||
ppsCount := int(extraData[offset])
|
||||
offset++
|
||||
|
||||
// 提取 PPS
|
||||
for i := 0; i < ppsCount; i++ {
|
||||
if offset+2 > len(extraData) {
|
||||
return nil, nil, fmt.Errorf("invalid pps length")
|
||||
}
|
||||
ppsLen := int(binary.BigEndian.Uint16(extraData[offset : offset+2]))
|
||||
offset += 2
|
||||
if offset+ppsLen > len(extraData) {
|
||||
return nil, nil, fmt.Errorf("pps data overflow")
|
||||
}
|
||||
pps = extraData[offset : offset+ppsLen]
|
||||
offset += ppsLen
|
||||
}
|
||||
return sps, pps, nil
|
||||
}
|
||||
|
||||
// 转换函数(支持动态插入参数集)
|
||||
func ConvertAVCCH264ToAnnexB(data []byte, extraData []byte, isFirst *bool) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
pos := 0
|
||||
|
||||
for pos < len(data) {
|
||||
if pos+4 > len(data) {
|
||||
break
|
||||
}
|
||||
nalSize := binary.BigEndian.Uint32(data[pos : pos+4])
|
||||
pos += 4
|
||||
nalStart := pos
|
||||
pos += int(nalSize)
|
||||
if pos > len(data) {
|
||||
break
|
||||
}
|
||||
nalu := data[nalStart:pos]
|
||||
nalType := nalu[0] & 0x1F
|
||||
|
||||
// 关键帧前插入SPS/PPS(仅需执行一次)
|
||||
if *isFirst && nalType == 5 {
|
||||
sps, pps, err := ExtractH264SPSPPS(extraData)
|
||||
if err != nil {
|
||||
//panic(err)
|
||||
return nil, err
|
||||
}
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
buf.Write(sps)
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
buf.Write(pps)
|
||||
//buf.Write(videoTrack.ExtraData)
|
||||
*isFirst = false // 仅首帧插入
|
||||
}
|
||||
|
||||
// 保留SEI单元(类型6)和所有其他单元
|
||||
if nalType == 5 || nalType == 6 { // IDR/SEI用4字节起始码
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
} else {
|
||||
buf.Write([]byte{0x00, 0x00, 0x01}) // 其他用3字节
|
||||
}
|
||||
buf.Write(nalu)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
/*
|
||||
H.264与H.265的AVCC格式差异
|
||||
VPS引入:H.265新增视频参数集(VPS),用于描述多层编码、时序等信息
|
||||
*/
|
||||
// 提取H.265的VPS/SPS/PPS(HEVCDecoderConfigurationRecord格式)
|
||||
func ExtractHEVCParams(extraData []byte) (vps, sps, pps []byte, err error) {
|
||||
if len(extraData) < 22 {
|
||||
return nil, nil, nil, errors.New("extra data too short")
|
||||
}
|
||||
|
||||
// HEVC的extradata格式参考ISO/IEC 14496-15
|
||||
offset := 22 // 跳过头部22字节
|
||||
if offset+2 > len(extraData) {
|
||||
return nil, nil, nil, errors.New("invalid extra data")
|
||||
}
|
||||
|
||||
numOfArrays := int(extraData[offset])
|
||||
offset++
|
||||
|
||||
for i := 0; i < numOfArrays; i++ {
|
||||
if offset+3 > len(extraData) {
|
||||
break
|
||||
}
|
||||
|
||||
naluType := extraData[offset] & 0x3F
|
||||
offset++
|
||||
count := int(binary.BigEndian.Uint16(extraData[offset:]))
|
||||
offset += 2
|
||||
|
||||
for j := 0; j < count; j++ {
|
||||
if offset+2 > len(extraData) {
|
||||
break
|
||||
}
|
||||
|
||||
naluSize := int(binary.BigEndian.Uint16(extraData[offset:]))
|
||||
offset += 2
|
||||
|
||||
if offset+naluSize > len(extraData) {
|
||||
break
|
||||
}
|
||||
|
||||
naluData := extraData[offset : offset+naluSize]
|
||||
offset += naluSize
|
||||
|
||||
// 根据类型存储参数集
|
||||
switch naluType {
|
||||
case 32: // VPS
|
||||
if vps == nil {
|
||||
vps = make([]byte, len(naluData))
|
||||
copy(vps, naluData)
|
||||
}
|
||||
case 33: // SPS
|
||||
if sps == nil {
|
||||
sps = make([]byte, len(naluData))
|
||||
copy(sps, naluData)
|
||||
}
|
||||
case 34: // PPS
|
||||
if pps == nil {
|
||||
pps = make([]byte, len(naluData))
|
||||
copy(pps, naluData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vps == nil || sps == nil || pps == nil {
|
||||
return nil, nil, nil, errors.New("missing required parameter sets")
|
||||
}
|
||||
|
||||
return vps, sps, pps, nil
|
||||
}
|
||||
|
||||
// H.265的AVCC转Annex B
|
||||
func ConvertAVCCHEVCToAnnexB(data []byte, extraData []byte, isFirst *bool) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
pos := 0
|
||||
|
||||
// 首帧插入VPS/SPS/PPS
|
||||
if *isFirst {
|
||||
vps, sps, pps, err := ExtractHEVCParams(extraData)
|
||||
if err == nil {
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
buf.Write(vps)
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
buf.Write(sps)
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
buf.Write(pps)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 处理NALU
|
||||
for pos < len(data) {
|
||||
if pos+4 > len(data) {
|
||||
break
|
||||
}
|
||||
nalSize := binary.BigEndian.Uint32(data[pos : pos+4])
|
||||
pos += 4
|
||||
nalStart := pos
|
||||
pos += int(nalSize)
|
||||
if pos > len(data) {
|
||||
break
|
||||
}
|
||||
nalu := data[nalStart:pos]
|
||||
nalType := (nalu[0] >> 1) & 0x3F // H.265的NALU类型在头部的第2-7位
|
||||
|
||||
// 关键帧或参数集使用4字节起始码
|
||||
if nalType == 19 || nalType == 20 || nalType >= 32 && nalType <= 34 {
|
||||
buf.Write([]byte{0x00, 0x00, 0x00, 0x01})
|
||||
} else {
|
||||
buf.Write([]byte{0x00, 0x00, 0x01})
|
||||
}
|
||||
buf.Write(nalu)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ffmpeg -hide_banner -i gop.mp4 -vf "select=eq(n\,15)" -vframes 1 -f image2 -pix_fmt bgr24 output.bmp
|
||||
func ProcessWithFFmpeg(samples []box.Sample, index int, videoTrack *mp4.Track) (image.Image, error) {
|
||||
// code := "h264"
|
||||
// if videoTrack.Cid == box.MP4_CODEC_H265 {
|
||||
// code = "hevc"
|
||||
// }
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-hide_banner",
|
||||
//"-f", code, //"h264" 强制指定输入格式为H.264裸流
|
||||
"-i", "pipe:0",
|
||||
"-vf", fmt.Sprintf("select=eq(n\\,%d)", index),
|
||||
"-vframes", "1",
|
||||
"-pix_fmt", "bgr24",
|
||||
"-f", "rawvideo",
|
||||
"pipe:1")
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
errOutput, _ := io.ReadAll(stderr)
|
||||
log.Printf("FFmpeg stderr: %s", errOutput)
|
||||
}()
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
log.Printf("cmd.Start失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer stdin.Close()
|
||||
isFirst := true
|
||||
for _, sample := range samples {
|
||||
|
||||
if videoTrack.Cid == box.MP4_CODEC_H264 {
|
||||
annexb, _ := ConvertAVCCH264ToAnnexB(sample.Data, videoTrack.ExtraData, &isFirst)
|
||||
if _, err := stdin.Write(annexb); err != nil {
|
||||
log.Printf("写入失败: %v", err)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
annexb, _ := ConvertAVCCHEVCToAnnexB(sample.Data, videoTrack.ExtraData, &isFirst)
|
||||
if _, err := stdin.Write(annexb); err != nil {
|
||||
log.Printf("写入失败: %v", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 读取原始RGB数据
|
||||
var buf bytes.Buffer
|
||||
if _, err = io.Copy(&buf, stdout); err != nil {
|
||||
log.Printf("读取失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if err = cmd.Wait(); err != nil {
|
||||
log.Printf("cmd.Wait失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//log.Printf("ffmpeg 提取成功: data size:%v", buf.Len())
|
||||
|
||||
// 转换为image.Image对象
|
||||
data := buf.Bytes()
|
||||
//width, height := parseBMPDimensions(data)
|
||||
|
||||
width := int(videoTrack.Width)
|
||||
height := int(videoTrack.Height)
|
||||
|
||||
log.Printf("ffmpeg size: %v,%v", width, height)
|
||||
|
||||
//FFmpeg的 rawvideo 输出默认采用从上到下的扫描方式
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
//pos := (height-y-1)*width*3 + x*3
|
||||
pos := (y*width + x) * 3 // 关键修复:按行顺序读取
|
||||
img.Set(x, y, color.RGBA{
|
||||
R: data[pos+2],
|
||||
G: data[pos+1],
|
||||
B: data[pos],
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
@@ -238,10 +238,9 @@ func (p *RecordFilePuller) queryRecordStreams(startTime, endTime time.Time) (err
|
||||
return pkg.ErrNoDB
|
||||
}
|
||||
queryRecord := RecordStream{
|
||||
Mode: RecordModeAuto,
|
||||
Type: p.Type,
|
||||
}
|
||||
tx := p.PullJob.Plugin.DB.Where(&queryRecord).Find(&p.Streams, "end_time>=? AND start_time<=? AND stream_path=?", startTime, endTime, p.PullJob.RemoteURL)
|
||||
tx := p.PullJob.Plugin.DB.Where(&queryRecord).Find(&p.Streams, "event_id=0 AND end_time>=? AND start_time<=? AND stream_path=?", startTime, endTime, p.PullJob.RemoteURL)
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
140
recoder.go
140
recoder.go
@@ -1,6 +1,8 @@
|
||||
package m7s
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -12,58 +14,46 @@ import (
|
||||
"m7s.live/v5/pkg"
|
||||
)
|
||||
|
||||
const (
|
||||
RecordModeAuto RecordMode = "auto"
|
||||
RecordModeEvent RecordMode = "event"
|
||||
EventLevelLow EventLevel = "low"
|
||||
EventLevelHigh EventLevel = "high"
|
||||
)
|
||||
|
||||
type (
|
||||
EventLevel = string
|
||||
RecordMode = string
|
||||
IRecorder interface {
|
||||
IRecorder interface {
|
||||
task.ITask
|
||||
GetRecordJob() *RecordJob
|
||||
}
|
||||
RecorderFactory = func(config.Record) IRecorder
|
||||
RecordJob struct {
|
||||
// RecordEvent 包含录像事件的公共字段
|
||||
|
||||
EventRecordStream struct {
|
||||
CreatedAt time.Time
|
||||
*config.RecordEvent
|
||||
RecordStream
|
||||
}
|
||||
RecordJob struct {
|
||||
task.Job
|
||||
StreamPath string // 对应本地流
|
||||
Plugin *Plugin
|
||||
Subscriber *Subscriber
|
||||
SubConf *config.Subscribe
|
||||
RecConf *config.Record
|
||||
recorder IRecorder
|
||||
EventId string `json:"eventId" desc:"事件编号"`
|
||||
Mode RecordMode `json:"mode" desc:"事件类型,auto=连续录像模式,event=事件录像模式"`
|
||||
BeforeDuration time.Duration `json:"beforeDuration" desc:"事件前缓存时长"`
|
||||
AfterDuration time.Duration `json:"afterDuration" desc:"事件后缓存时长"`
|
||||
EventDesc string `json:"eventDesc" desc:"事件描述"`
|
||||
EventLevel EventLevel `json:"eventLevel" desc:"事件级别"`
|
||||
EventName string `json:"eventName" desc:"事件名称"`
|
||||
Event *config.RecordEvent
|
||||
StreamPath string // 对应本地流
|
||||
Plugin *Plugin
|
||||
Subscriber *Subscriber
|
||||
SubConf *config.Subscribe
|
||||
RecConf *config.Record
|
||||
recorder IRecorder
|
||||
}
|
||||
DefaultRecorder struct {
|
||||
task.Task
|
||||
RecordJob RecordJob
|
||||
Event EventRecordStream
|
||||
}
|
||||
RecordStream struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
StartTime, EndTime time.Time `gorm:"type:datetime;default:NULL"`
|
||||
Duration uint32 `gorm:"comment:录像时长;default:0"`
|
||||
EventId string `json:"eventId" desc:"事件编号" gorm:"type:varchar(255);comment:事件编号"`
|
||||
Mode RecordMode `json:"mode" desc:"事件类型,auto=连续录像模式,event=事件录像模式" gorm:"type:varchar(255);comment:事件类型,auto=连续录像模式,event=事件录像模式;default:'auto'"`
|
||||
EventName string `json:"eventName" desc:"事件名称" gorm:"type:varchar(255);comment:事件名称"`
|
||||
BeforeDuration time.Duration `json:"beforeDuration" desc:"事件前缓存时长" gorm:"type:BIGINT;comment:事件前缓存时长;default:30000000000"`
|
||||
AfterDuration time.Duration `json:"afterDuration" desc:"事件后缓存时长" gorm:"type:BIGINT;comment:事件后缓存时长;default:30000000000"`
|
||||
Filename string `json:"fileName" desc:"文件名" gorm:"type:varchar(255);comment:文件名"`
|
||||
EventDesc string `json:"eventDesc" desc:"事件描述" gorm:"type:varchar(255);comment:事件描述"`
|
||||
Type string `json:"type" desc:"录像文件类型" gorm:"type:varchar(255);comment:录像文件类型,flv,mp4,raw,fmp4,hls"`
|
||||
EventLevel EventLevel `json:"eventLevel" desc:"事件级别" gorm:"type:varchar(255);comment:事件级别,high表示重要事件,无法删除且表示无需自动删除,low表示非重要事件,达到自动删除时间后,自动删除;default:'low'"`
|
||||
FilePath string
|
||||
StreamPath string
|
||||
AudioCodec, VideoCodec string
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" yaml:"-"`
|
||||
ID uint `gorm:"primarykey"`
|
||||
StartTime time.Time `gorm:"default:NULL"`
|
||||
EndTime time.Time `gorm:"default:NULL"`
|
||||
Duration uint32 `gorm:"comment:录像时长;default:0"`
|
||||
Filename string `json:"fileName" desc:"文件名" gorm:"type:varchar(255);comment:文件名"`
|
||||
Type string `json:"type" desc:"录像文件类型" gorm:"type:varchar(255);comment:录像文件类型,flv,mp4,raw,fmp4,hls"`
|
||||
FilePath string
|
||||
StreamPath string
|
||||
AudioCodec string
|
||||
VideoCodec string
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" yaml:"-"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,6 +65,52 @@ func (r *DefaultRecorder) Start() (err error) {
|
||||
return r.RecordJob.Subscribe()
|
||||
}
|
||||
|
||||
func (r *DefaultRecorder) CreateStream(start time.Time, customFileName func(*RecordJob) string) (err error) {
|
||||
recordJob := &r.RecordJob
|
||||
sub := recordJob.Subscriber
|
||||
r.Event.RecordStream = RecordStream{
|
||||
StartTime: start,
|
||||
StreamPath: sub.StreamPath,
|
||||
FilePath: customFileName(recordJob),
|
||||
Type: recordJob.RecConf.Type,
|
||||
}
|
||||
dir := filepath.Dir(r.Event.FilePath)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
if sub.Publisher.HasAudioTrack() {
|
||||
r.Event.AudioCodec = sub.Publisher.AudioTrack.ICodecCtx.String()
|
||||
}
|
||||
if sub.Publisher.HasVideoTrack() {
|
||||
r.Event.VideoCodec = sub.Publisher.VideoTrack.ICodecCtx.String()
|
||||
}
|
||||
if recordJob.Plugin.DB != nil {
|
||||
if recordJob.Event != nil {
|
||||
r.Event.RecordEvent = recordJob.Event
|
||||
recordJob.Plugin.DB.Save(&r.Event)
|
||||
} else {
|
||||
recordJob.Plugin.DB.Save(&r.Event.RecordStream)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *DefaultRecorder) WriteTail(end time.Time, tailJob task.IJob) {
|
||||
r.Event.EndTime = end
|
||||
if r.RecordJob.Plugin.DB != nil {
|
||||
// 将事件和录像记录关联
|
||||
if r.RecordJob.Event != nil {
|
||||
r.RecordJob.Plugin.DB.Save(&r.Event)
|
||||
} else {
|
||||
r.RecordJob.Plugin.DB.Save(&r.Event.RecordStream)
|
||||
}
|
||||
}
|
||||
if tailJob == nil {
|
||||
return
|
||||
}
|
||||
tailJob.AddTask(NewEventRecordCheck(r.Event.Type, r.Event.StreamPath, r.RecordJob.Plugin.DB))
|
||||
}
|
||||
|
||||
func (p *RecordJob) GetKey() string {
|
||||
return p.RecConf.FilePath
|
||||
}
|
||||
@@ -150,3 +186,27 @@ func (p *RecordJob) Start() (err error) {
|
||||
p.AddTask(p.recorder, p.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
func NewEventRecordCheck(t string, streamPath string, db *gorm.DB) *eventRecordCheck {
|
||||
return &eventRecordCheck{
|
||||
DB: db,
|
||||
streamPath: streamPath,
|
||||
Type: t,
|
||||
}
|
||||
}
|
||||
|
||||
type eventRecordCheck struct {
|
||||
task.Task
|
||||
DB *gorm.DB
|
||||
streamPath string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (t *eventRecordCheck) Run() (err error) {
|
||||
var eventRecordStreams []EventRecordStream
|
||||
t.DB.Find(&eventRecordStreams, "type=? AND level=high AND stream_path=?", t.Type, t.streamPath) //搜索事件录像,且为重要事件(无法自动删除)
|
||||
for _, recordStream := range eventRecordStreams {
|
||||
t.DB.Model(&EventRecordStream{}).Where(`level=low AND start_time <= ? and end_time >= ?`, recordStream.EndTime, recordStream.StartTime).Update("level", config.EventLevelHigh)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
202
scripts/packet_replayer.py
Normal file
202
scripts/packet_replayer.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
from scapy.all import rdpcap, IP, TCP, UDP, Raw, send, sr1, sr, PcapReader
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
import random
|
||||
import threading
|
||||
import queue
|
||||
import socket
|
||||
|
||||
class PacketReplayer:
|
||||
def __init__(self, pcap_file, target_ip, target_port):
|
||||
self.pcap_file = pcap_file
|
||||
self.target_ip = target_ip
|
||||
self.target_port = target_port
|
||||
self.connections = defaultdict(list) # 存储每个连接的包序列
|
||||
self.response_queue = queue.Queue()
|
||||
self.stop_reading = threading.Event()
|
||||
self.socket = None
|
||||
|
||||
def establish_tcp_connection(self, src_port):
|
||||
"""建立TCP连接"""
|
||||
print(f"正在建立TCP连接 {self.target_ip}:{self.target_port}...")
|
||||
try:
|
||||
# 创建socket对象
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
# 绑定源端口(如果指定了端口)
|
||||
if src_port > 0:
|
||||
try:
|
||||
self.socket.bind(('0.0.0.0', src_port))
|
||||
except socket.error as e:
|
||||
print(f"指定端口 {src_port} 被占用,将使用随机端口")
|
||||
self.socket.bind(('0.0.0.0', 0)) # 使用随机可用端口
|
||||
else:
|
||||
self.socket.bind(('0.0.0.0', 0)) # 使用随机可用端口
|
||||
|
||||
# 获取实际使用的端口
|
||||
actual_port = self.socket.getsockname()[1]
|
||||
print(f"使用本地端口: {actual_port}")
|
||||
|
||||
# 设置超时
|
||||
self.socket.settimeout(5)
|
||||
# 连接目标
|
||||
self.socket.connect((self.target_ip, self.target_port))
|
||||
print("TCP连接已建立")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"建立连接失败: {e}")
|
||||
if self.socket:
|
||||
self.socket.close()
|
||||
self.socket = None
|
||||
return False
|
||||
|
||||
def process_packet(self, packet, src_ip=None, src_port=None, protocol=None):
|
||||
"""处理单个数据包"""
|
||||
if IP not in packet:
|
||||
return
|
||||
|
||||
# 检查源IP
|
||||
if src_ip and packet[IP].src != src_ip:
|
||||
return
|
||||
|
||||
# 检查协议和源端口
|
||||
if protocol == 'tcp' and TCP in packet:
|
||||
if src_port and packet[TCP].sport != src_port:
|
||||
return
|
||||
conn_id = (packet[IP].src, packet[TCP].sport)
|
||||
self.connections[conn_id].append(packet)
|
||||
elif protocol == 'udp' and UDP in packet:
|
||||
if src_port and packet[UDP].sport != src_port:
|
||||
return
|
||||
conn_id = (packet[IP].src, packet[UDP].sport)
|
||||
self.connections[conn_id].append(packet)
|
||||
elif not protocol: # 如果没有指定协议,则包含所有IP包
|
||||
if TCP in packet:
|
||||
if src_port and packet[TCP].sport != src_port:
|
||||
return
|
||||
conn_id = (packet[IP].src, packet[TCP].sport)
|
||||
self.connections[conn_id].append(packet)
|
||||
elif UDP in packet:
|
||||
if src_port and packet[UDP].sport != src_port:
|
||||
return
|
||||
conn_id = (packet[IP].src, packet[UDP].sport)
|
||||
self.connections[conn_id].append(packet)
|
||||
|
||||
def response_reader(self, src_port):
|
||||
"""持续读取服务器响应的线程函数"""
|
||||
while not self.stop_reading.is_set() and self.socket:
|
||||
try:
|
||||
# 使用socket接收数据
|
||||
data = self.socket.recv(4096)
|
||||
if data:
|
||||
self.response_queue.put(data)
|
||||
print(f"收到响应: {len(data)} 字节")
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
if not self.stop_reading.is_set():
|
||||
print(f"读取响应时出错: {e}")
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
def replay_packets(self, src_ip=None, src_port=None, protocol=None, delay=0):
|
||||
"""边读取边重放数据包"""
|
||||
print(f"开始读取并重放数据包到 {self.target_ip}:{self.target_port}")
|
||||
|
||||
try:
|
||||
# 使用PcapReader逐包读取
|
||||
reader = PcapReader(self.pcap_file)
|
||||
packet_count = 0
|
||||
connection_established = False
|
||||
|
||||
# 读取并处理数据包
|
||||
for packet in reader:
|
||||
packet_count += 1
|
||||
|
||||
if IP not in packet:
|
||||
continue
|
||||
|
||||
# 检查源IP
|
||||
if src_ip and packet[IP].src != src_ip:
|
||||
continue
|
||||
|
||||
# 检查协议和源端口
|
||||
current_src_port = None
|
||||
if protocol == 'tcp' and TCP in packet:
|
||||
if src_port and packet[TCP].sport != src_port:
|
||||
continue
|
||||
current_src_port = packet[TCP].sport
|
||||
elif protocol == 'udp' and UDP in packet:
|
||||
if src_port and packet[UDP].sport != src_port:
|
||||
continue
|
||||
current_src_port = packet[UDP].sport
|
||||
elif not protocol: # 如果没有指定协议,则包含所有IP包
|
||||
if TCP in packet:
|
||||
if src_port and packet[TCP].sport != src_port:
|
||||
continue
|
||||
current_src_port = packet[TCP].sport
|
||||
elif UDP in packet:
|
||||
if src_port and packet[UDP].sport != src_port:
|
||||
continue
|
||||
current_src_port = packet[UDP].sport
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# 找到第一个符合条件的包,建立连接
|
||||
if not connection_established:
|
||||
if not self.establish_tcp_connection(current_src_port):
|
||||
print("无法建立连接,退出")
|
||||
return
|
||||
# 启动响应读取线程
|
||||
self.stop_reading.clear()
|
||||
reader_thread = threading.Thread(target=self.response_reader, args=(current_src_port,))
|
||||
reader_thread.daemon = True
|
||||
reader_thread.start()
|
||||
connection_established = True
|
||||
|
||||
# 发送当前数据包
|
||||
try:
|
||||
if Raw in packet:
|
||||
self.socket.send(packet[Raw].load)
|
||||
packet_time = time.strftime("%H:%M:%S", time.localtime(float(packet.time)))
|
||||
print(f"[{packet_time}] [序号:{packet_count}] 已发送数据包 (负载大小: {len(packet[Raw].load)} 字节)")
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
except Exception as e:
|
||||
print(f"发送数据包 {packet_count} 时出错: {e}")
|
||||
sys.exit(1) # 发送失败直接退出进程
|
||||
|
||||
print(f"总共处理了 {packet_count} 个数据包")
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理数据包时出错: {e}")
|
||||
sys.exit(1) # 其他错误也直接退出进程
|
||||
finally:
|
||||
# 关闭连接和停止读取线程
|
||||
self.stop_reading.set()
|
||||
if self.socket:
|
||||
self.socket.close()
|
||||
self.socket = None
|
||||
reader.close()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Wireshark数据包重放工具')
|
||||
parser.add_argument('pcap_file', help='pcap文件路径')
|
||||
parser.add_argument('target_ip', help='目标IP地址')
|
||||
parser.add_argument('target_port', type=int, help='目标端口')
|
||||
parser.add_argument('--delay', type=float, default=0, help='数据包发送间隔(秒)')
|
||||
parser.add_argument('--src-ip', help='过滤源IP地址')
|
||||
parser.add_argument('--src-port', type=int, help='过滤源端口')
|
||||
parser.add_argument('--protocol', choices=['tcp', 'udp'], help='过滤协议类型')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
replayer = PacketReplayer(args.pcap_file, args.target_ip, args.target_port)
|
||||
replayer.replay_packets(args.src_ip, args.src_port, args.protocol, args.delay)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user