Compare commits

..

22 Commits
v5.0.2 ... dev

Author SHA1 Message Date
langhuihui
a1b40bd7b8 feat: add port map to webrtc 2025-06-13 16:54:29 +08:00
pggiroro
827f6eac8d fix: ignore RecordEvent in gorm 2025-06-13 12:51:46 +08:00
langhuihui
ee056144a8 refactor: record 2025-06-13 12:51:41 +08:00
langhuihui
20ec6c55cd fix: plugin init error 2025-06-12 15:08:47 +08:00
langhuihui
e478a1972e fix: webrtc batch bug 2025-06-12 14:21:59 +08:00
langhuihui
94be02cd79 feat: consider pull proxy disable status 2025-06-12 13:50:47 +08:00
langhuihui
bacda6f5a0 feat: webrtc fit client codecs 2025-06-12 12:49:36 +08:00
langhuihui
61fae4cc97 fix: webrtc h265 subscribe 2025-06-11 23:43:22 +08:00
pggiroro
e0752242b2 feat: crontab support record with plan like nvr 2025-06-11 22:18:45 +08:00
pggiroro
23f2ed39a1 fix: gb28181 check from.Address.User when onRegister,delete device from db when device is not register 2025-06-11 22:18:45 +08:00
erroot
0b731e468b 插件数据库不同时,新建DB 对象赋值给插件 2025-06-11 21:43:34 +08:00
langhuihui
4fe1472117 refactor: init plugin faild do not register http handle 2025-06-11 13:57:45 +08:00
langhuihui
a8b3a644c3 feat: record recover 2025-06-10 20:16:39 +08:00
pggiroro
4f0a097dac feat: crontab support plat with streampath in database 2025-06-08 21:01:36 +08:00
pggiroro
4df3de00af fix: gb28181 subscriber and invite sdp 2025-06-08 10:40:17 +08:00
langhuihui
9c16905f28 feat: add evn check to debug plugin 2025-06-07 21:07:28 +08:00
pggiroro
0470f78ed7 fix: register to up platform change cseq when need password, get deviceinfo do not update device name when name is not nil in db,return error when DB is nil in Oninit 2025-06-06 22:45:50 +08:00
pggiroro
7282f1f44d fix: add platform from config.yaml,add example into default/config.yaml 2025-06-06 09:03:58 +08:00
pggiroro
67186cd669 fix: subscribe stream before start mp4 record 2025-06-06 09:03:58 +08:00
pggiroro
09e9761083 feat: Added the association feature between plan and streampath, which has not been tested yet. 2025-06-06 09:03:58 +08:00
langhuihui
4acdc19beb feat: add duration to record 2025-06-05 23:51:33 +08:00
langhuihui
80e19726d4 fix: use safeGet insteadof Call and get
feat: multi buddy support
2025-06-05 20:33:59 +08:00
60 changed files with 6095 additions and 1185 deletions

View File

@@ -98,4 +98,13 @@ jobs:
if: success() && !contains(env.version, 'beta')
run: |
docker tag langhuihui/monibuca:v5 langhuihui/monibuca:${{ env.version }}
docker push langhuihui/monibuca:${{ env.version }}
docker push langhuihui/monibuca:${{ env.version }}
- name: docker build lite version
if: success() && startsWith(github.ref, 'refs/tags/')
run: |
docker buildx build --platform linux/amd64,linux/arm64 -f DockerfileLite -t monibuca/v5:latest --push .
- name: docker lite push version tag
if: success() && !contains(env.version, 'beta')
run: |
docker tag monibuca/v5 monibuca/v5:${{ env.version }}
docker push lmonibuca/v5:${{ env.version }}

31
DockerfileLite Normal file
View File

@@ -0,0 +1,31 @@
# Running Stage
FROM alpine:latest
WORKDIR /monibuca
# Copy the pre-compiled binary from the build context
# The GitHub Actions workflow prepares 'monibuca_linux' in the context root
COPY monibuca_amd64 ./monibuca_amd64
COPY monibuca_arm64 ./monibuca_arm64
COPY admin.zip ./admin.zip
# Copy the configuration file from the build context
COPY example/default/config.yaml /etc/monibuca/config.yaml
# Export necessary ports
EXPOSE 6000 8080 8443 1935 554 5060 9000-20000
EXPOSE 5060/udp 44944/udp
RUN if [ "$(uname -m)" = "aarch64" ]; then \
mv ./monibuca_arm64 ./monibuca_linux; \
rm ./monibuca_amd64; \
else \
mv ./monibuca_amd64 ./monibuca_linux; \
rm ./monibuca_arm64; \
fi
ENTRYPOINT [ "./monibuca_linux"]
CMD ["-c", "/etc/monibuca/config.yaml"]

111
RELEASE_NOTES_5.0.x_CN.md Normal file
View File

@@ -0,0 +1,111 @@
# Monibuca v5.0.x Release Notes
## v5.0.2 (2025-06-05)
### 🎉 新功能 (New Features)
#### 核心功能
- **降低延迟** - 禁用了TCP WebRTC的重放保护功能降低了延迟
- **配置系统增强** - 支持更多配置格式(支持配置项中插入`-``_`和大写字母),提升配置灵活性
- **原始数据检查** - 新增原始数据无帧检查功能,提升数据处理稳定性
- **MP4循环读取** - 支持MP4文件循环读取功能通过配置 pull 配置下的 `loop` 配置)
- **S3插件** - 新增S3存储插件支持云存储集成
- **TCP读写缓冲配置** - 新增TCP连接读写缓冲区配置选项针对高并发下的吞吐能力增强
- **拉流测试模式** - 新增拉流测试模式选项(可以选择拉流时不发布),便于调试和测试
- **SEI API格式扩展** - 扩展SEI API支持更多数据格式
- **Hook扩展** - 新增更多Hook回调点增强扩展性
- **定时任务插件** - 新增crontab定时任务插件
- **服务器抓包** - 新增服务器抓包功能(调用`tcpdump`支持TCP和UDP协议,API 说明见 [tcpdump](https://api.monibuca.com/api-301117332)
#### GB28181协议增强
- **平台配置支持** - GB28181现在支持从config.yaml中添加平台和平台通道配置
- **子码流播放** - 支持GB28181子码流播放功能
- **SDP优化** - 优化invite SDP中的mediaip和sipip处理
- **本地端口保存** - 修复GB28181本地端口保存到数据库的问题
#### MP4功能增强
- **FLV格式下载** - 支持从MP4录制文件下载FLV格式
- **下载功能修复** - 修复MP4下载功能的相关问题
- **恢复功能修复** - 修复MP4恢复功能
### 🐛 问题修复 (Bug Fixes)
#### 网络通信
- **TCP读取阻塞** - 修复TCP读取阻塞问题增加了读取超时设置
- **RTSP内存泄漏** - 修复RTSP协议的内存泄漏问题
- **RTSP音视频标识** - 修复RTSP无音频或视频标识的问题
#### GB28181协议
- **任务管理** - 使用task.Manager解决注册处理器的问题
- **计划长度** - 修复plan.length为168的问题
- **注册频率** - 修复GB28181注册过快导致启动过多任务的问题
- **联系信息** - 修复GB28181获取错误联系信息的问题
#### RTMP协议
- **时间戳处理** - 修复RTMP时间戳开头跳跃问题
### 🛠️ 优化改进 (Improvements)
#### Docker支持
- **tcpdump工具** - Docker镜像中新增tcpdump网络诊断工具
#### Linux平台优化
- **SIP请求优化** - Linux平台移除SIP请求中的viaheader
### 👥 贡献者 (Contributors)
- langhuihui
- pggiroro
- banshan
---
## v5.0.1 (2025-05-21)
### 🎉 新功能 (New Features)
#### WebRTC增强
- **H265支持** - 新增WebRTC对H265编码的支持提升视频质量和压缩效率
#### GB28181协议增强
- **订阅功能扩展** - GB28181模块现在支持订阅报警、移动位置、目录信息
- **通知请求** - 支持接收通知请求,增强与设备的交互能力
#### Docker优化
- **FFmpeg集成** - Docker镜像中新增FFmpeg工具支持更多音视频处理场景
- **多架构支持** - 新增Docker多架构构建支持
### 🐛 问题修复 (Bug Fixes)
#### Docker相关
- **构建问题** - 修复Docker构建过程中的多个问题
- **构建优化** - 优化Docker构建流程提升构建效率
#### RTMP协议
- **时间戳处理** - 修复RTMP第一个chunk类型3需要添加时间戳的问题
#### GB28181协议
- **路径匹配** - 修复GB28181模块中播放流路径的正则表达式匹配问题
#### MP4处理
- **stsz box** - 修复stsz box采样大小的问题
- **G711音频** - 修复拉取MP4文件时读取G711音频的问题
- **H265解析** - 修复H265 MP4文件解析问题
### 🛠️ 优化改进 (Improvements)
#### 代码质量
- **错误处理** - 新增maxcount错误处理机制
- **文档更新** - 更新README文档和go.mod配置
#### 构建系统
- **ARM架构** - 减少JavaScript代码优化ARM架构Docker构建
- **构建标签** - 移除Docker中不必要的构建标签
### 📦 其他更新 (Other Updates)
- **MCP相关** - 更新Model Context Protocol相关功能
- **依赖更新** - 更新项目依赖和模块配置
### 👥 贡献者 (Contributors)
- langhuihui
---

139
api.go
View File

@@ -180,19 +180,17 @@ func (s *Server) getStreamInfo(pub *Publisher) (res *pb.StreamInfoResponse, err
func (s *Server) StreamInfo(ctx context.Context, req *pb.StreamSnapRequest) (res *pb.StreamInfoResponse, err error) {
var recordings []*pb.RecordingDetail
s.Records.Call(func() error {
for record := range s.Records.Range {
if record.StreamPath == req.StreamPath {
recordings = append(recordings, &pb.RecordingDetail{
FilePath: record.RecConf.FilePath,
Mode: record.Mode,
Fragment: durationpb.New(record.RecConf.Fragment),
Append: record.RecConf.Append,
PluginName: record.Plugin.Meta.Name,
})
}
s.Records.SafeRange(func(record *RecordJob) bool {
if record.StreamPath == req.StreamPath {
recordings = append(recordings, &pb.RecordingDetail{
FilePath: record.RecConf.FilePath,
Mode: record.RecConf.Mode,
Fragment: durationpb.New(record.RecConf.Fragment),
Append: record.RecConf.Append,
PluginName: record.Plugin.Meta.Name,
})
}
return nil
return true
})
if pub, ok := s.Streams.SafeGet(req.StreamPath); ok {
res, err = s.getStreamInfo(pub)
@@ -260,17 +258,15 @@ func (s *Server) RestartTask(ctx context.Context, req *pb.RequestWithId64) (resp
}
func (s *Server) GetRecording(ctx context.Context, req *emptypb.Empty) (resp *pb.RecordingListResponse, err error) {
s.Records.Call(func() error {
resp = &pb.RecordingListResponse{}
for record := range s.Records.Range {
resp.Data = append(resp.Data, &pb.Recording{
StreamPath: record.StreamPath,
StartTime: timestamppb.New(record.StartTime),
Type: reflect.TypeOf(record.recorder).String(),
Pointer: uint64(record.GetTaskPointer()),
})
}
return nil
resp = &pb.RecordingListResponse{}
s.Records.SafeRange(func(record *RecordJob) bool {
resp.Data = append(resp.Data, &pb.Recording{
StreamPath: record.StreamPath,
StartTime: timestamppb.New(record.StartTime),
Type: reflect.TypeOf(record.recorder).String(),
Pointer: uint64(record.GetTaskPointer()),
})
return true
})
return
}
@@ -490,7 +486,7 @@ func (s *Server) Shutdown(ctx context.Context, req *pb.RequestWithId) (res *pb.S
func (s *Server) ChangeSubscribe(ctx context.Context, req *pb.ChangeSubscribeRequest) (res *pb.SuccessResponse, err error) {
s.Streams.Call(func() error {
if subscriber, ok := s.Subscribers.Get(req.Id); ok {
if pub, ok := s.Streams.SafeGet(req.StreamPath); ok {
if pub, ok := s.Streams.Get(req.StreamPath); ok {
subscriber.Publisher.RemoveSubscriber(subscriber)
subscriber.StreamPath = req.StreamPath
pub.AddSubscriber(subscriber)
@@ -516,86 +512,65 @@ func (s *Server) StopSubscribe(ctx context.Context, req *pb.RequestWithId) (res
}
func (s *Server) PauseStream(ctx context.Context, req *pb.StreamSnapRequest) (res *pb.SuccessResponse, err error) {
s.Streams.Call(func() error {
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Pause()
}
return nil
})
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Pause()
}
return &pb.SuccessResponse{}, err
}
func (s *Server) ResumeStream(ctx context.Context, req *pb.StreamSnapRequest) (res *pb.SuccessResponse, err error) {
s.Streams.Call(func() error {
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Resume()
}
return nil
})
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Resume()
}
return &pb.SuccessResponse{}, err
}
func (s *Server) SetStreamSpeed(ctx context.Context, req *pb.SetStreamSpeedRequest) (res *pb.SuccessResponse, err error) {
s.Streams.Call(func() error {
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Speed = float64(req.Speed)
s.Scale = float64(req.Speed)
s.Info("set stream speed", "speed", req.Speed)
}
return nil
})
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Speed = float64(req.Speed)
s.Scale = float64(req.Speed)
s.Info("set stream speed", "speed", req.Speed)
}
return &pb.SuccessResponse{}, err
}
func (s *Server) SeekStream(ctx context.Context, req *pb.SeekStreamRequest) (res *pb.SuccessResponse, err error) {
s.Streams.Call(func() error {
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Seek(time.Unix(int64(req.TimeStamp), 0))
}
return nil
})
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Seek(time.Unix(int64(req.TimeStamp), 0))
}
return &pb.SuccessResponse{}, err
}
func (s *Server) StopPublish(ctx context.Context, req *pb.StreamSnapRequest) (res *pb.SuccessResponse, err error) {
s.Streams.Call(func() error {
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Stop(task.ErrStopByUser)
}
return nil
})
if s, ok := s.Streams.SafeGet(req.StreamPath); ok {
s.Stop(task.ErrStopByUser)
}
return &pb.SuccessResponse{}, err
}
// /api/stream/list
func (s *Server) StreamList(_ context.Context, req *pb.StreamListRequest) (res *pb.StreamListResponse, err error) {
recordingMap := make(map[string][]*pb.RecordingDetail)
s.Records.Call(func() error {
for record := range s.Records.Range {
recordingMap[record.StreamPath] = append(recordingMap[record.StreamPath], &pb.RecordingDetail{
FilePath: record.RecConf.FilePath,
Mode: record.Mode,
Fragment: durationpb.New(record.RecConf.Fragment),
Append: record.RecConf.Append,
PluginName: record.Plugin.Meta.Name,
Pointer: uint64(record.GetTaskPointer()),
})
for record := range s.Records.SafeRange {
recordingMap[record.StreamPath] = append(recordingMap[record.StreamPath], &pb.RecordingDetail{
FilePath: record.RecConf.FilePath,
Mode: record.RecConf.Mode,
Fragment: durationpb.New(record.RecConf.Fragment),
Append: record.RecConf.Append,
PluginName: record.Plugin.Meta.Name,
Pointer: uint64(record.GetTaskPointer()),
})
}
var streams []*pb.StreamInfo
for publisher := range s.Streams.SafeRange {
info, err := s.getStreamInfo(publisher)
if err != nil {
continue
}
return nil
})
s.Streams.Call(func() error {
var streams []*pb.StreamInfo
for publisher := range s.Streams.Range {
info, err := s.getStreamInfo(publisher)
if err != nil {
continue
}
info.Data.Recording = recordingMap[info.Data.Path]
streams = append(streams, info.Data)
}
res = &pb.StreamListResponse{Data: streams, Total: int32(s.Streams.Length), PageNum: req.PageNum, PageSize: req.PageSize}
return nil
})
info.Data.Recording = recordingMap[info.Data.Path]
streams = append(streams, info.Data)
}
res = &pb.StreamListResponse{Data: streams, Total: int32(s.Streams.Length), PageNum: req.PageNum, PageSize: req.PageSize}
return
}
@@ -775,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, "*", "%"))

View File

@@ -15,13 +15,33 @@ gb28181:
sip:
listenaddr:
- udp::5060
# pull:
# live/test: dump/34020000001320000001
onsub:
pull:
^\d{20}/\d{20}$: $0
^gb_\d+/(.+)$: $1
# .* : $0
platforms:
- enable: false #是否启用平台
name: "测试平台" #平台名称
servergbid: "34020000002000000002" #上级平台GBID
servergbdomain: "3402000000" #上级平台GB域
serverip: 192.168.1.106 #上级平台IP
serverport: 5061 #上级平台端口
devicegbid: "34020000002000000001" #本平台设备GBID
deviceip: 192.168.1.106 #本平台设备IP
deviceport: 5060 #本平台设备端口
username: "34020000002000000001" #SIP账号
password: "123456" #SIP密码
expires: 3600 #注册有效期,单位秒
keeptimeout: 60 #注册保持超时时间,单位秒
civilCode: "340200" #行政区划代码
manufacturer: "Monibuca" #设备制造商
model: "GB28181" #设备型号
address: "江苏南京" #设备地址
register_way: 1
platformchannels:
- platformservergbid: "34020000002000000002" #上级平台GBID
channeldbid: "34020000001110000003_34020000001320000005" #通道DBID,格式为设备ID_通道ID
mp4:
# enable: false
# publish:

2
go.mod
View File

@@ -53,7 +53,7 @@ require (
google.golang.org/protobuf v1.34.2
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.11
gorm.io/gorm v1.30.0
)
require (

4
go.sum
View File

@@ -423,8 +423,8 @@ gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkD
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=

View File

@@ -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;
}

View File

@@ -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:"转码目标"` // 转码目标

View File

@@ -13,6 +13,7 @@ type (
Port struct {
Protocol string
Ports [2]int
Map [2]int // 映射端口范围,通常用于 NAT 或端口转发
}
IPort interface {
IsTCP() bool
@@ -22,10 +23,23 @@ type (
)
func (p Port) String() string {
var result string
if p.Ports[0] == p.Ports[1] {
return p.Protocol + ":" + strconv.Itoa(p.Ports[0])
result = p.Protocol + ":" + strconv.Itoa(p.Ports[0])
} else {
result = p.Protocol + ":" + strconv.Itoa(p.Ports[0]) + "-" + strconv.Itoa(p.Ports[1])
}
return p.Protocol + ":" + strconv.Itoa(p.Ports[0]) + "-" + strconv.Itoa(p.Ports[1])
// 如果有端口映射,添加映射信息
if p.HasMapping() {
if p.Map[0] == p.Map[1] {
result += ":" + strconv.Itoa(p.Map[0])
} else {
result += ":" + strconv.Itoa(p.Map[0]) + "-" + strconv.Itoa(p.Map[1])
}
}
return result
}
func (p Port) IsTCP() bool {
@@ -40,6 +54,36 @@ func (p Port) IsRange() bool {
return p.Ports[0] != p.Ports[1]
}
func (p Port) HasMapping() bool {
return p.Map[0] > 0 || p.Map[1] > 0
}
func (p Port) IsRangeMapping() bool {
return p.HasMapping() && p.Map[0] != p.Map[1]
}
// ParsePort2 解析端口配置字符串并返回对应的端口类型实例
// 根据协议类型和端口范围返回不同的类型:
// - TCP单端口返回 TCPPort
// - TCP端口范围返回 TCPRangePort
// - UDP单端口返回 UDPPort
// - UDP端口范围返回 UDPRangePort
//
// 参数:
//
// conf - 端口配置字符串格式protocol:port 或 protocol:port1-port2
//
// 返回值:
//
// ret - 端口实例 (TCPPort/UDPPort/TCPRangePort/UDPRangePort)
// err - 解析错误
//
// 示例:
//
// ParsePort2("tcp:8080") // 返回 TCPPort(8080)
// ParsePort2("tcp:8080-8090") // 返回 TCPRangePort([2]int{8080, 8090})
// ParsePort2("udp:5000") // 返回 UDPPort(5000)
// ParsePort2("udp:5000-5010") // 返回 UDPRangePort([2]int{5000, 5010})
func ParsePort2(conf string) (ret any, err error) {
var port Port
port, err = ParsePort(conf)
@@ -58,10 +102,84 @@ func ParsePort2(conf string) (ret any, err error) {
return UDPPort(port.Ports[0]), nil
}
// ParsePort 解析端口配置字符串为 Port 结构体
// 支持协议前缀、端口号/端口范围以及端口映射的解析
//
// 参数:
//
// conf - 端口配置字符串,格式:
// - "protocol:port" 单端口,如 "tcp:8080"
// - "protocol:port1-port2" 端口范围,如 "tcp:8080-8090"
// - "protocol:port:mapPort" 单端口映射,如 "tcp:8080:9090"
// - "protocol:port:mapPort1-mapPort2" 单端口映射到端口范围,如 "tcp:8080:9000-9010"
// - "protocol:port1-port2:mapPort1-mapPort2" 端口范围映射,如 "tcp:8080-8090:9000-9010"
//
// 返回值:
//
// ret - Port 结构体,包含协议、端口和映射端口信息
// err - 解析错误
//
// 注意:
// - 如果端口范围中 min > max会自动交换顺序
// - 单端口时Ports[0] 和 Ports[1] 值相同
// - 端口映射时Map[0] 和 Map[1] 存储映射的目标端口范围
// - 单个映射端口时Map[0] 和 Map[1] 值相同
//
// 示例:
//
// ParsePort("tcp:8080") // Port{Protocol:"tcp", Ports:[2]int{8080, 8080}, Map:[2]int{0, 0}}
// ParsePort("tcp:8080-8090") // Port{Protocol:"tcp", Ports:[2]int{8080, 8090}, Map:[2]int{0, 0}}
// ParsePort("tcp:8080:9090") // Port{Protocol:"tcp", Ports:[2]int{8080, 8080}, Map:[2]int{9090, 9090}}
// ParsePort("tcp:8080:9000-9010") // Port{Protocol:"tcp", Ports:[2]int{8080, 8080}, Map:[2]int{9000, 9010}}
// ParsePort("tcp:8080-8090:9000-9010") // Port{Protocol:"tcp", Ports:[2]int{8080, 8090}, Map:[2]int{9000, 9010}}
// ParsePort("udp:5000") // Port{Protocol:"udp", Ports:[2]int{5000, 5000}, Map:[2]int{0, 0}}
// ParsePort("udp:5010-5000") // Port{Protocol:"udp", Ports:[2]int{5000, 5010}, Map:[2]int{0, 0}}
func ParsePort(conf string) (ret Port, err error) {
var port string
var port, mapPort string
var min, max int
ret.Protocol, port, _ = strings.Cut(conf, ":")
// 按冒号分割,支持端口映射
parts := strings.Split(conf, ":")
if len(parts) < 2 || len(parts) > 3 {
err = strconv.ErrSyntax
return
}
ret.Protocol = parts[0]
port = parts[1]
// 处理端口映射
if len(parts) == 3 {
mapPort = parts[2]
// 解析映射端口,支持单端口和端口范围
if mapRange := strings.Split(mapPort, "-"); len(mapRange) == 2 {
// 映射端口范围
var mapMin, mapMax int
mapMin, err = strconv.Atoi(mapRange[0])
if err != nil {
return
}
mapMax, err = strconv.Atoi(mapRange[1])
if err != nil {
return
}
if mapMin < mapMax {
ret.Map[0], ret.Map[1] = mapMin, mapMax
} else {
ret.Map[0], ret.Map[1] = mapMax, mapMin
}
} else {
// 单个映射端口
var mapPortNum int
mapPortNum, err = strconv.Atoi(mapPort)
if err != nil {
return
}
ret.Map[0], ret.Map[1] = mapPortNum, mapPortNum
}
}
// 处理端口范围
if r := strings.Split(port, "-"); len(r) == 2 {
min, err = strconv.Atoi(r[0])
if err != nil {
@@ -76,7 +194,12 @@ func ParsePort(conf string) (ret Port, err error) {
} else {
ret.Ports[0], ret.Ports[1] = max, min
}
} else if p, err := strconv.Atoi(port); err == nil {
} else {
var p int
p, err = strconv.Atoi(port)
if err != nil {
return
}
ret.Ports[0], ret.Ports[1] = p, p
}
return

370
pkg/port_test.go Normal file
View File

@@ -0,0 +1,370 @@
package pkg
import (
"testing"
)
func TestParsePort(t *testing.T) {
tests := []struct {
name string
input string
expected Port
hasError bool
}{
{
name: "TCP单端口",
input: "tcp:8080",
expected: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8080},
Map: [2]int{0, 0},
},
hasError: false,
},
{
name: "TCP端口范围",
input: "tcp:8080-8090",
expected: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8090},
Map: [2]int{0, 0},
},
hasError: false,
},
{
name: "TCP端口范围反序",
input: "tcp:8090-8080",
expected: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8090},
Map: [2]int{0, 0},
},
hasError: false,
},
{
name: "TCP单端口映射到单端口",
input: "tcp:8080:9090",
expected: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8080},
Map: [2]int{9090, 9090},
},
hasError: false,
},
{
name: "TCP单端口映射到端口范围",
input: "tcp:8080:9000-9010",
expected: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8080},
Map: [2]int{9000, 9010},
},
hasError: false,
},
{
name: "TCP端口范围映射到端口范围",
input: "tcp:8080-8090:9000-9010",
expected: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8090},
Map: [2]int{9000, 9010},
},
hasError: false,
},
{
name: "UDP单端口",
input: "udp:5000",
expected: Port{
Protocol: "udp",
Ports: [2]int{5000, 5000},
Map: [2]int{0, 0},
},
hasError: false,
},
{
name: "UDP端口范围",
input: "udp:5000-5010",
expected: Port{
Protocol: "udp",
Ports: [2]int{5000, 5010},
Map: [2]int{0, 0},
},
hasError: false,
},
{
name: "UDP端口映射",
input: "udp:5000:6000",
expected: Port{
Protocol: "udp",
Ports: [2]int{5000, 5000},
Map: [2]int{6000, 6000},
},
hasError: false,
},
{
name: "UDP端口范围映射映射范围反序",
input: "udp:5000-5010:6010-6000",
expected: Port{
Protocol: "udp",
Ports: [2]int{5000, 5010},
Map: [2]int{6000, 6010},
},
hasError: false,
},
// 错误情况
{
name: "缺少协议",
input: "8080",
expected: Port{},
hasError: true,
},
{
name: "过多冒号",
input: "tcp:8080:9090:extra",
expected: Port{},
hasError: true,
},
{
name: "无效端口号",
input: "tcp:abc",
expected: Port{},
hasError: true,
},
{
name: "无效映射端口号",
input: "tcp:8080:abc",
expected: Port{},
hasError: true,
},
{
name: "无效端口范围",
input: "tcp:8080-abc",
expected: Port{},
hasError: true,
},
{
name: "无效映射端口范围",
input: "tcp:8080:9000-abc",
expected: Port{},
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParsePort(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("期望有错误,但没有错误")
}
return
}
if err != nil {
t.Errorf("意外的错误: %v", err)
return
}
if result.Protocol != tt.expected.Protocol {
t.Errorf("协议不匹配: 期望 %s, 得到 %s", tt.expected.Protocol, result.Protocol)
}
if result.Ports != tt.expected.Ports {
t.Errorf("端口不匹配: 期望 %v, 得到 %v", tt.expected.Ports, result.Ports)
}
if result.Map != tt.expected.Map {
t.Errorf("映射端口不匹配: 期望 %v, 得到 %v", tt.expected.Map, result.Map)
}
})
}
}
func TestPortMethods(t *testing.T) {
tests := []struct {
name string
port Port
expectTCP bool
expectUDP bool
expectRange bool
expectMapping bool
expectRangeMap bool
expectString string
}{
{
name: "TCP单端口",
port: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8080},
Map: [2]int{0, 0},
},
expectTCP: true,
expectUDP: false,
expectRange: false,
expectMapping: false,
expectRangeMap: false,
expectString: "tcp:8080",
},
{
name: "TCP端口范围",
port: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8090},
Map: [2]int{0, 0},
},
expectTCP: true,
expectUDP: false,
expectRange: true,
expectMapping: false,
expectRangeMap: false,
expectString: "tcp:8080-8090",
},
{
name: "TCP单端口映射",
port: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8080},
Map: [2]int{9090, 9090},
},
expectTCP: true,
expectUDP: false,
expectRange: false,
expectMapping: true,
expectRangeMap: false,
expectString: "tcp:8080:9090",
},
{
name: "TCP端口范围映射",
port: Port{
Protocol: "tcp",
Ports: [2]int{8080, 8090},
Map: [2]int{9000, 9010},
},
expectTCP: true,
expectUDP: false,
expectRange: true,
expectMapping: true,
expectRangeMap: true,
expectString: "tcp:8080-8090:9000-9010",
},
{
name: "UDP单端口映射到端口范围",
port: Port{
Protocol: "udp",
Ports: [2]int{5000, 5000},
Map: [2]int{6000, 6010},
},
expectTCP: false,
expectUDP: true,
expectRange: false,
expectMapping: true,
expectRangeMap: true,
expectString: "udp:5000:6000-6010",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.port.IsTCP() != tt.expectTCP {
t.Errorf("IsTCP(): 期望 %v, 得到 %v", tt.expectTCP, tt.port.IsTCP())
}
if tt.port.IsUDP() != tt.expectUDP {
t.Errorf("IsUDP(): 期望 %v, 得到 %v", tt.expectUDP, tt.port.IsUDP())
}
if tt.port.IsRange() != tt.expectRange {
t.Errorf("IsRange(): 期望 %v, 得到 %v", tt.expectRange, tt.port.IsRange())
}
if tt.port.HasMapping() != tt.expectMapping {
t.Errorf("HasMapping(): 期望 %v, 得到 %v", tt.expectMapping, tt.port.HasMapping())
}
if tt.port.IsRangeMapping() != tt.expectRangeMap {
t.Errorf("IsRangeMapping(): 期望 %v, 得到 %v", tt.expectRangeMap, tt.port.IsRangeMapping())
}
if tt.port.String() != tt.expectString {
t.Errorf("String(): 期望 %s, 得到 %s", tt.expectString, tt.port.String())
}
})
}
}
func TestParsePort2(t *testing.T) {
tests := []struct {
name string
input string
expectedType string
hasError bool
}{
{
name: "TCP单端口",
input: "tcp:8080",
expectedType: "TCPPort",
hasError: false,
},
{
name: "TCP端口范围",
input: "tcp:8080-8090",
expectedType: "TCPRangePort",
hasError: false,
},
{
name: "UDP单端口",
input: "udp:5000",
expectedType: "UDPPort",
hasError: false,
},
{
name: "UDP端口范围",
input: "udp:5000-5010",
expectedType: "UDPRangePort",
hasError: false,
},
{
name: "无效输入",
input: "invalid",
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParsePort2(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("期望有错误,但没有错误")
}
return
}
if err != nil {
t.Errorf("意外的错误: %v", err)
return
}
switch tt.expectedType {
case "TCPPort":
if _, ok := result.(TCPPort); !ok {
t.Errorf("期望类型 TCPPort, 得到 %T", result)
}
case "TCPRangePort":
if _, ok := result.(TCPRangePort); !ok {
t.Errorf("期望类型 TCPRangePort, 得到 %T", result)
}
case "UDPPort":
if _, ok := result.(UDPPort); !ok {
t.Errorf("期望类型 UDPPort, 得到 %T", result)
}
case "UDPRangePort":
if _, ok := result.(UDPRangePort); !ok {
t.Errorf("期望类型 UDPRangePort, 得到 %T", result)
}
}
})
}
}

View File

@@ -2,33 +2,55 @@ package util
import (
"errors"
"sync"
"unsafe"
)
type Buddy struct {
size int
longests []int
size int
longests [BuddySize>>(MinPowerOf2-1) - 1]int
memoryPool [BuddySize]byte
poolStart int64
lock sync.Mutex // 保护 longests 数组的并发访问
}
var (
InValidParameterErr = errors.New("buddy: invalid parameter")
NotFoundErr = errors.New("buddy: can't find block")
buddyPool = sync.Pool{
New: func() interface{} {
return NewBuddy()
},
}
)
// GetBuddy 从池中获取一个 Buddy 实例
func GetBuddy() *Buddy {
buddy := buddyPool.Get().(*Buddy)
return buddy
}
// PutBuddy 将 Buddy 实例放回池中
func PutBuddy(b *Buddy) {
buddyPool.Put(b)
}
// NewBuddy creates a buddy instance.
// If the parameter isn't valid, return the nil and error as well
func NewBuddy(size int) *Buddy {
if !isPowerOf2(size) {
size = fixSize(size)
func NewBuddy() *Buddy {
size := BuddySize >> MinPowerOf2
ret := &Buddy{
size: size,
}
nodeCount := 2*size - 1
longests := make([]int, nodeCount)
for nodeSize, i := 2*size, 0; i < nodeCount; i++ {
for nodeSize, i := 2*size, 0; i < len(ret.longests); i++ {
if isPowerOf2(i + 1) {
nodeSize /= 2
}
longests[i] = nodeSize
ret.longests[i] = nodeSize
}
return &Buddy{size, longests}
ret.poolStart = int64(uintptr(unsafe.Pointer(&ret.memoryPool[0])))
return ret
}
// Alloc find a unused block according to the size
@@ -42,6 +64,8 @@ func (b *Buddy) Alloc(size int) (offset int, err error) {
if !isPowerOf2(size) {
size = fixSize(size)
}
b.lock.Lock()
defer b.lock.Unlock()
if size > b.longests[0] {
err = NotFoundErr
return
@@ -70,6 +94,8 @@ func (b *Buddy) Free(offset int) error {
if offset < 0 || offset >= b.size {
return InValidParameterErr
}
b.lock.Lock()
defer b.lock.Unlock()
nodeSize := 1
index := offset + b.size - 1
for ; b.longests[index] != 0; index = parent(index) {

View File

@@ -3,11 +3,9 @@
package util
import (
"container/list"
"fmt"
"io"
"slices"
"sync"
"unsafe"
)
@@ -58,53 +56,59 @@ func (r *RecyclableMemory) Recycle() {
}
}
var (
memoryPool [BuddySize]byte
buddy = NewBuddy(BuddySize >> MinPowerOf2)
lock sync.Mutex
poolStart = int64(uintptr(unsafe.Pointer(&memoryPool[0])))
blockPool = list.New()
//EnableCheckSize bool = false
)
type MemoryAllocator struct {
allocator *Allocator
start int64
memory []byte
Size int
buddy *Buddy
}
// createMemoryAllocator 创建并初始化 MemoryAllocator
func createMemoryAllocator(size int, buddy *Buddy, offset int) *MemoryAllocator {
ret := &MemoryAllocator{
allocator: NewAllocator(size),
buddy: buddy,
Size: size,
memory: buddy.memoryPool[offset : offset+size],
start: buddy.poolStart + int64(offset),
}
ret.allocator.Init(size)
return ret
}
func GetMemoryAllocator(size int) (ret *MemoryAllocator) {
lock.Lock()
offset, err := buddy.Alloc(size >> MinPowerOf2)
if blockPool.Len() > 0 {
ret = blockPool.Remove(blockPool.Front()).(*MemoryAllocator)
} else {
ret = &MemoryAllocator{
allocator: NewAllocator(size),
if size < BuddySize {
requiredSize := size >> MinPowerOf2
// 循环尝试从池中获取可用的 buddy
for {
buddy := GetBuddy()
offset, err := buddy.Alloc(requiredSize)
PutBuddy(buddy)
if err == nil {
// 分配成功,使用这个 buddy
return createMemoryAllocator(size, buddy, offset<<MinPowerOf2)
}
}
}
lock.Unlock()
ret.Size = size
ret.allocator.Init(size)
if err != nil {
ret.memory = make([]byte, size)
ret.start = int64(uintptr(unsafe.Pointer(&ret.memory[0])))
return
// 池中的 buddy 都无法分配或大小不够,使用系统内存
memory := make([]byte, size)
start := int64(uintptr(unsafe.Pointer(&memory[0])))
return &MemoryAllocator{
allocator: NewAllocator(size),
Size: size,
memory: memory,
start: start,
}
offset = offset << MinPowerOf2
ret.memory = memoryPool[offset : offset+size]
ret.start = poolStart + int64(offset)
return
}
func (ma *MemoryAllocator) Recycle() {
ma.allocator.Recycle()
lock.Lock()
blockPool.PushBack(ma)
_ = buddy.Free(int((poolStart - ma.start) >> MinPowerOf2))
if ma.buddy != nil {
_ = ma.buddy.Free(int((ma.buddy.poolStart - ma.start) >> MinPowerOf2))
ma.buddy = nil
}
ma.memory = nil
lock.Unlock()
}
func (ma *MemoryAllocator) Find(size int) (memory []byte) {

View File

@@ -133,24 +133,9 @@ func (plugin *PluginMeta) Init(s *Server, userConfig map[string]any) (p *Plugin)
finalConfig, _ := yaml.Marshal(p.Config.GetMap())
p.Logger.Handler().(*MultiLogHandler).SetLevel(ParseLevel(p.config.LogLevel))
p.Debug("config", "detail", string(finalConfig))
if s.DisableAll {
p.Disabled = true
}
if userConfig["enable"] == false {
p.Disabled = true
} else if userConfig["enable"] == true {
p.Disabled = false
}
if p.Disabled {
if userConfig["enable"] == false || (s.DisableAll && userConfig["enable"] != true) {
p.disable("config")
p.Warn("plugin disabled")
return
} else {
var handlers map[string]http.HandlerFunc
if v, ok := instance.(IRegisterHandler); ok {
handlers = v.RegisterHandler()
}
p.registerHandler(handlers)
}
p.Info("init", "version", plugin.Version)
var err error
@@ -158,7 +143,7 @@ func (plugin *PluginMeta) Init(s *Server, userConfig map[string]any) (p *Plugin)
p.DB = s.DB
} else if p.config.DSN != "" {
if factory, ok := db.Factory[p.config.DBType]; ok {
s.DB, err = gorm.Open(factory(p.config.DSN), &gorm.Config{})
p.DB, err = gorm.Open(factory(p.config.DSN), &gorm.Config{})
if err != nil {
s.Error("failed to connect database", "error", err, "dsn", s.config.DSN, "type", s.config.DBType)
p.disable(fmt.Sprintf("database %v", err))
@@ -171,8 +156,21 @@ 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
}
}
s.AddTask(instance)
if err := s.AddTask(instance).WaitStarted(); err != nil {
p.disable(instance.StopReason().Error())
return
}
var handlers map[string]http.HandlerFunc
if v, ok := instance.(IRegisterHandler); ok {
handlers = v.RegisterHandler()
}
p.registerHandler(handlers)
s.Plugins.Add(p)
return
}
@@ -277,11 +275,19 @@ func (p *Plugin) GetPublicIP(netcardIP string) string {
func (p *Plugin) disable(reason string) {
p.Disabled = true
p.SetDescription("disableReason", reason)
p.Warn("plugin disabled")
p.Server.disabledPlugins = append(p.Server.disabledPlugins, p)
}
func (p *Plugin) Start() (err error) {
s := p.Server
if err = p.listen(); err != nil {
return
}
if err = p.handler.OnInit(); err != nil {
return
}
if p.Meta.ServiceDesc != nil && s.grpcServer != nil {
s.grpcServer.RegisterService(p.Meta.ServiceDesc, p.handler)
if p.Meta.RegisterGRPCHandler != nil {
@@ -293,15 +299,6 @@ func (p *Plugin) Start() (err error) {
}
}
}
s.Plugins.Add(p)
if err = p.listen(); err != nil {
p.disable(fmt.Sprintf("listen %v", err))
return
}
if err = p.handler.OnInit(); err != nil {
p.disable(fmt.Sprintf("init %v", err))
return
}
if p.config.Hook != nil {
if hook, ok := p.config.Hook[config.HookOnServerKeepAlive]; ok && hook.Interval > 0 {
p.AddTask(&ServerKeepAliveTask{plugin: p})

View File

@@ -2,7 +2,10 @@ package plugin_crontab
import (
"context"
"fmt"
"sort"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
cronpb "m7s.live/v5/plugin/crontab/pb"
@@ -17,34 +20,29 @@ func (ct *CrontabPlugin) List(ctx context.Context, req *cronpb.ReqPlanList) (*cr
req.PageSize = 10
}
var total int64
var plans []pkg.RecordPlan
// 从内存中获取所有计划
plans := ct.recordPlans.Items
total := len(plans)
query := ct.DB.Model(&pkg.RecordPlan{})
result := query.Count(&total)
if result.Error != nil {
return &cronpb.PlanResponseList{
Code: 500,
Message: result.Error.Error(),
}, nil
// 计算分页
start := int(req.PageNum-1) * int(req.PageSize)
end := start + int(req.PageSize)
if start >= total {
start = total
}
if end > total {
end = total
}
offset := (req.PageNum - 1) * req.PageSize
result = query.Order("id desc").Offset(int(offset)).Limit(int(req.PageSize)).Find(&plans)
if result.Error != nil {
return &cronpb.PlanResponseList{
Code: 500,
Message: result.Error.Error(),
}, nil
}
// 获取当前页的数据
pagePlans := plans[start:end]
data := make([]*cronpb.Plan, 0, len(plans))
for _, plan := range plans {
data := make([]*cronpb.Plan, 0, len(pagePlans))
for _, plan := range pagePlans {
data = append(data, &cronpb.Plan{
Id: uint32(plan.ID),
Name: plan.Name,
Enable: plan.Enabled,
Enable: plan.Enable,
CreateTime: timestamppb.New(plan.CreatedAt),
UpdateTime: timestamppb.New(plan.UpdatedAt),
Plan: plan.Plan,
@@ -94,9 +92,9 @@ func (ct *CrontabPlugin) Add(ctx context.Context, req *cronpb.Plan) (*cronpb.Res
}
plan := &pkg.RecordPlan{
Name: req.Name,
Plan: req.Plan,
Enabled: req.Enable,
Name: req.Name,
Plan: req.Plan,
Enable: req.Enable,
}
if err := ct.DB.Create(plan).Error; err != nil {
@@ -106,6 +104,9 @@ func (ct *CrontabPlugin) Add(ctx context.Context, req *cronpb.Plan) (*cronpb.Res
}, nil
}
// 添加到内存中
ct.recordPlans.Add(plan)
return &cronpb.Response{
Code: 0,
Message: "success",
@@ -160,10 +161,14 @@ func (ct *CrontabPlugin) Update(ctx context.Context, req *cronpb.Plan) (*cronpb.
}, nil
}
// 处理 enable 状态变更
enableChanged := existingPlan.Enable != req.Enable
// 更新记录
updates := map[string]interface{}{
"name": req.Name,
"plan": req.Plan,
"enabled": req.Enable,
"name": req.Name,
"plan": req.Plan,
"enable": req.Enable,
}
if err := ct.DB.Model(&existingPlan).Updates(updates).Error; err != nil {
@@ -173,6 +178,45 @@ func (ct *CrontabPlugin) Update(ctx context.Context, req *cronpb.Plan) (*cronpb.
}, nil
}
// 更新内存中的记录
existingPlan.Name = req.Name
existingPlan.Plan = req.Plan
existingPlan.Enable = req.Enable
ct.recordPlans.Set(&existingPlan)
// 处理 enable 状态变更后的操作
if enableChanged {
if req.Enable {
// 从 false 变为 true需要创建并启动新的定时任务
var streams []pkg.RecordPlanStream
model := &pkg.RecordPlanStream{PlanID: existingPlan.ID}
if err := ct.DB.Model(model).Where(model).Find(&streams).Error; err != nil {
ct.Error("query record plan streams error: %v", err)
} else {
// 为每个流创建定时任务
for _, stream := range streams {
crontab := &Crontab{
ctp: ct,
RecordPlan: &existingPlan,
RecordPlanStream: &stream,
}
crontab.OnStart(func() {
ct.crontabs.Set(crontab)
})
ct.AddTask(crontab)
}
}
} else {
// 从 true 变为 false需要停止相关的定时任务
ct.crontabs.Range(func(crontab *Crontab) bool {
if crontab.RecordPlan.ID == existingPlan.ID {
crontab.Stop(nil)
}
return true
})
}
}
return &cronpb.Response{
Code: 0,
Message: "success",
@@ -196,6 +240,14 @@ func (ct *CrontabPlugin) Remove(ctx context.Context, req *cronpb.DeleteRequest)
}, nil
}
// 先停止所有相关的定时任务
ct.crontabs.Range(func(crontab *Crontab) bool {
if crontab.RecordPlan.ID == existingPlan.ID {
crontab.Stop(nil)
}
return true
})
// 执行软删除
if err := ct.DB.Delete(&existingPlan).Error; err != nil {
return &cronpb.Response{
@@ -204,8 +256,735 @@ func (ct *CrontabPlugin) Remove(ctx context.Context, req *cronpb.DeleteRequest)
}, nil
}
// 从内存中移除
ct.recordPlans.RemoveByKey(existingPlan.ID)
return &cronpb.Response{
Code: 0,
Message: "success",
}, nil
}
func (ct *CrontabPlugin) ListRecordPlanStreams(ctx context.Context, req *cronpb.ReqPlanStreamList) (*cronpb.RecordPlanStreamResponseList, error) {
if req.PageNum < 1 {
req.PageNum = 1
}
if req.PageSize < 1 {
req.PageSize = 10
}
var total int64
var streams []pkg.RecordPlanStream
model := &pkg.RecordPlanStream{}
// 构建查询条件
query := ct.DB.Model(model).
Scopes(
pkg.ScopeRecordPlanID(uint(req.PlanId)),
pkg.ScopeStreamPathLike(req.StreamPath),
pkg.ScopeOrderByCreatedAtDesc(),
)
result := query.Count(&total)
if result.Error != nil {
return &cronpb.RecordPlanStreamResponseList{
Code: 500,
Message: result.Error.Error(),
}, nil
}
offset := (req.PageNum - 1) * req.PageSize
result = query.Offset(int(offset)).Limit(int(req.PageSize)).Find(&streams)
if result.Error != nil {
return &cronpb.RecordPlanStreamResponseList{
Code: 500,
Message: result.Error.Error(),
}, nil
}
data := make([]*cronpb.PlanStream, 0, len(streams))
for _, stream := range streams {
data = append(data, &cronpb.PlanStream{
PlanId: uint32(stream.PlanID),
StreamPath: stream.StreamPath,
Fragment: stream.Fragment,
FilePath: stream.FilePath,
CreatedAt: timestamppb.New(stream.CreatedAt),
UpdatedAt: timestamppb.New(stream.UpdatedAt),
Enable: stream.Enable,
})
}
return &cronpb.RecordPlanStreamResponseList{
Code: 0,
Message: "success",
TotalCount: uint32(total),
PageNum: req.PageNum,
PageSize: req.PageSize,
Data: data,
}, nil
}
func (ct *CrontabPlugin) AddRecordPlanStream(ctx context.Context, req *cronpb.PlanStream) (*cronpb.Response, error) {
if req.PlanId == 0 {
return &cronpb.Response{
Code: 400,
Message: "record_plan_id is required",
}, nil
}
if strings.TrimSpace(req.StreamPath) == "" {
return &cronpb.Response{
Code: 400,
Message: "stream_path is required",
}, nil
}
// 从内存中获取录制计划
plan, ok := ct.recordPlans.Get(uint(req.PlanId))
if !ok {
return &cronpb.Response{
Code: 404,
Message: "record plan not found",
}, nil
}
// 检查是否已存在相同的记录
var count int64
searchModel := pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
StreamPath: req.StreamPath,
}
if err := ct.DB.Model(&searchModel).Where(&searchModel).Count(&count).Error; err != nil {
return &cronpb.Response{
Code: 500,
Message: err.Error(),
}, nil
}
if count > 0 {
return &cronpb.Response{
Code: 400,
Message: "record already exists",
}, nil
}
stream := &pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
StreamPath: req.StreamPath,
Fragment: req.Fragment,
FilePath: req.FilePath,
Enable: req.Enable,
RecordType: req.RecordType,
}
if err := ct.DB.Create(stream).Error; err != nil {
return &cronpb.Response{
Code: 500,
Message: err.Error(),
}, nil
}
// 如果计划是启用状态,创建并启动定时任务
if plan.Enable {
crontab := &Crontab{
ctp: ct,
RecordPlan: plan,
RecordPlanStream: stream,
}
crontab.OnStart(func() {
ct.crontabs.Set(crontab)
})
ct.AddTask(crontab)
}
return &cronpb.Response{
Code: 0,
Message: "success",
}, nil
}
func (ct *CrontabPlugin) UpdateRecordPlanStream(ctx context.Context, req *cronpb.PlanStream) (*cronpb.Response, error) {
if req.PlanId == 0 {
return &cronpb.Response{
Code: 400,
Message: "record_plan_id is required",
}, nil
}
if strings.TrimSpace(req.StreamPath) == "" {
return &cronpb.Response{
Code: 400,
Message: "stream_path is required",
}, nil
}
// 检查记录是否存在
var existingStream pkg.RecordPlanStream
searchModel := pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
StreamPath: req.StreamPath,
}
if err := ct.DB.Where(&searchModel).First(&existingStream).Error; err != nil {
return &cronpb.Response{
Code: 404,
Message: "record not found",
}, nil
}
// 更新记录
existingStream.Fragment = req.Fragment
existingStream.FilePath = req.FilePath
existingStream.Enable = req.Enable
existingStream.RecordType = req.RecordType
if err := ct.DB.Save(&existingStream).Error; err != nil {
return &cronpb.Response{
Code: 500,
Message: err.Error(),
}, nil
}
// 停止当前流相关的所有任务
ct.crontabs.Range(func(crontab *Crontab) bool {
if crontab.RecordPlanStream.StreamPath == req.StreamPath {
crontab.Stop(nil)
}
return true
})
// 查询所有关联此流的记录
var streams []pkg.RecordPlanStream
if err := ct.DB.Where("stream_path = ?", req.StreamPath).Find(&streams).Error; err != nil {
ct.Error("query record plan streams error: %v", err)
return &cronpb.Response{
Code: 500,
Message: err.Error(),
}, nil
}
// 为每个启用的计划创建新的定时任务
for _, stream := range streams {
// 从内存中获取对应的计划
plan, ok := ct.recordPlans.Get(stream.PlanID)
if !ok {
ct.Error("record plan not found in memory: %d", stream.PlanID)
continue
}
// 如果计划是启用状态,创建并启动定时任务
if plan.Enable && stream.Enable {
crontab := &Crontab{
ctp: ct,
RecordPlan: plan,
RecordPlanStream: &stream,
}
crontab.OnStart(func() {
ct.crontabs.Set(crontab)
})
ct.AddTask(crontab)
}
}
return &cronpb.Response{
Code: 0,
Message: "success",
}, nil
}
func (ct *CrontabPlugin) RemoveRecordPlanStream(ctx context.Context, req *cronpb.DeletePlanStreamRequest) (*cronpb.Response, error) {
if req.PlanId == 0 {
return &cronpb.Response{
Code: 400,
Message: "record_plan_id is required",
}, nil
}
if strings.TrimSpace(req.StreamPath) == "" {
return &cronpb.Response{
Code: 400,
Message: "stream_path is required",
}, nil
}
// 检查记录是否存在
var existingStream pkg.RecordPlanStream
searchModel := pkg.RecordPlanStream{
PlanID: uint(req.PlanId),
StreamPath: req.StreamPath,
}
if err := ct.DB.Where(&searchModel).First(&existingStream).Error; err != nil {
return &cronpb.Response{
Code: 404,
Message: "record not found",
}, nil
}
// 停止所有相关的定时任务
ct.crontabs.Range(func(crontab *Crontab) bool {
if crontab.RecordPlanStream.StreamPath == req.StreamPath && crontab.RecordPlan.ID == uint(req.PlanId) {
crontab.Stop(nil)
}
return true
})
// 执行删除
if err := ct.DB.Delete(&existingStream).Error; err != nil {
return &cronpb.Response{
Code: 500,
Message: err.Error(),
}, nil
}
return &cronpb.Response{
Code: 0,
Message: "success",
}, nil
}
// 获取周几的名称0=周日1=周一,...6=周六)
func getWeekdayName(weekday int) string {
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
return weekdays[weekday]
}
// 获取周几的索引0=周日1=周一,...6=周六)
func getWeekdayIndex(weekdayName string) int {
weekdays := map[string]int{
"周日": 0, "周一": 1, "周二": 2, "周三": 3, "周四": 4, "周五": 5, "周六": 6,
}
return weekdays[weekdayName]
}
// 获取下一个指定周几的日期
func getNextDateForWeekday(now time.Time, targetWeekday int, location *time.Location) time.Time {
nowWeekday := int(now.Weekday())
daysToAdd := 0
if targetWeekday >= nowWeekday {
daysToAdd = targetWeekday - nowWeekday
} else {
daysToAdd = 7 - (nowWeekday - targetWeekday)
}
// 如果是同一天但当前时间已经过了最后的时间段,则推到下一周
if daysToAdd == 0 {
// 这里简化处理直接加7天到下周同一天
daysToAdd = 7
}
return now.AddDate(0, 0, daysToAdd)
}
// 计算计划中的所有时间段
func calculateTimeSlots(plan string, now time.Time, location *time.Location) ([]*cronpb.TimeSlotInfo, error) {
if len(plan) != 168 {
return nil, fmt.Errorf("invalid plan format: length should be 168")
}
var slots []*cronpb.TimeSlotInfo
// 按周几遍历0=周日1=周一,...6=周六)
for weekday := 0; weekday < 7; weekday++ {
dayOffset := weekday * 24
var startHour int = -1
// 遍历这一天的每个小时
for hour := 0; hour <= 24; hour++ {
// 如果到了一天的结尾或者当前小时状态为0
isEndOfDay := hour == 24
isHourOff := !isEndOfDay && plan[dayOffset+hour] == '0'
if isEndOfDay || isHourOff {
// 如果之前有开始的时间段,现在结束了
if startHour != -1 {
// 计算下一个该周几的日期
targetDate := getNextDateForWeekday(now, weekday, location)
// 创建时间段
startTime := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), startHour, 0, 0, 0, location)
endTime := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), hour, 0, 0, 0, location)
// 转换为 UTC 时间
startTs := timestamppb.New(startTime.UTC())
endTs := timestamppb.New(endTime.UTC())
slots = append(slots, &cronpb.TimeSlotInfo{
Start: startTs,
End: endTs,
Weekday: getWeekdayName(weekday),
TimeRange: fmt.Sprintf("%02d:00-%02d:00", startHour, hour),
})
startHour = -1
}
} else if plan[dayOffset+hour] == '1' && startHour == -1 {
// 找到新的开始时间
startHour = hour
}
}
}
// 按时间排序
sort.Slice(slots, func(i, j int) bool {
// 先按周几排序
weekdayI := getWeekdayIndex(slots[i].Weekday)
weekdayJ := getWeekdayIndex(slots[j].Weekday)
if weekdayI != weekdayJ {
return weekdayI < weekdayJ
}
// 同一天按开始时间排序
return slots[i].Start.AsTime().Hour() < slots[j].Start.AsTime().Hour()
})
return slots, nil
}
// 获取下一个时间段
func getNextTimeSlotFromNow(plan string, now time.Time, location *time.Location) (*cronpb.TimeSlotInfo, error) {
if len(plan) != 168 {
return nil, fmt.Errorf("invalid plan format: length should be 168")
}
// 将当前时间转换为本地时间
localNow := now.In(location)
currentWeekday := int(localNow.Weekday())
currentHour := localNow.Hour()
// 检查是否在整点边界附近(前后30秒)
isNearHourBoundary := localNow.Minute() == 59 && localNow.Second() >= 30 || localNow.Minute() == 0 && localNow.Second() <= 30
// 首先检查当前时间是否在某个时间段内
dayOffset := currentWeekday * 24
if currentHour < 24 && plan[dayOffset+currentHour] == '1' {
// 找到当前小时所在的完整时间段
startHour := currentHour
// 向前查找时间段的开始
for h := currentHour - 1; h >= 0; h-- {
if plan[dayOffset+h] == '1' {
startHour = h
} else {
break
}
}
// 向后查找时间段的结束
endHour := currentHour + 1
for h := endHour; h < 24; h++ {
if plan[dayOffset+h] == '1' {
endHour = h + 1
} else {
break
}
}
// 检查是否已经接近当前时间段的结束
isNearEndOfTimeSlot := currentHour == endHour-1 && localNow.Minute() >= 59 && localNow.Second() >= 30
// 如果我们靠近时间段结束且在小时边界附近,我们跳过此时间段,找下一个
if isNearEndOfTimeSlot && isNearHourBoundary {
// 继续查找下一个时间段
} else {
// 创建时间段
startTime := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), startHour, 0, 0, 0, location)
endTime := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), endHour, 0, 0, 0, location)
// 如果当前时间已经接近或超过了结束时间,调整结束时间
if localNow.After(endTime.Add(-30*time.Second)) || localNow.Equal(endTime) {
// 继续查找下一个时间段
} else {
// 返回当前时间段
return &cronpb.TimeSlotInfo{
Start: timestamppb.New(startTime.UTC()),
End: timestamppb.New(endTime.UTC()),
Weekday: getWeekdayName(currentWeekday),
TimeRange: fmt.Sprintf("%02d:00-%02d:00", startHour, endHour),
}, nil
}
}
}
// 查找下一个时间段
// 先查找当天剩余时间
for h := currentHour + 1; h < 24; h++ {
if plan[dayOffset+h] == '1' {
// 找到开始小时
startHour := h
// 查找结束小时
endHour := h + 1
for j := h + 1; j < 24; j++ {
if plan[dayOffset+j] == '1' {
endHour = j + 1
} else {
break
}
}
// 创建时间段
startTime := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), startHour, 0, 0, 0, location)
endTime := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), endHour, 0, 0, 0, location)
return &cronpb.TimeSlotInfo{
Start: timestamppb.New(startTime.UTC()),
End: timestamppb.New(endTime.UTC()),
Weekday: getWeekdayName(currentWeekday),
TimeRange: fmt.Sprintf("%02d:00-%02d:00", startHour, endHour),
}, nil
}
}
// 如果当天没有找到,则查找后续日期
for d := 1; d <= 7; d++ {
nextDay := (currentWeekday + d) % 7
dayOffset := nextDay * 24
for h := 0; h < 24; h++ {
if plan[dayOffset+h] == '1' {
// 找到开始小时
startHour := h
// 查找结束小时
endHour := h + 1
for j := h + 1; j < 24; j++ {
if plan[dayOffset+j] == '1' {
endHour = j + 1
} else {
break
}
}
// 计算日期
nextDate := localNow.AddDate(0, 0, d)
// 创建时间段
startTime := time.Date(nextDate.Year(), nextDate.Month(), nextDate.Day(), startHour, 0, 0, 0, location)
endTime := time.Date(nextDate.Year(), nextDate.Month(), nextDate.Day(), endHour, 0, 0, 0, location)
return &cronpb.TimeSlotInfo{
Start: timestamppb.New(startTime.UTC()),
End: timestamppb.New(endTime.UTC()),
Weekday: getWeekdayName(nextDay),
TimeRange: fmt.Sprintf("%02d:00-%02d:00", startHour, endHour),
}, nil
}
}
}
return nil, nil
}
func (ct *CrontabPlugin) ParsePlanTime(ctx context.Context, req *cronpb.ParsePlanRequest) (*cronpb.ParsePlanResponse, error) {
if len(req.Plan) != 168 {
return &cronpb.ParsePlanResponse{
Code: 400,
Message: "invalid plan format: length should be 168",
}, nil
}
// 检查字符串格式是否正确只包含0和1
for i, c := range req.Plan {
if c != '0' && c != '1' {
return &cronpb.ParsePlanResponse{
Code: 400,
Message: fmt.Sprintf("invalid character at position %d: %c (should be 0 or 1)", i, c),
}, nil
}
}
// 获取所有时间段
slots, err := calculateTimeSlots(req.Plan, time.Now(), time.Local)
if err != nil {
return &cronpb.ParsePlanResponse{
Code: 500,
Message: err.Error(),
}, nil
}
// 获取下一个时间段
nextSlot, err := getNextTimeSlotFromNow(req.Plan, time.Now(), time.Local)
if err != nil {
return &cronpb.ParsePlanResponse{
Code: 500,
Message: err.Error(),
}, nil
}
return &cronpb.ParsePlanResponse{
Code: 0,
Message: "success",
Slots: slots,
NextSlot: nextSlot,
}, nil
}
// 辅助函数:构建任务状态信息
func buildCrontabTaskInfo(crontab *Crontab, now time.Time) *cronpb.CrontabTaskInfo {
// 基础任务信息
taskInfo := &cronpb.CrontabTaskInfo{
PlanId: uint32(crontab.RecordPlan.ID),
PlanName: crontab.RecordPlan.Name,
StreamPath: crontab.StreamPath,
FilePath: crontab.FilePath,
Fragment: crontab.Fragment,
}
// 获取完整计划时间段列表
if crontab.RecordPlan != nil && crontab.RecordPlan.Plan != "" {
planSlots, err := calculateTimeSlots(crontab.RecordPlan.Plan, now, time.Local)
if err == nil && planSlots != nil && len(planSlots) > 0 {
taskInfo.PlanSlots = planSlots
}
}
return taskInfo
}
// GetCrontabStatus 获取当前Crontab任务状态
func (ct *CrontabPlugin) GetCrontabStatus(ctx context.Context, req *cronpb.CrontabStatusRequest) (*cronpb.CrontabStatusResponse, error) {
response := &cronpb.CrontabStatusResponse{
Code: 0,
Message: "success",
RunningTasks: []*cronpb.CrontabTaskInfo{},
NextTasks: []*cronpb.CrontabTaskInfo{},
TotalRunning: 0,
TotalPlanned: 0,
}
// 获取当前正在运行的任务
runningTasks := make([]*cronpb.CrontabTaskInfo, 0)
nextTasks := make([]*cronpb.CrontabTaskInfo, 0)
// 如果只指定了流路径但未找到对应的任务,也返回该流的计划信息
streamPathFound := false
// 遍历所有Crontab任务
ct.crontabs.Range(func(crontab *Crontab) bool {
// 如果指定了stream_path过滤条件且不匹配则跳过
if req.StreamPath != "" && crontab.StreamPath != req.StreamPath {
return true // 继续遍历
}
// 标记已找到指定的流
if req.StreamPath != "" {
streamPathFound = true
}
now := time.Now()
// 构建基本任务信息
taskInfo := buildCrontabTaskInfo(crontab, now)
// 检查是否正在录制
if crontab.recording && crontab.currentSlot != nil {
// 当前正在录制
taskInfo.IsRecording = true
// 设置时间信息
taskInfo.StartTime = timestamppb.New(crontab.currentSlot.Start)
taskInfo.EndTime = timestamppb.New(crontab.currentSlot.End)
// 计算已运行时间和剩余时间
elapsedDuration := now.Sub(crontab.currentSlot.Start)
remainingDuration := crontab.currentSlot.End.Sub(now)
taskInfo.ElapsedSeconds = uint32(elapsedDuration.Seconds())
taskInfo.RemainingSeconds = uint32(remainingDuration.Seconds())
// 设置时间范围和周几
startHour := crontab.currentSlot.Start.Hour()
endHour := crontab.currentSlot.End.Hour()
taskInfo.TimeRange = fmt.Sprintf("%02d:00-%02d:00", startHour, endHour)
taskInfo.Weekday = getWeekdayName(int(crontab.currentSlot.Start.Weekday()))
// 添加到正在运行的任务列表
runningTasks = append(runningTasks, taskInfo)
} else {
// 获取下一个时间段
nextSlot := crontab.getNextTimeSlot()
if nextSlot != nil {
// 设置下一个任务的信息
taskInfo.IsRecording = false
// 设置时间信息
taskInfo.StartTime = timestamppb.New(nextSlot.Start)
taskInfo.EndTime = timestamppb.New(nextSlot.End)
// 计算等待时间
waitingDuration := nextSlot.Start.Sub(now)
taskInfo.RemainingSeconds = uint32(waitingDuration.Seconds())
// 设置时间范围和周几
startHour := nextSlot.Start.Hour()
endHour := nextSlot.End.Hour()
taskInfo.TimeRange = fmt.Sprintf("%02d:00-%02d:00", startHour, endHour)
taskInfo.Weekday = getWeekdayName(int(nextSlot.Start.Weekday()))
// 添加到计划任务列表
nextTasks = append(nextTasks, taskInfo)
}
}
return true // 继续遍历
})
// 如果指定了流路径但未找到对应的任务,查询数据库获取该流的计划信息
if req.StreamPath != "" && !streamPathFound {
// 查询与该流相关的所有计划
var streams []pkg.RecordPlanStream
if err := ct.DB.Where("stream_path = ?", req.StreamPath).Find(&streams).Error; err == nil && len(streams) > 0 {
for _, stream := range streams {
// 获取对应的计划
var plan pkg.RecordPlan
if err := ct.DB.First(&plan, stream.PlanID).Error; err == nil && plan.Enable && stream.Enable {
now := time.Now()
// 构建任务信息
taskInfo := &cronpb.CrontabTaskInfo{
PlanId: uint32(plan.ID),
PlanName: plan.Name,
StreamPath: stream.StreamPath,
FilePath: stream.FilePath,
Fragment: stream.Fragment,
IsRecording: false,
}
// 获取完整计划时间段列表
planSlots, err := calculateTimeSlots(plan.Plan, now, time.Local)
if err == nil && planSlots != nil && len(planSlots) > 0 {
taskInfo.PlanSlots = planSlots
}
// 获取下一个时间段
nextSlot, err := getNextTimeSlotFromNow(plan.Plan, now, time.Local)
if err == nil && nextSlot != nil {
// 设置时间信息
taskInfo.StartTime = nextSlot.Start
taskInfo.EndTime = nextSlot.End
taskInfo.TimeRange = nextSlot.TimeRange
taskInfo.Weekday = nextSlot.Weekday
// 计算等待时间
waitingDuration := nextSlot.Start.AsTime().Sub(now)
taskInfo.RemainingSeconds = uint32(waitingDuration.Seconds())
// 添加到计划任务列表
nextTasks = append(nextTasks, taskInfo)
}
}
}
}
}
// 按开始时间排序下一个任务列表
sort.Slice(nextTasks, func(i, j int) bool {
return nextTasks[i].StartTime.AsTime().Before(nextTasks[j].StartTime.AsTime())
})
// 设置响应结果
response.RunningTasks = runningTasks
response.NextTasks = nextTasks
response.TotalRunning = uint32(len(runningTasks))
response.TotalPlanned = uint32(len(nextTasks))
return response, nil
}

244
plugin/crontab/api_test.go Normal file
View File

@@ -0,0 +1,244 @@
package plugin_crontab
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCalculateTimeSlots(t *testing.T) {
// 测试案例:周五的凌晨和上午有开启时间段
// 字符串中1的索引是120(0点),122(2点),123(3点),125(5点),130(10点),135(15点)
// 000000000000000000000000 - 周日(0-23小时) - 全0
// 000000000000000000000000 - 周一(24-47小时) - 全0
// 000000000000000000000000 - 周二(48-71小时) - 全0
// 000000000000000000000000 - 周三(72-95小时) - 全0
// 000000000000000000000000 - 周四(96-119小时) - 全0
// 101101000010000100000000 - 周五(120-143小时) - 0,2,3,5,10,15点开启
// 000000000000000000000000 - 周六(144-167小时) - 全0
planStr := "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101101000010000100000000000000000000000000000000"
now := time.Date(2023, 5, 1, 12, 0, 0, 0, time.Local) // 周一中午
slots, err := calculateTimeSlots(planStr, now, time.Local)
assert.NoError(t, err)
assert.Equal(t, 5, len(slots), "应该有5个时间段")
// 检查结果中的时间段(按实际解析结果排序)
assert.Equal(t, "周五", slots[0].Weekday)
assert.Equal(t, "10:00-11:00", slots[0].TimeRange)
assert.Equal(t, "周五", slots[1].Weekday)
assert.Equal(t, "15:00-16:00", slots[1].TimeRange)
assert.Equal(t, "周五", slots[2].Weekday)
assert.Equal(t, "00:00-01:00", slots[2].TimeRange)
assert.Equal(t, "周五", slots[3].Weekday)
assert.Equal(t, "02:00-04:00", slots[3].TimeRange)
assert.Equal(t, "周五", slots[4].Weekday)
assert.Equal(t, "05:00-06:00", slots[4].TimeRange)
// 打印出所有时间段,便于调试
for i, slot := range slots {
t.Logf("时间段 %d: %s %s", i, slot.Weekday, slot.TimeRange)
}
}
func TestGetNextTimeSlotFromNow(t *testing.T) {
// 测试案例:周五的凌晨和上午有开启时间段
planStr := "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101101000010000100000000000000000000000000000000"
// 测试1: 当前是周一下一个时间段应该是周五凌晨0点
now1 := time.Date(2023, 5, 1, 12, 0, 0, 0, time.Local) // 周一中午
nextSlot1, err := getNextTimeSlotFromNow(planStr, now1, time.Local)
assert.NoError(t, err)
assert.NotNil(t, nextSlot1)
assert.Equal(t, "周五", nextSlot1.Weekday)
assert.Equal(t, "00:00-01:00", nextSlot1.TimeRange)
// 测试2: 当前是周五凌晨1点下一个时间段应该是周五凌晨2点
now2 := time.Date(2023, 5, 5, 1, 30, 0, 0, time.Local) // 周五凌晨1:30
nextSlot2, err := getNextTimeSlotFromNow(planStr, now2, time.Local)
assert.NoError(t, err)
assert.NotNil(t, nextSlot2)
assert.Equal(t, "周五", nextSlot2.Weekday)
assert.Equal(t, "02:00-04:00", nextSlot2.TimeRange)
// 测试3: 当前是周五凌晨3点此时正在一个时间段内
now3 := time.Date(2023, 5, 5, 3, 0, 0, 0, time.Local) // 周五凌晨3:00
nextSlot3, err := getNextTimeSlotFromNow(planStr, now3, time.Local)
assert.NoError(t, err)
assert.NotNil(t, nextSlot3)
assert.Equal(t, "周五", nextSlot3.Weekday)
assert.Equal(t, "02:00-04:00", nextSlot3.TimeRange)
}
func TestParsePlanFromString(t *testing.T) {
// 测试用户提供的案例字符串的第36-41位表示周一的时间段
// 这个案例中对应周一的12点、14-15点、17点和22点开启
planStr := "000000000000000000000000000000000000101101000010000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
now := time.Now()
slots, err := calculateTimeSlots(planStr, now, time.Local)
assert.NoError(t, err)
// 验证解析结果
var foundMondaySlots bool
for _, slot := range slots {
if slot.Weekday == "周一" {
foundMondaySlots = true
t.Logf("找到周一时间段: %s", slot.TimeRange)
}
}
assert.True(t, foundMondaySlots, "应该找到周一的时间段")
// 预期的周一时间段
var mondaySlots []string
for _, slot := range slots {
if slot.Weekday == "周一" {
mondaySlots = append(mondaySlots, slot.TimeRange)
}
}
// 检查是否包含预期的时间段
expectedSlots := []string{
"12:00-13:00",
"14:00-16:00",
"17:00-18:00",
"22:00-23:00",
}
for _, expected := range expectedSlots {
found := false
for _, actual := range mondaySlots {
if expected == actual {
found = true
break
}
}
assert.True(t, found, "应该找到周一时间段:"+expected)
}
// 获取下一个时间段
nextSlot, err := getNextTimeSlotFromNow(planStr, now, time.Local)
assert.NoError(t, err)
if nextSlot != nil {
t.Logf("下一个时间段: %s %s", nextSlot.Weekday, nextSlot.TimeRange)
} else {
t.Log("没有找到下一个时间段")
}
}
// 手动计算字符串长度的辅助函数
func TestCountStringLength(t *testing.T) {
str1 := "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101101000010000100000000000000000000000000000000"
assert.Equal(t, 168, len(str1), "第一个测试字符串长度应为168")
str2 := "000000000000000000000000000000000000101101000010000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
assert.Equal(t, 168, len(str2), "第二个测试字符串长度应为168")
}
// 测试用户提供的具体字符串
func TestUserProvidedPlanString(t *testing.T) {
// 用户提供的测试字符串
planStr := "000000000000000000000000000000000000101101000010000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
// 验证字符串长度
assert.Equal(t, 168, len(planStr), "字符串长度应为168")
// 解析时间段
now := time.Now()
slots, err := calculateTimeSlots(planStr, now, time.Local)
assert.NoError(t, err)
// 打印所有时间段
t.Log("所有时间段:")
for i, slot := range slots {
t.Logf("%d: %s %s", i, slot.Weekday, slot.TimeRange)
}
// 获取下一个时间段
nextSlot, err := getNextTimeSlotFromNow(planStr, now, time.Local)
assert.NoError(t, err)
if nextSlot != nil {
t.Logf("下一个执行时间段: %s %s", nextSlot.Weekday, nextSlot.TimeRange)
t.Logf("开始时间: %s", nextSlot.Start.AsTime().In(time.Local).Format("2006-01-02 15:04:05"))
t.Logf("结束时间: %s", nextSlot.End.AsTime().In(time.Local).Format("2006-01-02 15:04:05"))
} else {
t.Log("没有找到下一个时间段")
}
// 验证周一的时间段
var mondaySlots []string
for _, slot := range slots {
if slot.Weekday == "周一" {
mondaySlots = append(mondaySlots, slot.TimeRange)
}
}
// 预期周一应该有这些时间段
expectedMondaySlots := []string{
"12:00-13:00",
"14:00-16:00",
"17:00-18:00",
"22:00-23:00",
}
assert.Equal(t, len(expectedMondaySlots), len(mondaySlots), "周一时间段数量不匹配")
for i, expected := range expectedMondaySlots {
if i < len(mondaySlots) {
t.Logf("期望周一时间段 %s, 实际是 %s", expected, mondaySlots[i])
}
}
}
// 测试用户提供的第二个字符串
func TestUserProvidedPlanString2(t *testing.T) {
// 用户提供的第二个测试字符串
planStr := "000000000000000000000000000000000000000000000000000000000000001011010100001000000000000000000000000100000000000000000000000010000000000000000000000001000000000000000000"
// 验证字符串长度
assert.Equal(t, 168, len(planStr), "字符串长度应为168")
// 解析时间段
now := time.Now()
slots, err := calculateTimeSlots(planStr, now, time.Local)
assert.NoError(t, err)
// 打印所有时间段并按周几分组
weekdaySlots := make(map[string][]string)
for _, slot := range slots {
weekdaySlots[slot.Weekday] = append(weekdaySlots[slot.Weekday], slot.TimeRange)
}
t.Log("所有时间段(按周几分组):")
weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
for _, weekday := range weekdays {
if timeRanges, ok := weekdaySlots[weekday]; ok {
t.Logf("%s: %v", weekday, timeRanges)
}
}
// 打印所有时间段的详细信息
t.Log("\n所有时间段详细信息:")
for i, slot := range slots {
t.Logf("%d: %s %s", i, slot.Weekday, slot.TimeRange)
}
// 获取下一个时间段
nextSlot, err := getNextTimeSlotFromNow(planStr, now, time.Local)
assert.NoError(t, err)
if nextSlot != nil {
t.Logf("\n下一个执行时间段: %s %s", nextSlot.Weekday, nextSlot.TimeRange)
t.Logf("开始时间: %s", nextSlot.Start.AsTime().In(time.Local).Format("2006-01-02 15:04:05"))
t.Logf("结束时间: %s", nextSlot.End.AsTime().In(time.Local).Format("2006-01-02 15:04:05"))
} else {
t.Log("没有找到下一个时间段")
}
}

View File

@@ -1,59 +1,422 @@
package plugin_crontab
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"m7s.live/v5/pkg/task"
"m7s.live/v5/plugin/crontab/pkg"
)
// 计划时间段
type TimeSlot struct {
Start time.Time // 开始时间
End time.Time // 结束时间
}
// Crontab 定时任务调度器
type Crontab struct {
task.TickTask
task.Job
ctp *CrontabPlugin
*pkg.RecordPlan
*pkg.RecordPlanStream
stop chan struct{}
running bool
location *time.Location
timer *time.Timer
currentSlot *TimeSlot // 当前执行的时间段
recording bool // 是否正在录制
}
func (r *Crontab) GetTickInterval() time.Duration {
return time.Minute
func (cron *Crontab) GetKey() string {
return strconv.Itoa(int(cron.PlanID)) + "_" + cron.StreamPath
}
func (r *Crontab) Tick(any) {
r.Info("开始检查录制计划")
// 获取当前时间
now := time.Now()
// 计算当前是一周中的第几天(0-6, 0是周日)和当前小时(0-23)
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7 // 将周日从0改为7以便计算
}
hour := now.Hour()
// 计算当前时间对应的位置索引
// (weekday-1)*24 + hour 得到当前时间在144位字符串中的位置
// weekday-1 是因为我们要从周一开始计算
index := (weekday-1)*24 + hour
// 查询所有启用的录制计划
var plans []pkg.RecordPlan
model := pkg.RecordPlan{
Enabled: true,
}
if err := r.ctp.DB.Where(&model).Find(&plans).Error; err != nil {
r.Error("查询录制计划失败:", err)
return
// 初始化
func (cron *Crontab) Start() (err error) {
cron.Info("crontab plugin start")
if cron.running {
return // 已经运行中,不重复启动
}
// 遍历所有计划
for _, plan := range plans {
if len(plan.Plan) != 168 {
r.Error("录制计划格式错误plan长度应为168位:", plan.Name)
// 初始化必要字段
if cron.stop == nil {
cron.stop = make(chan struct{})
}
if cron.location == nil {
cron.location = time.Local
}
cron.running = true
return nil
}
// 阻塞运行
func (cron *Crontab) Run() (err error) {
cron.Info("crontab plugin is running")
// 初始化必要字段
if cron.stop == nil {
cron.stop = make(chan struct{})
}
if cron.location == nil {
cron.location = time.Local
}
cron.Info("调度器启动")
for {
// 获取当前时间
now := time.Now().In(cron.location)
// 首先检查是否需要立即执行操作(如停止录制)
if cron.recording && cron.currentSlot != nil &&
(now.Equal(cron.currentSlot.End) || now.After(cron.currentSlot.End)) {
cron.stopRecording()
continue
}
// 检查当前时间对应的位置是否为1
if plan.Plan[index] == '1' {
r.Info("检测到需要开启录像的计划:", plan.Name)
// TODO: 在这里添加开启录像的逻辑
// 确定下一个事件
var nextEvent time.Time
var isStartEvent bool
if cron.recording {
// 如果正在录制,下一个事件是结束时间
nextEvent = cron.currentSlot.End
isStartEvent = false
} else {
// 如果没有录制,计算下一个开始时间
nextSlot := cron.getNextTimeSlot()
if nextSlot == nil {
// 无法确定下次执行时间,使用默认间隔
cron.timer = time.NewTimer(1 * time.Hour)
cron.Info("无有效计划等待1小时后重试")
// 等待定时器或停止信号
select {
case <-cron.timer.C:
continue // 继续循环
case <-cron.stop:
// 停止调度器
if cron.timer != nil {
cron.timer.Stop()
}
cron.Info("调度器停止")
return
}
}
cron.currentSlot = nextSlot
nextEvent = nextSlot.Start
isStartEvent = true
// 如果已过开始时间,立即开始录制
if now.Equal(nextEvent) || now.After(nextEvent) {
cron.startRecording()
continue
}
}
// 计算等待时间
waitDuration := nextEvent.Sub(now)
// 如果等待时间为负,立即执行
if waitDuration <= 0 {
if isStartEvent {
cron.startRecording()
} else {
cron.stopRecording()
}
continue
}
// 设置定时器
timer := time.NewTimer(waitDuration)
if isStartEvent {
cron.Info("下次开始时间: ", nextEvent, "等待时间:", waitDuration)
} else {
cron.Info("下次结束时间: ", nextEvent, " 等待时间:", waitDuration)
}
// 等待定时器或停止信号
select {
case now = <-timer.C:
// 更新当前时间为定时器触发时间
now = now.In(cron.location)
// 执行任务
if isStartEvent {
cron.startRecording()
} else {
cron.stopRecording()
}
case <-cron.stop:
// 停止调度器
timer.Stop()
cron.Info("调度器停止")
return
}
}
}
// 停止
func (cron *Crontab) Dispose() (err error) {
if cron.running {
cron.stop <- struct{}{}
cron.running = false
if cron.timer != nil {
cron.timer.Stop()
}
// 如果还在录制,停止录制
if cron.recording {
cron.stopRecording()
}
}
return
}
// 获取下一个时间段
func (cron *Crontab) getNextTimeSlot() *TimeSlot {
if cron.RecordPlan == nil || !cron.RecordPlan.Enable || cron.RecordPlan.Plan == "" {
return nil // 无有效计划
}
plan := cron.RecordPlan.Plan
if len(plan) != 168 {
cron.Error("无效的计划格式: %s, 长度应为168", plan)
return nil
}
// 使用当地时间
now := time.Now().In(cron.location)
cron.Debug("当前本地时间: %v, 星期%d, 小时%d", now.Format("2006-01-02 15:04:05"), now.Weekday(), now.Hour())
// 当前小时
currentWeekday := int(now.Weekday())
currentHour := now.Hour()
// 检查是否在整点边界附近(前后30秒)
isNearHourBoundary := now.Minute() == 59 && now.Second() >= 30 || now.Minute() == 0 && now.Second() <= 30
// 首先检查当前时间是否在某个时间段内
dayOffset := currentWeekday * 24
if currentHour < 24 && plan[dayOffset+currentHour] == '1' {
// 找到当前小时所在的完整时间段
startHour := currentHour
// 向前查找时间段的开始
for h := currentHour - 1; h >= 0; h-- {
if plan[dayOffset+h] == '1' {
startHour = h
} else {
break
}
}
// 向后查找时间段的结束
endHour := currentHour + 1
for h := endHour; h < 24; h++ {
if plan[dayOffset+h] == '1' {
endHour = h + 1
} else {
break
}
}
// 检查我们是否已经接近当前时间段的结束
isNearEndOfTimeSlot := currentHour == endHour-1 && now.Minute() == 59 && now.Second() >= 30
// 如果我们靠近时间段结束且在小时边界附近,我们跳过此时间段,找下一个
if isNearEndOfTimeSlot && isNearHourBoundary {
cron.Debug("接近当前时间段结束,准备查找下一个时间段")
} else {
// 创建时间段
startTime := time.Date(now.Year(), now.Month(), now.Day(), startHour, 0, 0, 0, cron.location)
endTime := time.Date(now.Year(), now.Month(), now.Day(), endHour, 0, 0, 0, cron.location)
// 如果当前时间已经接近或超过了结束时间,调整结束时间
if now.After(endTime.Add(-30*time.Second)) || now.Equal(endTime) {
cron.Debug("当前时间已接近或超过结束时间,尝试查找下一个时间段")
} else {
cron.Debug("当前已在有效时间段内: 开始=%v, 结束=%v",
startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"))
return &TimeSlot{
Start: startTime,
End: endTime,
}
}
}
}
// 查找下一个时间段
// 先查找当天剩余时间
for h := currentHour + 1; h < 24; h++ {
if plan[dayOffset+h] == '1' {
// 找到开始小时
startHour := h
// 查找结束小时
endHour := h + 1
for j := h + 1; j < 24; j++ {
if plan[dayOffset+j] == '1' {
endHour = j + 1
} else {
break
}
}
// 创建时间段
startTime := time.Date(now.Year(), now.Month(), now.Day(), startHour, 0, 0, 0, cron.location)
endTime := time.Date(now.Year(), now.Month(), now.Day(), endHour, 0, 0, 0, cron.location)
cron.Debug("找到今天的有效时间段: 开始=%v, 结束=%v",
startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"))
return &TimeSlot{
Start: startTime,
End: endTime,
}
}
}
// 如果当天没有找到,则查找后续日期
for d := 1; d <= 7; d++ {
nextDay := (currentWeekday + d) % 7
dayOffset := nextDay * 24
for h := 0; h < 24; h++ {
if plan[dayOffset+h] == '1' {
// 找到开始小时
startHour := h
// 查找结束小时
endHour := h + 1
for j := h + 1; j < 24; j++ {
if plan[dayOffset+j] == '1' {
endHour = j + 1
} else {
break
}
}
// 计算日期
nextDate := now.AddDate(0, 0, d)
// 创建时间段
startTime := time.Date(nextDate.Year(), nextDate.Month(), nextDate.Day(), startHour, 0, 0, 0, cron.location)
endTime := time.Date(nextDate.Year(), nextDate.Month(), nextDate.Day(), endHour, 0, 0, 0, cron.location)
cron.Debug("找到未来有效时间段: 开始=%v, 结束=%v",
startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"))
return &TimeSlot{
Start: startTime,
End: endTime,
}
}
}
}
cron.Debug("未找到有效的时间段")
return nil
}
// 开始录制
func (cron *Crontab) startRecording() {
if cron.recording {
return // 已经在录制了
}
now := time.Now().In(cron.location)
cron.Info("开始录制任务: %s, 时间: %v, 计划结束时间: %v",
cron.RecordPlan.Name, now, cron.currentSlot.End)
// 构造请求体
reqBody := map[string]string{
"fragment": cron.Fragment,
"filePath": cron.FilePath,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
cron.Error("构造请求体失败: %v", err)
return
}
// 获取 HTTP 地址
addr := cron.ctp.Plugin.GetCommonConf().HTTP.ListenAddr
if addr == "" {
addr = ":8080" // 使用默认端口
}
if addr[0] == ':' {
addr = "localhost" + addr
}
// 发送开始录制请求
resp, err := http.Post(fmt.Sprintf("http://%s/mp4/api/start/%s", addr, cron.StreamPath), "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
cron.Error("开始录制失败: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cron.Error("开始录制失败HTTP状态码: %d", resp.StatusCode)
return
}
cron.recording = true
}
// 停止录制
func (cron *Crontab) stopRecording() {
if !cron.recording {
return // 没有在录制
}
// 立即记录当前时间并重置状态,避免重复调用
now := time.Now().In(cron.location)
cron.Info("停止录制任务: %s, 时间: %v", cron.RecordPlan.Name, now)
// 先重置状态,避免循环中重复检测到停止条件
wasRecording := cron.recording
cron.recording = false
savedSlot := cron.currentSlot
cron.currentSlot = nil
// 获取 HTTP 地址
addr := cron.ctp.Plugin.GetCommonConf().HTTP.ListenAddr
if addr == "" {
addr = ":8080" // 使用默认端口
}
if addr[0] == ':' {
addr = "localhost" + addr
}
// 发送停止录制请求
resp, err := http.Post(fmt.Sprintf("http://%s/mp4/api/stop/%s", addr, cron.StreamPath), "application/json", nil)
if err != nil {
cron.Error("停止录制失败: %v", err)
// 如果请求失败,恢复状态以便下次重试
if wasRecording {
cron.recording = true
cron.currentSlot = savedSlot
}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cron.Error("停止录制失败HTTP状态码: %d", resp.StatusCode)
// 如果请求失败,恢复状态以便下次重试
if wasRecording {
cron.recording = true
cron.currentSlot = savedSlot
}
}
}

View File

@@ -3,6 +3,8 @@ package plugin_crontab
import (
"fmt"
"m7s.live/v5/pkg/util"
"m7s.live/v5"
"m7s.live/v5/plugin/crontab/pb"
"m7s.live/v5/plugin/crontab/pkg"
@@ -11,6 +13,8 @@ import (
type CrontabPlugin struct {
m7s.Plugin
pb.UnimplementedApiServer
crontabs util.Collection[string, *Crontab]
recordPlans util.Collection[uint, *pkg.RecordPlan]
}
var _ = m7s.InstallPlugin[CrontabPlugin](m7s.PluginMeta{
@@ -22,14 +26,46 @@ func (ct *CrontabPlugin) OnInit() (err error) {
if ct.DB == nil {
ct.Error("DB is nil")
} else {
err = ct.DB.AutoMigrate(&pkg.RecordPlan{})
err = ct.DB.AutoMigrate(&pkg.RecordPlan{}, &pkg.RecordPlanStream{})
if err != nil {
return fmt.Errorf("auto migrate tables error: %v", err)
}
ct.Info("init database success")
// 查询所有录制计划
var plans []pkg.RecordPlan
if err = ct.DB.Find(&plans).Error; err != nil {
return fmt.Errorf("query record plans error: %v", err)
}
// 遍历所有计划
for _, plan := range plans {
// 将计划存入 recordPlans 集合
ct.recordPlans.Add(&plan)
// 如果计划已启用,查询对应的流信息并创建定时任务
if plan.Enable {
var streams []pkg.RecordPlanStream
model := &pkg.RecordPlanStream{PlanID: plan.ID}
if err = ct.DB.Model(model).Where(model).Find(&streams).Error; err != nil {
ct.Error("query record plan streams error: %v", err)
continue
}
// 为每个流创建定时任务
for _, stream := range streams {
crontab := &Crontab{
ctp: ct,
RecordPlan: &plan,
RecordPlanStream: &stream,
}
crontab.OnStart(func() {
ct.crontabs.Set(crontab)
})
ct.AddTask(crontab)
}
}
}
}
crontab := &Crontab{ctp: ct}
ct.AddTask(crontab)
crontab.Tick(nil)
return
}

View File

@@ -339,6 +339,767 @@ func (x *Response) GetMessage() string {
return ""
}
// RecordPlanStream 相关消息定义
type PlanStream struct {
state protoimpl.MessageState `protogen:"open.v1"`
PlanId uint32 `protobuf:"varint,1,opt,name=planId,proto3" json:"planId,omitempty"`
StreamPath string `protobuf:"bytes,2,opt,name=stream_path,json=streamPath,proto3" json:"stream_path,omitempty"`
Fragment string `protobuf:"bytes,3,opt,name=fragment,proto3" json:"fragment,omitempty"`
FilePath string `protobuf:"bytes,4,opt,name=filePath,proto3" json:"filePath,omitempty"`
RecordType string `protobuf:"bytes,5,opt,name=record_type,json=recordType,proto3" json:"record_type,omitempty"` // 录制类型,例如 "mp4", "flv"
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
Enable bool `protobuf:"varint,8,opt,name=enable,proto3" json:"enable,omitempty"` // 是否启用该录制流
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PlanStream) Reset() {
*x = PlanStream{}
mi := &file_crontab_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PlanStream) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PlanStream) ProtoMessage() {}
func (x *PlanStream) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PlanStream.ProtoReflect.Descriptor instead.
func (*PlanStream) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{5}
}
func (x *PlanStream) GetPlanId() uint32 {
if x != nil {
return x.PlanId
}
return 0
}
func (x *PlanStream) GetStreamPath() string {
if x != nil {
return x.StreamPath
}
return ""
}
func (x *PlanStream) GetFragment() string {
if x != nil {
return x.Fragment
}
return ""
}
func (x *PlanStream) GetFilePath() string {
if x != nil {
return x.FilePath
}
return ""
}
func (x *PlanStream) GetRecordType() string {
if x != nil {
return x.RecordType
}
return ""
}
func (x *PlanStream) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *PlanStream) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
func (x *PlanStream) GetEnable() bool {
if x != nil {
return x.Enable
}
return false
}
type ReqPlanStreamList struct {
state protoimpl.MessageState `protogen:"open.v1"`
PageNum uint32 `protobuf:"varint,1,opt,name=pageNum,proto3" json:"pageNum,omitempty"`
PageSize uint32 `protobuf:"varint,2,opt,name=pageSize,proto3" json:"pageSize,omitempty"`
PlanId uint32 `protobuf:"varint,3,opt,name=planId,proto3" json:"planId,omitempty"` // 可选的按录制计划ID筛选
StreamPath string `protobuf:"bytes,4,opt,name=stream_path,json=streamPath,proto3" json:"stream_path,omitempty"` // 可选的按流路径筛选
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ReqPlanStreamList) Reset() {
*x = ReqPlanStreamList{}
mi := &file_crontab_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ReqPlanStreamList) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ReqPlanStreamList) ProtoMessage() {}
func (x *ReqPlanStreamList) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ReqPlanStreamList.ProtoReflect.Descriptor instead.
func (*ReqPlanStreamList) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{6}
}
func (x *ReqPlanStreamList) GetPageNum() uint32 {
if x != nil {
return x.PageNum
}
return 0
}
func (x *ReqPlanStreamList) GetPageSize() uint32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *ReqPlanStreamList) GetPlanId() uint32 {
if x != nil {
return x.PlanId
}
return 0
}
func (x *ReqPlanStreamList) GetStreamPath() string {
if x != nil {
return x.StreamPath
}
return ""
}
type RecordPlanStreamResponseList struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
TotalCount uint32 `protobuf:"varint,3,opt,name=totalCount,proto3" json:"totalCount,omitempty"`
PageNum uint32 `protobuf:"varint,4,opt,name=pageNum,proto3" json:"pageNum,omitempty"`
PageSize uint32 `protobuf:"varint,5,opt,name=pageSize,proto3" json:"pageSize,omitempty"`
Data []*PlanStream `protobuf:"bytes,6,rep,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RecordPlanStreamResponseList) Reset() {
*x = RecordPlanStreamResponseList{}
mi := &file_crontab_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordPlanStreamResponseList) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RecordPlanStreamResponseList) ProtoMessage() {}
func (x *RecordPlanStreamResponseList) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RecordPlanStreamResponseList.ProtoReflect.Descriptor instead.
func (*RecordPlanStreamResponseList) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{7}
}
func (x *RecordPlanStreamResponseList) GetCode() int32 {
if x != nil {
return x.Code
}
return 0
}
func (x *RecordPlanStreamResponseList) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *RecordPlanStreamResponseList) GetTotalCount() uint32 {
if x != nil {
return x.TotalCount
}
return 0
}
func (x *RecordPlanStreamResponseList) GetPageNum() uint32 {
if x != nil {
return x.PageNum
}
return 0
}
func (x *RecordPlanStreamResponseList) GetPageSize() uint32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *RecordPlanStreamResponseList) GetData() []*PlanStream {
if x != nil {
return x.Data
}
return nil
}
type DeletePlanStreamRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
PlanId uint32 `protobuf:"varint,1,opt,name=planId,proto3" json:"planId,omitempty"`
StreamPath string `protobuf:"bytes,2,opt,name=streamPath,proto3" json:"streamPath,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeletePlanStreamRequest) Reset() {
*x = DeletePlanStreamRequest{}
mi := &file_crontab_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeletePlanStreamRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeletePlanStreamRequest) ProtoMessage() {}
func (x *DeletePlanStreamRequest) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeletePlanStreamRequest.ProtoReflect.Descriptor instead.
func (*DeletePlanStreamRequest) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{8}
}
func (x *DeletePlanStreamRequest) GetPlanId() uint32 {
if x != nil {
return x.PlanId
}
return 0
}
func (x *DeletePlanStreamRequest) GetStreamPath() string {
if x != nil {
return x.StreamPath
}
return ""
}
// 解析计划请求
type ParsePlanRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Plan string `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` // 168位的0/1字符串表示一周的每个小时是否录制
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ParsePlanRequest) Reset() {
*x = ParsePlanRequest{}
mi := &file_crontab_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ParsePlanRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ParsePlanRequest) ProtoMessage() {}
func (x *ParsePlanRequest) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ParsePlanRequest.ProtoReflect.Descriptor instead.
func (*ParsePlanRequest) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{9}
}
func (x *ParsePlanRequest) GetPlan() string {
if x != nil {
return x.Plan
}
return ""
}
// 时间段信息
type TimeSlotInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Start *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=start,proto3" json:"start,omitempty"` // 开始时间
End *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=end,proto3" json:"end,omitempty"` // 结束时间
Weekday string `protobuf:"bytes,3,opt,name=weekday,proto3" json:"weekday,omitempty"` // 周几(例如:周一)
TimeRange string `protobuf:"bytes,4,opt,name=time_range,json=timeRange,proto3" json:"time_range,omitempty"` // 时间范围例如09:00-10:00
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TimeSlotInfo) Reset() {
*x = TimeSlotInfo{}
mi := &file_crontab_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TimeSlotInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TimeSlotInfo) ProtoMessage() {}
func (x *TimeSlotInfo) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TimeSlotInfo.ProtoReflect.Descriptor instead.
func (*TimeSlotInfo) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{10}
}
func (x *TimeSlotInfo) GetStart() *timestamppb.Timestamp {
if x != nil {
return x.Start
}
return nil
}
func (x *TimeSlotInfo) GetEnd() *timestamppb.Timestamp {
if x != nil {
return x.End
}
return nil
}
func (x *TimeSlotInfo) GetWeekday() string {
if x != nil {
return x.Weekday
}
return ""
}
func (x *TimeSlotInfo) GetTimeRange() string {
if x != nil {
return x.TimeRange
}
return ""
}
// 解析计划响应
type ParsePlanResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` // 响应码
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // 响应消息
Slots []*TimeSlotInfo `protobuf:"bytes,3,rep,name=slots,proto3" json:"slots,omitempty"` // 所有计划的时间段
NextSlot *TimeSlotInfo `protobuf:"bytes,4,opt,name=next_slot,json=nextSlot,proto3" json:"next_slot,omitempty"` // 从当前时间开始的下一个时间段
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ParsePlanResponse) Reset() {
*x = ParsePlanResponse{}
mi := &file_crontab_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ParsePlanResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ParsePlanResponse) ProtoMessage() {}
func (x *ParsePlanResponse) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ParsePlanResponse.ProtoReflect.Descriptor instead.
func (*ParsePlanResponse) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{11}
}
func (x *ParsePlanResponse) GetCode() int32 {
if x != nil {
return x.Code
}
return 0
}
func (x *ParsePlanResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *ParsePlanResponse) GetSlots() []*TimeSlotInfo {
if x != nil {
return x.Slots
}
return nil
}
func (x *ParsePlanResponse) GetNextSlot() *TimeSlotInfo {
if x != nil {
return x.NextSlot
}
return nil
}
// 新增的消息定义
// 获取Crontab状态请求
type CrontabStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// 可以为空,表示获取所有任务
StreamPath string `protobuf:"bytes,1,opt,name=stream_path,json=streamPath,proto3" json:"stream_path,omitempty"` // 可选,按流路径过滤
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CrontabStatusRequest) Reset() {
*x = CrontabStatusRequest{}
mi := &file_crontab_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CrontabStatusRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CrontabStatusRequest) ProtoMessage() {}
func (x *CrontabStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CrontabStatusRequest.ProtoReflect.Descriptor instead.
func (*CrontabStatusRequest) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{12}
}
func (x *CrontabStatusRequest) GetStreamPath() string {
if x != nil {
return x.StreamPath
}
return ""
}
// 任务信息
type CrontabTaskInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
PlanId uint32 `protobuf:"varint,1,opt,name=plan_id,json=planId,proto3" json:"plan_id,omitempty"` // 计划ID
PlanName string `protobuf:"bytes,2,opt,name=plan_name,json=planName,proto3" json:"plan_name,omitempty"` // 计划名称
StreamPath string `protobuf:"bytes,3,opt,name=stream_path,json=streamPath,proto3" json:"stream_path,omitempty"` // 流路径
IsRecording bool `protobuf:"varint,4,opt,name=is_recording,json=isRecording,proto3" json:"is_recording,omitempty"` // 是否正在录制
StartTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` // 当前/下一个任务开始时间
EndTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"` // 当前/下一个任务结束时间
TimeRange string `protobuf:"bytes,7,opt,name=time_range,json=timeRange,proto3" json:"time_range,omitempty"` // 时间范围例如09:00-10:00
Weekday string `protobuf:"bytes,8,opt,name=weekday,proto3" json:"weekday,omitempty"` // 周几(例如:周一)
FilePath string `protobuf:"bytes,9,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"` // 文件保存路径
Fragment string `protobuf:"bytes,10,opt,name=fragment,proto3" json:"fragment,omitempty"` // 分片设置
ElapsedSeconds uint32 `protobuf:"varint,11,opt,name=elapsed_seconds,json=elapsedSeconds,proto3" json:"elapsed_seconds,omitempty"` // 已运行时间(秒,仅对正在运行的任务有效)
RemainingSeconds uint32 `protobuf:"varint,12,opt,name=remaining_seconds,json=remainingSeconds,proto3" json:"remaining_seconds,omitempty"` // 剩余时间(秒)
PlanSlots []*TimeSlotInfo `protobuf:"bytes,13,rep,name=plan_slots,json=planSlots,proto3" json:"plan_slots,omitempty"` // 完整的计划时间段列表
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CrontabTaskInfo) Reset() {
*x = CrontabTaskInfo{}
mi := &file_crontab_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CrontabTaskInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CrontabTaskInfo) ProtoMessage() {}
func (x *CrontabTaskInfo) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CrontabTaskInfo.ProtoReflect.Descriptor instead.
func (*CrontabTaskInfo) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{13}
}
func (x *CrontabTaskInfo) GetPlanId() uint32 {
if x != nil {
return x.PlanId
}
return 0
}
func (x *CrontabTaskInfo) GetPlanName() string {
if x != nil {
return x.PlanName
}
return ""
}
func (x *CrontabTaskInfo) GetStreamPath() string {
if x != nil {
return x.StreamPath
}
return ""
}
func (x *CrontabTaskInfo) GetIsRecording() bool {
if x != nil {
return x.IsRecording
}
return false
}
func (x *CrontabTaskInfo) GetStartTime() *timestamppb.Timestamp {
if x != nil {
return x.StartTime
}
return nil
}
func (x *CrontabTaskInfo) GetEndTime() *timestamppb.Timestamp {
if x != nil {
return x.EndTime
}
return nil
}
func (x *CrontabTaskInfo) GetTimeRange() string {
if x != nil {
return x.TimeRange
}
return ""
}
func (x *CrontabTaskInfo) GetWeekday() string {
if x != nil {
return x.Weekday
}
return ""
}
func (x *CrontabTaskInfo) GetFilePath() string {
if x != nil {
return x.FilePath
}
return ""
}
func (x *CrontabTaskInfo) GetFragment() string {
if x != nil {
return x.Fragment
}
return ""
}
func (x *CrontabTaskInfo) GetElapsedSeconds() uint32 {
if x != nil {
return x.ElapsedSeconds
}
return 0
}
func (x *CrontabTaskInfo) GetRemainingSeconds() uint32 {
if x != nil {
return x.RemainingSeconds
}
return 0
}
func (x *CrontabTaskInfo) GetPlanSlots() []*TimeSlotInfo {
if x != nil {
return x.PlanSlots
}
return nil
}
// 获取Crontab状态响应
type CrontabStatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` // 响应码
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // 响应消息
RunningTasks []*CrontabTaskInfo `protobuf:"bytes,3,rep,name=running_tasks,json=runningTasks,proto3" json:"running_tasks,omitempty"` // 当前正在执行的任务列表
NextTasks []*CrontabTaskInfo `protobuf:"bytes,4,rep,name=next_tasks,json=nextTasks,proto3" json:"next_tasks,omitempty"` // 下一个计划执行的任务列表
TotalRunning uint32 `protobuf:"varint,5,opt,name=total_running,json=totalRunning,proto3" json:"total_running,omitempty"` // 正在运行的任务总数
TotalPlanned uint32 `protobuf:"varint,6,opt,name=total_planned,json=totalPlanned,proto3" json:"total_planned,omitempty"` // 计划中的任务总数
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CrontabStatusResponse) Reset() {
*x = CrontabStatusResponse{}
mi := &file_crontab_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CrontabStatusResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CrontabStatusResponse) ProtoMessage() {}
func (x *CrontabStatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_crontab_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CrontabStatusResponse.ProtoReflect.Descriptor instead.
func (*CrontabStatusResponse) Descriptor() ([]byte, []int) {
return file_crontab_proto_rawDescGZIP(), []int{14}
}
func (x *CrontabStatusResponse) GetCode() int32 {
if x != nil {
return x.Code
}
return 0
}
func (x *CrontabStatusResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *CrontabStatusResponse) GetRunningTasks() []*CrontabTaskInfo {
if x != nil {
return x.RunningTasks
}
return nil
}
func (x *CrontabStatusResponse) GetNextTasks() []*CrontabTaskInfo {
if x != nil {
return x.NextTasks
}
return nil
}
func (x *CrontabStatusResponse) GetTotalRunning() uint32 {
if x != nil {
return x.TotalRunning
}
return 0
}
func (x *CrontabStatusResponse) GetTotalPlanned() uint32 {
if x != nil {
return x.TotalPlanned
}
return 0
}
var File_crontab_proto protoreflect.FileDescriptor
const file_crontab_proto_rawDesc = "" +
@@ -371,12 +1132,95 @@ const file_crontab_proto_rawDesc = "" +
"\x02id\x18\x01 \x01(\rR\x02id\"8\n" +
"\bResponse\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage2\xbe\x02\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"\xac\x02\n" +
"\n" +
"PlanStream\x12\x16\n" +
"\x06planId\x18\x01 \x01(\rR\x06planId\x12\x1f\n" +
"\vstream_path\x18\x02 \x01(\tR\n" +
"streamPath\x12\x1a\n" +
"\bfragment\x18\x03 \x01(\tR\bfragment\x12\x1a\n" +
"\bfilePath\x18\x04 \x01(\tR\bfilePath\x12\x1f\n" +
"\vrecord_type\x18\x05 \x01(\tR\n" +
"recordType\x129\n" +
"\n" +
"created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
"\n" +
"updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x16\n" +
"\x06enable\x18\b \x01(\bR\x06enable\"\x82\x01\n" +
"\x11ReqPlanStreamList\x12\x18\n" +
"\apageNum\x18\x01 \x01(\rR\apageNum\x12\x1a\n" +
"\bpageSize\x18\x02 \x01(\rR\bpageSize\x12\x16\n" +
"\x06planId\x18\x03 \x01(\rR\x06planId\x12\x1f\n" +
"\vstream_path\x18\x04 \x01(\tR\n" +
"streamPath\"\xcb\x01\n" +
"\x1cRecordPlanStreamResponseList\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1e\n" +
"\n" +
"totalCount\x18\x03 \x01(\rR\n" +
"totalCount\x12\x18\n" +
"\apageNum\x18\x04 \x01(\rR\apageNum\x12\x1a\n" +
"\bpageSize\x18\x05 \x01(\rR\bpageSize\x12'\n" +
"\x04data\x18\x06 \x03(\v2\x13.crontab.PlanStreamR\x04data\"Q\n" +
"\x17DeletePlanStreamRequest\x12\x16\n" +
"\x06planId\x18\x01 \x01(\rR\x06planId\x12\x1e\n" +
"\n" +
"streamPath\x18\x02 \x01(\tR\n" +
"streamPath\"&\n" +
"\x10ParsePlanRequest\x12\x12\n" +
"\x04plan\x18\x01 \x01(\tR\x04plan\"\xa7\x01\n" +
"\fTimeSlotInfo\x120\n" +
"\x05start\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05start\x12,\n" +
"\x03end\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x03end\x12\x18\n" +
"\aweekday\x18\x03 \x01(\tR\aweekday\x12\x1d\n" +
"\n" +
"time_range\x18\x04 \x01(\tR\ttimeRange\"\xa2\x01\n" +
"\x11ParsePlanResponse\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12+\n" +
"\x05slots\x18\x03 \x03(\v2\x15.crontab.TimeSlotInfoR\x05slots\x122\n" +
"\tnext_slot\x18\x04 \x01(\v2\x15.crontab.TimeSlotInfoR\bnextSlot\"7\n" +
"\x14CrontabStatusRequest\x12\x1f\n" +
"\vstream_path\x18\x01 \x01(\tR\n" +
"streamPath\"\xfb\x03\n" +
"\x0fCrontabTaskInfo\x12\x17\n" +
"\aplan_id\x18\x01 \x01(\rR\x06planId\x12\x1b\n" +
"\tplan_name\x18\x02 \x01(\tR\bplanName\x12\x1f\n" +
"\vstream_path\x18\x03 \x01(\tR\n" +
"streamPath\x12!\n" +
"\fis_recording\x18\x04 \x01(\bR\visRecording\x129\n" +
"\n" +
"start_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tstartTime\x125\n" +
"\bend_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\aendTime\x12\x1d\n" +
"\n" +
"time_range\x18\a \x01(\tR\ttimeRange\x12\x18\n" +
"\aweekday\x18\b \x01(\tR\aweekday\x12\x1b\n" +
"\tfile_path\x18\t \x01(\tR\bfilePath\x12\x1a\n" +
"\bfragment\x18\n" +
" \x01(\tR\bfragment\x12'\n" +
"\x0felapsed_seconds\x18\v \x01(\rR\x0eelapsedSeconds\x12+\n" +
"\x11remaining_seconds\x18\f \x01(\rR\x10remainingSeconds\x124\n" +
"\n" +
"plan_slots\x18\r \x03(\v2\x15.crontab.TimeSlotInfoR\tplanSlots\"\x87\x02\n" +
"\x15CrontabStatusResponse\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12=\n" +
"\rrunning_tasks\x18\x03 \x03(\v2\x18.crontab.CrontabTaskInfoR\frunningTasks\x127\n" +
"\n" +
"next_tasks\x18\x04 \x03(\v2\x18.crontab.CrontabTaskInfoR\tnextTasks\x12#\n" +
"\rtotal_running\x18\x05 \x01(\rR\ftotalRunning\x12#\n" +
"\rtotal_planned\x18\x06 \x01(\rR\ftotalPlanned2\xe0\a\n" +
"\x03api\x12O\n" +
"\x04List\x12\x14.crontab.ReqPlanList\x1a\x19.crontab.PlanResponseList\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/plan/api/list\x12A\n" +
"\x03Add\x12\r.crontab.Plan\x1a\x11.crontab.Response\"\x18\x82\xd3\xe4\x93\x02\x12:\x01*\"\r/plan/api/add\x12L\n" +
"\x06Update\x12\r.crontab.Plan\x1a\x11.crontab.Response\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/plan/api/update/{id}\x12U\n" +
"\x06Remove\x12\x16.crontab.DeleteRequest\x1a\x11.crontab.Response\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/plan/api/remove/{id}B\x1fZ\x1dm7s.live/v5/plugin/crontab/pbb\x06proto3"
"\x06Remove\x12\x16.crontab.DeleteRequest\x1a\x11.crontab.Response\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/plan/api/remove/{id}\x12x\n" +
"\x15ListRecordPlanStreams\x12\x1a.crontab.ReqPlanStreamList\x1a%.crontab.RecordPlanStreamResponseList\"\x1c\x82\xd3\xe4\x93\x02\x16\x12\x14/planstream/api/list\x12]\n" +
"\x13AddRecordPlanStream\x12\x13.crontab.PlanStream\x1a\x11.crontab.Response\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/planstream/api/add\x12c\n" +
"\x16UpdateRecordPlanStream\x12\x13.crontab.PlanStream\x1a\x11.crontab.Response\"!\x82\xd3\xe4\x93\x02\x1b:\x01*\"\x16/planstream/api/update\x12\x89\x01\n" +
"\x16RemoveRecordPlanStream\x12 .crontab.DeletePlanStreamRequest\x1a\x11.crontab.Response\":\x82\xd3\xe4\x93\x024:\x01*\"//planstream/api/remove/{planId}/{streamPath=**}\x12f\n" +
"\rParsePlanTime\x12\x19.crontab.ParsePlanRequest\x1a\x1a.crontab.ParsePlanResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/plan/api/parse/{plan}\x12n\n" +
"\x10GetCrontabStatus\x12\x1d.crontab.CrontabStatusRequest\x1a\x1e.crontab.CrontabStatusResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\x12\x13/crontab/api/statusB\x1fZ\x1dm7s.live/v5/plugin/crontab/pbb\x06proto3"
var (
file_crontab_proto_rawDescOnce sync.Once
@@ -390,32 +1234,66 @@ func file_crontab_proto_rawDescGZIP() []byte {
return file_crontab_proto_rawDescData
}
var file_crontab_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_crontab_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_crontab_proto_goTypes = []any{
(*PlanResponseList)(nil), // 0: crontab.PlanResponseList
(*Plan)(nil), // 1: crontab.Plan
(*ReqPlanList)(nil), // 2: crontab.ReqPlanList
(*DeleteRequest)(nil), // 3: crontab.DeleteRequest
(*Response)(nil), // 4: crontab.Response
(*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp
(*PlanResponseList)(nil), // 0: crontab.PlanResponseList
(*Plan)(nil), // 1: crontab.Plan
(*ReqPlanList)(nil), // 2: crontab.ReqPlanList
(*DeleteRequest)(nil), // 3: crontab.DeleteRequest
(*Response)(nil), // 4: crontab.Response
(*PlanStream)(nil), // 5: crontab.PlanStream
(*ReqPlanStreamList)(nil), // 6: crontab.ReqPlanStreamList
(*RecordPlanStreamResponseList)(nil), // 7: crontab.RecordPlanStreamResponseList
(*DeletePlanStreamRequest)(nil), // 8: crontab.DeletePlanStreamRequest
(*ParsePlanRequest)(nil), // 9: crontab.ParsePlanRequest
(*TimeSlotInfo)(nil), // 10: crontab.TimeSlotInfo
(*ParsePlanResponse)(nil), // 11: crontab.ParsePlanResponse
(*CrontabStatusRequest)(nil), // 12: crontab.CrontabStatusRequest
(*CrontabTaskInfo)(nil), // 13: crontab.CrontabTaskInfo
(*CrontabStatusResponse)(nil), // 14: crontab.CrontabStatusResponse
(*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp
}
var file_crontab_proto_depIdxs = []int32{
1, // 0: crontab.PlanResponseList.data:type_name -> crontab.Plan
5, // 1: crontab.Plan.createTime:type_name -> google.protobuf.Timestamp
5, // 2: crontab.Plan.updateTime:type_name -> google.protobuf.Timestamp
2, // 3: crontab.api.List:input_type -> crontab.ReqPlanList
1, // 4: crontab.api.Add:input_type -> crontab.Plan
1, // 5: crontab.api.Update:input_type -> crontab.Plan
3, // 6: crontab.api.Remove:input_type -> crontab.DeleteRequest
0, // 7: crontab.api.List:output_type -> crontab.PlanResponseList
4, // 8: crontab.api.Add:output_type -> crontab.Response
4, // 9: crontab.api.Update:output_type -> crontab.Response
4, // 10: crontab.api.Remove:output_type -> crontab.Response
7, // [7:11] is the sub-list for method output_type
3, // [3:7] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
1, // 0: crontab.PlanResponseList.data:type_name -> crontab.Plan
15, // 1: crontab.Plan.createTime:type_name -> google.protobuf.Timestamp
15, // 2: crontab.Plan.updateTime:type_name -> google.protobuf.Timestamp
15, // 3: crontab.PlanStream.created_at:type_name -> google.protobuf.Timestamp
15, // 4: crontab.PlanStream.updated_at:type_name -> google.protobuf.Timestamp
5, // 5: crontab.RecordPlanStreamResponseList.data:type_name -> crontab.PlanStream
15, // 6: crontab.TimeSlotInfo.start:type_name -> google.protobuf.Timestamp
15, // 7: crontab.TimeSlotInfo.end:type_name -> google.protobuf.Timestamp
10, // 8: crontab.ParsePlanResponse.slots:type_name -> crontab.TimeSlotInfo
10, // 9: crontab.ParsePlanResponse.next_slot:type_name -> crontab.TimeSlotInfo
15, // 10: crontab.CrontabTaskInfo.start_time:type_name -> google.protobuf.Timestamp
15, // 11: crontab.CrontabTaskInfo.end_time:type_name -> google.protobuf.Timestamp
10, // 12: crontab.CrontabTaskInfo.plan_slots:type_name -> crontab.TimeSlotInfo
13, // 13: crontab.CrontabStatusResponse.running_tasks:type_name -> crontab.CrontabTaskInfo
13, // 14: crontab.CrontabStatusResponse.next_tasks:type_name -> crontab.CrontabTaskInfo
2, // 15: crontab.api.List:input_type -> crontab.ReqPlanList
1, // 16: crontab.api.Add:input_type -> crontab.Plan
1, // 17: crontab.api.Update:input_type -> crontab.Plan
3, // 18: crontab.api.Remove:input_type -> crontab.DeleteRequest
6, // 19: crontab.api.ListRecordPlanStreams:input_type -> crontab.ReqPlanStreamList
5, // 20: crontab.api.AddRecordPlanStream:input_type -> crontab.PlanStream
5, // 21: crontab.api.UpdateRecordPlanStream:input_type -> crontab.PlanStream
8, // 22: crontab.api.RemoveRecordPlanStream:input_type -> crontab.DeletePlanStreamRequest
9, // 23: crontab.api.ParsePlanTime:input_type -> crontab.ParsePlanRequest
12, // 24: crontab.api.GetCrontabStatus:input_type -> crontab.CrontabStatusRequest
0, // 25: crontab.api.List:output_type -> crontab.PlanResponseList
4, // 26: crontab.api.Add:output_type -> crontab.Response
4, // 27: crontab.api.Update:output_type -> crontab.Response
4, // 28: crontab.api.Remove:output_type -> crontab.Response
7, // 29: crontab.api.ListRecordPlanStreams:output_type -> crontab.RecordPlanStreamResponseList
4, // 30: crontab.api.AddRecordPlanStream:output_type -> crontab.Response
4, // 31: crontab.api.UpdateRecordPlanStream:output_type -> crontab.Response
4, // 32: crontab.api.RemoveRecordPlanStream:output_type -> crontab.Response
11, // 33: crontab.api.ParsePlanTime:output_type -> crontab.ParsePlanResponse
14, // 34: crontab.api.GetCrontabStatus:output_type -> crontab.CrontabStatusResponse
25, // [25:35] is the sub-list for method output_type
15, // [15:25] is the sub-list for method input_type
15, // [15:15] is the sub-list for extension type_name
15, // [15:15] is the sub-list for extension extendee
0, // [0:15] is the sub-list for field type_name
}
func init() { file_crontab_proto_init() }
@@ -429,7 +1307,7 @@ func file_crontab_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_crontab_proto_rawDesc), len(file_crontab_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumMessages: 15,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -176,6 +176,215 @@ func local_request_Api_Remove_0(ctx context.Context, marshaler runtime.Marshaler
return msg, metadata, err
}
var filter_Api_ListRecordPlanStreams_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
func request_Api_ListRecordPlanStreams_0(ctx context.Context, marshaler runtime.Marshaler, client ApiClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ReqPlanStreamList
metadata runtime.ServerMetadata
)
io.Copy(io.Discard, req.Body)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Api_ListRecordPlanStreams_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.ListRecordPlanStreams(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_Api_ListRecordPlanStreams_0(ctx context.Context, marshaler runtime.Marshaler, server ApiServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ReqPlanStreamList
metadata runtime.ServerMetadata
)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Api_ListRecordPlanStreams_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ListRecordPlanStreams(ctx, &protoReq)
return msg, metadata, err
}
func request_Api_AddRecordPlanStream_0(ctx context.Context, marshaler runtime.Marshaler, client ApiClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq PlanStream
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.AddRecordPlanStream(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_Api_AddRecordPlanStream_0(ctx context.Context, marshaler runtime.Marshaler, server ApiServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq PlanStream
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.AddRecordPlanStream(ctx, &protoReq)
return msg, metadata, err
}
func request_Api_UpdateRecordPlanStream_0(ctx context.Context, marshaler runtime.Marshaler, client ApiClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq PlanStream
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.UpdateRecordPlanStream(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_Api_UpdateRecordPlanStream_0(ctx context.Context, marshaler runtime.Marshaler, server ApiServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq PlanStream
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.UpdateRecordPlanStream(ctx, &protoReq)
return msg, metadata, err
}
func request_Api_RemoveRecordPlanStream_0(ctx context.Context, marshaler runtime.Marshaler, client ApiClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq DeletePlanStreamRequest
metadata runtime.ServerMetadata
err error
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
val, ok := pathParams["planId"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "planId")
}
protoReq.PlanId, err = runtime.Uint32(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "planId", err)
}
val, ok = pathParams["streamPath"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "streamPath")
}
protoReq.StreamPath, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "streamPath", err)
}
msg, err := client.RemoveRecordPlanStream(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_Api_RemoveRecordPlanStream_0(ctx context.Context, marshaler runtime.Marshaler, server ApiServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq DeletePlanStreamRequest
metadata runtime.ServerMetadata
err error
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
val, ok := pathParams["planId"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "planId")
}
protoReq.PlanId, err = runtime.Uint32(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "planId", err)
}
val, ok = pathParams["streamPath"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "streamPath")
}
protoReq.StreamPath, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "streamPath", err)
}
msg, err := server.RemoveRecordPlanStream(ctx, &protoReq)
return msg, metadata, err
}
func request_Api_ParsePlanTime_0(ctx context.Context, marshaler runtime.Marshaler, client ApiClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ParsePlanRequest
metadata runtime.ServerMetadata
err error
)
io.Copy(io.Discard, req.Body)
val, ok := pathParams["plan"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "plan")
}
protoReq.Plan, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "plan", err)
}
msg, err := client.ParsePlanTime(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_Api_ParsePlanTime_0(ctx context.Context, marshaler runtime.Marshaler, server ApiServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ParsePlanRequest
metadata runtime.ServerMetadata
err error
)
val, ok := pathParams["plan"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "plan")
}
protoReq.Plan, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "plan", err)
}
msg, err := server.ParsePlanTime(ctx, &protoReq)
return msg, metadata, err
}
var filter_Api_GetCrontabStatus_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
func request_Api_GetCrontabStatus_0(ctx context.Context, marshaler runtime.Marshaler, client ApiClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CrontabStatusRequest
metadata runtime.ServerMetadata
)
io.Copy(io.Discard, req.Body)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Api_GetCrontabStatus_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.GetCrontabStatus(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_Api_GetCrontabStatus_0(ctx context.Context, marshaler runtime.Marshaler, server ApiServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CrontabStatusRequest
metadata runtime.ServerMetadata
)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Api_GetCrontabStatus_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetCrontabStatus(ctx, &protoReq)
return msg, metadata, err
}
// RegisterApiHandlerServer registers the http handlers for service Api to "mux".
// UnaryRPC :call ApiServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
@@ -262,6 +471,126 @@ func RegisterApiHandlerServer(ctx context.Context, mux *runtime.ServeMux, server
}
forward_Api_Remove_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_Api_ListRecordPlanStreams_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crontab.Api/ListRecordPlanStreams", runtime.WithHTTPPathPattern("/planstream/api/list"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_Api_ListRecordPlanStreams_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_ListRecordPlanStreams_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_Api_AddRecordPlanStream_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crontab.Api/AddRecordPlanStream", runtime.WithHTTPPathPattern("/planstream/api/add"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_Api_AddRecordPlanStream_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_AddRecordPlanStream_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_Api_UpdateRecordPlanStream_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crontab.Api/UpdateRecordPlanStream", runtime.WithHTTPPathPattern("/planstream/api/update"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_Api_UpdateRecordPlanStream_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_UpdateRecordPlanStream_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_Api_RemoveRecordPlanStream_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crontab.Api/RemoveRecordPlanStream", runtime.WithHTTPPathPattern("/planstream/api/remove/{planId}/{streamPath=**}"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_Api_RemoveRecordPlanStream_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_RemoveRecordPlanStream_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_Api_ParsePlanTime_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crontab.Api/ParsePlanTime", runtime.WithHTTPPathPattern("/plan/api/parse/{plan}"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_Api_ParsePlanTime_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_ParsePlanTime_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_Api_GetCrontabStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crontab.Api/GetCrontabStatus", runtime.WithHTTPPathPattern("/crontab/api/status"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_Api_GetCrontabStatus_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_GetCrontabStatus_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
@@ -370,19 +699,133 @@ func RegisterApiHandlerClient(ctx context.Context, mux *runtime.ServeMux, client
}
forward_Api_Remove_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_Api_ListRecordPlanStreams_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crontab.Api/ListRecordPlanStreams", runtime.WithHTTPPathPattern("/planstream/api/list"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_Api_ListRecordPlanStreams_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_ListRecordPlanStreams_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_Api_AddRecordPlanStream_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crontab.Api/AddRecordPlanStream", runtime.WithHTTPPathPattern("/planstream/api/add"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_Api_AddRecordPlanStream_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_AddRecordPlanStream_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_Api_UpdateRecordPlanStream_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crontab.Api/UpdateRecordPlanStream", runtime.WithHTTPPathPattern("/planstream/api/update"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_Api_UpdateRecordPlanStream_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_UpdateRecordPlanStream_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_Api_RemoveRecordPlanStream_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crontab.Api/RemoveRecordPlanStream", runtime.WithHTTPPathPattern("/planstream/api/remove/{planId}/{streamPath=**}"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_Api_RemoveRecordPlanStream_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_RemoveRecordPlanStream_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_Api_ParsePlanTime_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crontab.Api/ParsePlanTime", runtime.WithHTTPPathPattern("/plan/api/parse/{plan}"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_Api_ParsePlanTime_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_ParsePlanTime_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_Api_GetCrontabStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crontab.Api/GetCrontabStatus", runtime.WithHTTPPathPattern("/crontab/api/status"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_Api_GetCrontabStatus_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_Api_GetCrontabStatus_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
var (
pattern_Api_List_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"plan", "api", "list"}, ""))
pattern_Api_Add_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"plan", "api", "add"}, ""))
pattern_Api_Update_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"plan", "api", "update", "id"}, ""))
pattern_Api_Remove_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"plan", "api", "remove", "id"}, ""))
pattern_Api_List_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"plan", "api", "list"}, ""))
pattern_Api_Add_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"plan", "api", "add"}, ""))
pattern_Api_Update_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"plan", "api", "update", "id"}, ""))
pattern_Api_Remove_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"plan", "api", "remove", "id"}, ""))
pattern_Api_ListRecordPlanStreams_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"planstream", "api", "list"}, ""))
pattern_Api_AddRecordPlanStream_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"planstream", "api", "add"}, ""))
pattern_Api_UpdateRecordPlanStream_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"planstream", "api", "update"}, ""))
pattern_Api_RemoveRecordPlanStream_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 3, 0, 4, 1, 5, 4}, []string{"planstream", "api", "remove", "planId", "streamPath"}, ""))
pattern_Api_ParsePlanTime_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 0}, []string{"plan", "api", "parse"}, ""))
pattern_Api_GetCrontabStatus_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"crontab", "api", "status"}, ""))
)
var (
forward_Api_List_0 = runtime.ForwardResponseMessage
forward_Api_Add_0 = runtime.ForwardResponseMessage
forward_Api_Update_0 = runtime.ForwardResponseMessage
forward_Api_Remove_0 = runtime.ForwardResponseMessage
forward_Api_List_0 = runtime.ForwardResponseMessage
forward_Api_Add_0 = runtime.ForwardResponseMessage
forward_Api_Update_0 = runtime.ForwardResponseMessage
forward_Api_Remove_0 = runtime.ForwardResponseMessage
forward_Api_ListRecordPlanStreams_0 = runtime.ForwardResponseMessage
forward_Api_AddRecordPlanStream_0 = runtime.ForwardResponseMessage
forward_Api_UpdateRecordPlanStream_0 = runtime.ForwardResponseMessage
forward_Api_RemoveRecordPlanStream_0 = runtime.ForwardResponseMessage
forward_Api_ParsePlanTime_0 = runtime.ForwardResponseMessage
forward_Api_GetCrontabStatus_0 = runtime.ForwardResponseMessage
)

View File

@@ -28,6 +28,45 @@ service api {
body: "*"
};
}
// RecordPlanStream 相关接口
rpc ListRecordPlanStreams (ReqPlanStreamList) returns (RecordPlanStreamResponseList) {
option (google.api.http) = {
get: "/planstream/api/list"
};
}
rpc AddRecordPlanStream (PlanStream) returns (Response) {
option (google.api.http) = {
post: "/planstream/api/add"
body: "*"
};
}
rpc UpdateRecordPlanStream (PlanStream) returns (Response) {
option (google.api.http) = {
post: "/planstream/api/update"
body: "*"
};
}
rpc RemoveRecordPlanStream (DeletePlanStreamRequest) returns (Response) {
option (google.api.http) = {
post: "/planstream/api/remove/{planId}/{streamPath=**}"
body: "*"
};
}
// 解析计划字符串,返回时间段信息
rpc ParsePlanTime (ParsePlanRequest) returns (ParsePlanResponse) {
option (google.api.http) = {
get: "/plan/api/parse/{plan}"
};
}
// 获取当前Crontab任务状态
rpc GetCrontabStatus (CrontabStatusRequest) returns (CrontabStatusResponse) {
option (google.api.http) = {
get: "/crontab/api/status"
};
}
}
message PlanResponseList {
@@ -60,4 +99,92 @@ message DeleteRequest {
message Response {
int32 code = 1;
string message = 2;
}
// RecordPlanStream 相关消息定义
message PlanStream {
uint32 planId = 1;
string stream_path = 2;
string fragment = 3;
string filePath = 4;
string record_type = 5; // 录制类型,例如 "mp4", "flv"
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
bool enable = 8; // 是否启用该录制流
}
message ReqPlanStreamList {
uint32 pageNum = 1;
uint32 pageSize = 2;
uint32 planId = 3; // 可选的按录制计划ID筛选
string stream_path = 4; // 可选的按流路径筛选
}
message RecordPlanStreamResponseList {
int32 code = 1;
string message = 2;
uint32 totalCount = 3;
uint32 pageNum = 4;
uint32 pageSize = 5;
repeated PlanStream data = 6;
}
message DeletePlanStreamRequest {
uint32 planId = 1;
string streamPath = 2;
}
// 解析计划请求
message ParsePlanRequest {
string plan = 1; // 168位的0/1字符串表示一周的每个小时是否录制
}
// 时间段信息
message TimeSlotInfo {
google.protobuf.Timestamp start = 1; // 开始时间
google.protobuf.Timestamp end = 2; // 结束时间
string weekday = 3; // 周几(例如:周一)
string time_range = 4; // 时间范围例如09:00-10:00
}
// 解析计划响应
message ParsePlanResponse {
int32 code = 1; // 响应码
string message = 2; // 响应消息
repeated TimeSlotInfo slots = 3; // 所有计划的时间段
TimeSlotInfo next_slot = 4; // 从当前时间开始的下一个时间段
}
// 新增的消息定义
// 获取Crontab状态请求
message CrontabStatusRequest {
// 可以为空,表示获取所有任务
string stream_path = 1; // 可选,按流路径过滤
}
// 任务信息
message CrontabTaskInfo {
uint32 plan_id = 1; // 计划ID
string plan_name = 2; // 计划名称
string stream_path = 3; // 流路径
bool is_recording = 4; // 是否正在录制
google.protobuf.Timestamp start_time = 5; // 当前/下一个任务开始时间
google.protobuf.Timestamp end_time = 6; // 当前/下一个任务结束时间
string time_range = 7; // 时间范围例如09:00-10:00
string weekday = 8; // 周几(例如:周一)
string file_path = 9; // 文件保存路径
string fragment = 10; // 分片设置
uint32 elapsed_seconds = 11; // 已运行时间(秒,仅对正在运行的任务有效)
uint32 remaining_seconds = 12; // 剩余时间(秒)
repeated TimeSlotInfo plan_slots = 13; // 完整的计划时间段列表
}
// 获取Crontab状态响应
message CrontabStatusResponse {
int32 code = 1; // 响应码
string message = 2; // 响应消息
repeated CrontabTaskInfo running_tasks = 3; // 当前正在执行的任务列表
repeated CrontabTaskInfo next_tasks = 4; // 下一个计划执行的任务列表
uint32 total_running = 5; // 正在运行的任务总数
uint32 total_planned = 6; // 计划中的任务总数
}

View File

@@ -19,10 +19,16 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
Api_List_FullMethodName = "/crontab.api/List"
Api_Add_FullMethodName = "/crontab.api/Add"
Api_Update_FullMethodName = "/crontab.api/Update"
Api_Remove_FullMethodName = "/crontab.api/Remove"
Api_List_FullMethodName = "/crontab.api/List"
Api_Add_FullMethodName = "/crontab.api/Add"
Api_Update_FullMethodName = "/crontab.api/Update"
Api_Remove_FullMethodName = "/crontab.api/Remove"
Api_ListRecordPlanStreams_FullMethodName = "/crontab.api/ListRecordPlanStreams"
Api_AddRecordPlanStream_FullMethodName = "/crontab.api/AddRecordPlanStream"
Api_UpdateRecordPlanStream_FullMethodName = "/crontab.api/UpdateRecordPlanStream"
Api_RemoveRecordPlanStream_FullMethodName = "/crontab.api/RemoveRecordPlanStream"
Api_ParsePlanTime_FullMethodName = "/crontab.api/ParsePlanTime"
Api_GetCrontabStatus_FullMethodName = "/crontab.api/GetCrontabStatus"
)
// ApiClient is the client API for Api service.
@@ -33,6 +39,15 @@ type ApiClient interface {
Add(ctx context.Context, in *Plan, opts ...grpc.CallOption) (*Response, error)
Update(ctx context.Context, in *Plan, opts ...grpc.CallOption) (*Response, error)
Remove(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*Response, error)
// RecordPlanStream 相关接口
ListRecordPlanStreams(ctx context.Context, in *ReqPlanStreamList, opts ...grpc.CallOption) (*RecordPlanStreamResponseList, error)
AddRecordPlanStream(ctx context.Context, in *PlanStream, opts ...grpc.CallOption) (*Response, error)
UpdateRecordPlanStream(ctx context.Context, in *PlanStream, opts ...grpc.CallOption) (*Response, error)
RemoveRecordPlanStream(ctx context.Context, in *DeletePlanStreamRequest, opts ...grpc.CallOption) (*Response, error)
// 解析计划字符串,返回时间段信息
ParsePlanTime(ctx context.Context, in *ParsePlanRequest, opts ...grpc.CallOption) (*ParsePlanResponse, error)
// 获取当前Crontab任务状态
GetCrontabStatus(ctx context.Context, in *CrontabStatusRequest, opts ...grpc.CallOption) (*CrontabStatusResponse, error)
}
type apiClient struct {
@@ -83,6 +98,66 @@ func (c *apiClient) Remove(ctx context.Context, in *DeleteRequest, opts ...grpc.
return out, nil
}
func (c *apiClient) ListRecordPlanStreams(ctx context.Context, in *ReqPlanStreamList, opts ...grpc.CallOption) (*RecordPlanStreamResponseList, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RecordPlanStreamResponseList)
err := c.cc.Invoke(ctx, Api_ListRecordPlanStreams_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *apiClient) AddRecordPlanStream(ctx context.Context, in *PlanStream, opts ...grpc.CallOption) (*Response, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Response)
err := c.cc.Invoke(ctx, Api_AddRecordPlanStream_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *apiClient) UpdateRecordPlanStream(ctx context.Context, in *PlanStream, opts ...grpc.CallOption) (*Response, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Response)
err := c.cc.Invoke(ctx, Api_UpdateRecordPlanStream_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *apiClient) RemoveRecordPlanStream(ctx context.Context, in *DeletePlanStreamRequest, opts ...grpc.CallOption) (*Response, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Response)
err := c.cc.Invoke(ctx, Api_RemoveRecordPlanStream_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *apiClient) ParsePlanTime(ctx context.Context, in *ParsePlanRequest, opts ...grpc.CallOption) (*ParsePlanResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ParsePlanResponse)
err := c.cc.Invoke(ctx, Api_ParsePlanTime_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *apiClient) GetCrontabStatus(ctx context.Context, in *CrontabStatusRequest, opts ...grpc.CallOption) (*CrontabStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CrontabStatusResponse)
err := c.cc.Invoke(ctx, Api_GetCrontabStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ApiServer is the server API for Api service.
// All implementations must embed UnimplementedApiServer
// for forward compatibility.
@@ -91,6 +166,15 @@ type ApiServer interface {
Add(context.Context, *Plan) (*Response, error)
Update(context.Context, *Plan) (*Response, error)
Remove(context.Context, *DeleteRequest) (*Response, error)
// RecordPlanStream 相关接口
ListRecordPlanStreams(context.Context, *ReqPlanStreamList) (*RecordPlanStreamResponseList, error)
AddRecordPlanStream(context.Context, *PlanStream) (*Response, error)
UpdateRecordPlanStream(context.Context, *PlanStream) (*Response, error)
RemoveRecordPlanStream(context.Context, *DeletePlanStreamRequest) (*Response, error)
// 解析计划字符串,返回时间段信息
ParsePlanTime(context.Context, *ParsePlanRequest) (*ParsePlanResponse, error)
// 获取当前Crontab任务状态
GetCrontabStatus(context.Context, *CrontabStatusRequest) (*CrontabStatusResponse, error)
mustEmbedUnimplementedApiServer()
}
@@ -113,6 +197,24 @@ func (UnimplementedApiServer) Update(context.Context, *Plan) (*Response, error)
func (UnimplementedApiServer) Remove(context.Context, *DeleteRequest) (*Response, error) {
return nil, status.Errorf(codes.Unimplemented, "method Remove not implemented")
}
func (UnimplementedApiServer) ListRecordPlanStreams(context.Context, *ReqPlanStreamList) (*RecordPlanStreamResponseList, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRecordPlanStreams not implemented")
}
func (UnimplementedApiServer) AddRecordPlanStream(context.Context, *PlanStream) (*Response, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddRecordPlanStream not implemented")
}
func (UnimplementedApiServer) UpdateRecordPlanStream(context.Context, *PlanStream) (*Response, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateRecordPlanStream not implemented")
}
func (UnimplementedApiServer) RemoveRecordPlanStream(context.Context, *DeletePlanStreamRequest) (*Response, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveRecordPlanStream not implemented")
}
func (UnimplementedApiServer) ParsePlanTime(context.Context, *ParsePlanRequest) (*ParsePlanResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ParsePlanTime not implemented")
}
func (UnimplementedApiServer) GetCrontabStatus(context.Context, *CrontabStatusRequest) (*CrontabStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCrontabStatus not implemented")
}
func (UnimplementedApiServer) mustEmbedUnimplementedApiServer() {}
func (UnimplementedApiServer) testEmbeddedByValue() {}
@@ -206,6 +308,114 @@ func _Api_Remove_Handler(srv interface{}, ctx context.Context, dec func(interfac
return interceptor(ctx, in, info, handler)
}
func _Api_ListRecordPlanStreams_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReqPlanStreamList)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ApiServer).ListRecordPlanStreams(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Api_ListRecordPlanStreams_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ApiServer).ListRecordPlanStreams(ctx, req.(*ReqPlanStreamList))
}
return interceptor(ctx, in, info, handler)
}
func _Api_AddRecordPlanStream_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PlanStream)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ApiServer).AddRecordPlanStream(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Api_AddRecordPlanStream_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ApiServer).AddRecordPlanStream(ctx, req.(*PlanStream))
}
return interceptor(ctx, in, info, handler)
}
func _Api_UpdateRecordPlanStream_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PlanStream)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ApiServer).UpdateRecordPlanStream(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Api_UpdateRecordPlanStream_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ApiServer).UpdateRecordPlanStream(ctx, req.(*PlanStream))
}
return interceptor(ctx, in, info, handler)
}
func _Api_RemoveRecordPlanStream_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeletePlanStreamRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ApiServer).RemoveRecordPlanStream(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Api_RemoveRecordPlanStream_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ApiServer).RemoveRecordPlanStream(ctx, req.(*DeletePlanStreamRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Api_ParsePlanTime_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ParsePlanRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ApiServer).ParsePlanTime(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Api_ParsePlanTime_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ApiServer).ParsePlanTime(ctx, req.(*ParsePlanRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Api_GetCrontabStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CrontabStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ApiServer).GetCrontabStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Api_GetCrontabStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ApiServer).GetCrontabStatus(ctx, req.(*CrontabStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// Api_ServiceDesc is the grpc.ServiceDesc for Api service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -229,6 +439,30 @@ var Api_ServiceDesc = grpc.ServiceDesc{
MethodName: "Remove",
Handler: _Api_Remove_Handler,
},
{
MethodName: "ListRecordPlanStreams",
Handler: _Api_ListRecordPlanStreams_Handler,
},
{
MethodName: "AddRecordPlanStream",
Handler: _Api_AddRecordPlanStream_Handler,
},
{
MethodName: "UpdateRecordPlanStream",
Handler: _Api_UpdateRecordPlanStream_Handler,
},
{
MethodName: "RemoveRecordPlanStream",
Handler: _Api_RemoveRecordPlanStream_Handler,
},
{
MethodName: "ParsePlanTime",
Handler: _Api_ParsePlanTime_Handler,
},
{
MethodName: "GetCrontabStatus",
Handler: _Api_GetCrontabStatus_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "crontab.proto",

View File

@@ -1,13 +0,0 @@
package pkg
import (
"gorm.io/gorm"
)
// RecordPlan 录制计划模型
type RecordPlan struct {
gorm.Model
Name string `json:"name" gorm:"default:''"`
Plan string `json:"plan" gorm:"type:text"`
Enabled bool `json:"enabled" gorm:"default:true"`
}

View File

@@ -0,0 +1,17 @@
package pkg
import (
"gorm.io/gorm"
)
// RecordPlan 录制计划模型
type RecordPlan struct {
gorm.Model
Name string `json:"name" gorm:"default:''"`
Plan string `json:"plan" gorm:"type:text"`
Enable bool `json:"enable" gorm:"default:false"` // 是否启用
}
func (r *RecordPlan) GetKey() uint {
return r.ID
}

View File

@@ -0,0 +1,51 @@
package pkg
import (
"gorm.io/gorm"
"time"
)
// RecordPlanStream 录制计划流信息模型
type RecordPlanStream struct {
PlanID uint `json:"plan_id" gorm:"primaryKey;type:bigint;not null"` // 录制计划ID
StreamPath string `json:"stream_path" gorm:"primaryKey;type:varchar(255)"`
Fragment string `json:"fragment" gorm:"type:text"`
FilePath string `json:"file_path" gorm:"type:varchar(255)"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Enable bool `json:"enable" gorm:"default:false"` // 是否启用
RecordType string `json:"record_type" gorm:"type:varchar(255)"`
}
// TableName 设置表名
func (RecordPlanStream) TableName() string {
return "record_plans_streams"
}
// ScopeStreamPathLike 模糊查询 StreamPath
func ScopeStreamPathLike(streamPath string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if streamPath != "" {
return db.Where("record_plans_streams.stream_path LIKE ?", "%"+streamPath+"%")
}
return db
}
}
// ScopeOrderByCreatedAtDesc 按创建时间倒序
func ScopeOrderByCreatedAtDesc() func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Order("record_plans_streams.created_at DESC")
}
}
// ScopeRecordPlanID 按录制计划ID查询
func ScopeRecordPlanID(recordPlanID uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if recordPlanID > 0 {
return db.Where(&RecordPlanStream{PlanID: recordPlanID})
}
return db
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/process"
"m7s.live/v5/pkg/task"
)
//go:embed static/*
@@ -40,8 +41,17 @@ type consumer struct {
}
type server struct {
task.TickTask
consumers []consumer
consumersMutex sync.RWMutex
data DataStorage
lastPause uint32
dataMutex sync.RWMutex
lastConsumerID uint
upgrader websocket.Upgrader
prevSysTime float64
prevUserTime float64
myProcess *process.Process
}
type SimplePair struct {
@@ -75,99 +85,91 @@ const (
maxCount int = 86400
)
var (
data DataStorage
lastPause uint32
mutex sync.RWMutex
lastConsumerID uint
s server
upgrader = websocket.Upgrader{
func (s *server) Start() error {
var err error
s.myProcess, err = process.NewProcess(int32(os.Getpid()))
if err != nil {
log.Printf("Failed to get process: %v", err)
}
// 初始化 WebSocket upgrader
s.upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
prevSysTime float64
prevUserTime float64
myProcess *process.Process
)
func init() {
myProcess, _ = process.NewProcess(int32(os.Getpid()))
// preallocate arrays in data, helps save on reallocations caused by append()
// when maxCount is large
data.BytesAllocated = make([]SimplePair, 0, maxCount)
data.GcPauses = make([]SimplePair, 0, maxCount)
data.CPUUsage = make([]CPUPair, 0, maxCount)
data.Pprof = make([]PprofPair, 0, maxCount)
go s.gatherData()
s.data.BytesAllocated = make([]SimplePair, 0, maxCount)
s.data.GcPauses = make([]SimplePair, 0, maxCount)
s.data.CPUUsage = make([]CPUPair, 0, maxCount)
s.data.Pprof = make([]PprofPair, 0, maxCount)
return s.TickTask.Start()
}
func (s *server) gatherData() {
timer := time.Tick(time.Second)
func (s *server) GetTickInterval() time.Duration {
return time.Second
}
for now := range timer {
nowUnix := now.Unix()
func (s *server) Tick(any) {
now := time.Now()
nowUnix := now.Unix()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
u := update{
Ts: nowUnix * 1000,
Block: pprof.Lookup("block").Count(),
Goroutine: pprof.Lookup("goroutine").Count(),
Heap: pprof.Lookup("heap").Count(),
Mutex: pprof.Lookup("mutex").Count(),
Threadcreate: pprof.Lookup("threadcreate").Count(),
}
data.Pprof = append(data.Pprof, PprofPair{
uint64(nowUnix) * 1000,
u.Block,
u.Goroutine,
u.Heap,
u.Mutex,
u.Threadcreate,
})
cpuTimes, err := myProcess.Times()
if err != nil {
cpuTimes = &cpu.TimesStat{}
}
if prevUserTime != 0 {
u.CPUUser = cpuTimes.User - prevUserTime
u.CPUSys = cpuTimes.System - prevSysTime
data.CPUUsage = append(data.CPUUsage, CPUPair{uint64(nowUnix) * 1000, u.CPUUser, u.CPUSys})
}
prevUserTime = cpuTimes.User
prevSysTime = cpuTimes.System
mutex.Lock()
bytesAllocated := ms.Alloc
u.BytesAllocated = bytesAllocated
data.BytesAllocated = append(data.BytesAllocated, SimplePair{uint64(nowUnix) * 1000, bytesAllocated})
if lastPause == 0 || lastPause != ms.NumGC {
gcPause := ms.PauseNs[(ms.NumGC+255)%256]
u.GcPause = gcPause
data.GcPauses = append(data.GcPauses, SimplePair{uint64(nowUnix) * 1000, gcPause})
lastPause = ms.NumGC
}
if len(data.BytesAllocated) > maxCount {
data.BytesAllocated = data.BytesAllocated[len(data.BytesAllocated)-maxCount:]
}
if len(data.GcPauses) > maxCount {
data.GcPauses = data.GcPauses[len(data.GcPauses)-maxCount:]
}
mutex.Unlock()
s.sendToConsumers(u)
u := update{
Ts: nowUnix * 1000,
Block: pprof.Lookup("block").Count(),
Goroutine: pprof.Lookup("goroutine").Count(),
Heap: pprof.Lookup("heap").Count(),
Mutex: pprof.Lookup("mutex").Count(),
Threadcreate: pprof.Lookup("threadcreate").Count(),
}
s.data.Pprof = append(s.data.Pprof, PprofPair{
uint64(nowUnix) * 1000,
u.Block,
u.Goroutine,
u.Heap,
u.Mutex,
u.Threadcreate,
})
cpuTimes, err := s.myProcess.Times()
if err != nil {
cpuTimes = &cpu.TimesStat{}
}
if s.prevUserTime != 0 {
u.CPUUser = cpuTimes.User - s.prevUserTime
u.CPUSys = cpuTimes.System - s.prevSysTime
s.data.CPUUsage = append(s.data.CPUUsage, CPUPair{uint64(nowUnix) * 1000, u.CPUUser, u.CPUSys})
}
s.prevUserTime = cpuTimes.User
s.prevSysTime = cpuTimes.System
s.dataMutex.Lock()
bytesAllocated := ms.Alloc
u.BytesAllocated = bytesAllocated
s.data.BytesAllocated = append(s.data.BytesAllocated, SimplePair{uint64(nowUnix) * 1000, bytesAllocated})
if s.lastPause == 0 || s.lastPause != ms.NumGC {
gcPause := ms.PauseNs[(ms.NumGC+255)%256]
u.GcPause = gcPause
s.data.GcPauses = append(s.data.GcPauses, SimplePair{uint64(nowUnix) * 1000, gcPause})
s.lastPause = ms.NumGC
}
if len(s.data.BytesAllocated) > maxCount {
s.data.BytesAllocated = s.data.BytesAllocated[len(s.data.BytesAllocated)-maxCount:]
}
if len(s.data.GcPauses) > maxCount {
s.data.GcPauses = s.data.GcPauses[len(s.data.GcPauses)-maxCount:]
}
s.dataMutex.Unlock()
s.sendToConsumers(u)
}
func (s *server) sendToConsumers(u update) {
@@ -203,10 +205,10 @@ func (s *server) addConsumer() consumer {
s.consumersMutex.Lock()
defer s.consumersMutex.Unlock()
lastConsumerID++
s.lastConsumerID++
c := consumer{
id: lastConsumerID,
id: s.lastConsumerID,
c: make(chan update),
}
@@ -221,7 +223,7 @@ func (s *server) dataFeedHandler(w http.ResponseWriter, r *http.Request) {
lastPong time.Time
)
conn, err := upgrader.Upgrade(w, r, nil)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
@@ -268,9 +270,9 @@ func (s *server) dataFeedHandler(w http.ResponseWriter, r *http.Request) {
}
}
func dataHandler(w http.ResponseWriter, r *http.Request) {
mutex.RLock()
defer mutex.RUnlock()
func (s *server) dataHandler(w http.ResponseWriter, r *http.Request) {
s.dataMutex.RLock()
defer s.dataMutex.RUnlock()
if e := r.ParseForm(); e != nil {
log.Print("error parsing form")
@@ -284,7 +286,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.Encode(data)
encoder.Encode(s.data)
fmt.Fprint(w, ")")
}

219
plugin/debug/envcheck.go Normal file
View File

@@ -0,0 +1,219 @@
package plugin_debug
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"gopkg.in/yaml.v3"
"m7s.live/v5/pb"
"m7s.live/v5/pkg/util"
)
type EnvCheckResult struct {
Message string `json:"message"`
Type string `json:"type"` // info, success, error, complete
}
// 自定义系统信息响应结构体,用于 JSON 解析
type SysInfoResponseJSON struct {
Code int32 `json:"code"`
Message string `json:"message"`
Data struct {
StartTime string `json:"startTime"`
LocalIP string `json:"localIP"`
PublicIP string `json:"publicIP"`
Version string `json:"version"`
GoVersion string `json:"goVersion"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUs int32 `json:"cpus"`
Plugins []struct {
Name string `json:"name"`
PushAddr []string `json:"pushAddr"`
PlayAddr []string `json:"playAddr"`
Description map[string]string `json:"description"`
} `json:"plugins"`
} `json:"data"`
}
// 插件配置响应结构体
type PluginConfigResponse struct {
Code int32 `json:"code"`
Message string `json:"message"`
Data struct {
File string `json:"file"`
Modified string `json:"modified"`
Merged string `json:"merged"`
} `json:"data"`
}
// TCP 配置结构体
type TCPConfig struct {
ListenAddr string `yaml:"listenaddr"`
ListenAddrTLS string `yaml:"listenaddrtls"`
}
// 插件配置结构体
type PluginConfig struct {
TCP TCPConfig `yaml:"tcp"`
}
func (p *DebugPlugin) EnvCheck(w http.ResponseWriter, r *http.Request) {
// Get target URL from query parameter
targetURL := r.URL.Query().Get("target")
if targetURL == "" {
r.URL.Path = "/static/envcheck.html"
staticFSHandler.ServeHTTP(w, r)
return
}
// Create SSE connection
util.NewSSE(w, r.Context(), func(sse *util.SSE) {
// Function to send SSE messages
sendMessage := func(message string, msgType string) {
result := EnvCheckResult{
Message: message,
Type: msgType,
}
sse.WriteJSON(result)
}
// Parse target URL
_, err := url.Parse(targetURL)
if err != nil {
sendMessage(fmt.Sprintf("Invalid URL: %v", err), "error")
return
}
// Check if we can connect to the target server
sendMessage(fmt.Sprintf("Checking connection to %s...", targetURL), "info")
// Get system info from target server
resp, err := http.Get(fmt.Sprintf("%s/api/sysinfo", targetURL))
if err != nil {
sendMessage(fmt.Sprintf("Failed to connect to target server: %v", err), "error")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
sendMessage(fmt.Sprintf("Target server returned status code: %d", resp.StatusCode), "error")
return
}
// Read and parse system info
body, err := io.ReadAll(resp.Body)
if err != nil {
sendMessage(fmt.Sprintf("Failed to read response: %v", err), "error")
return
}
var sysInfoJSON SysInfoResponseJSON
if err := json.Unmarshal(body, &sysInfoJSON); err != nil {
sendMessage(fmt.Sprintf("Failed to parse system info: %v", err), "error")
return
}
// Convert JSON response to protobuf response
sysInfo := &pb.SysInfoResponse{
Code: sysInfoJSON.Code,
Message: sysInfoJSON.Message,
Data: &pb.SysInfoData{
LocalIP: sysInfoJSON.Data.LocalIP,
PublicIP: sysInfoJSON.Data.PublicIP,
Version: sysInfoJSON.Data.Version,
GoVersion: sysInfoJSON.Data.GoVersion,
Os: sysInfoJSON.Data.OS,
Arch: sysInfoJSON.Data.Arch,
Cpus: sysInfoJSON.Data.CPUs,
},
}
// Parse start time
if startTime, err := time.Parse(time.RFC3339, sysInfoJSON.Data.StartTime); err == nil {
sysInfo.Data.StartTime = timestamppb.New(startTime)
}
// Convert plugins
for _, pluginJSON := range sysInfoJSON.Data.Plugins {
plugin := &pb.PluginInfo{
Name: pluginJSON.Name,
PushAddr: pluginJSON.PushAddr,
PlayAddr: pluginJSON.PlayAddr,
Description: pluginJSON.Description,
}
sysInfo.Data.Plugins = append(sysInfo.Data.Plugins, plugin)
}
// Check each plugin's configuration
for _, plugin := range sysInfo.Data.Plugins {
// Get plugin configuration
configResp, err := http.Get(fmt.Sprintf("%s/api/config/get/%s", targetURL, plugin.Name))
if err != nil {
sendMessage(fmt.Sprintf("Failed to get configuration for plugin %s: %v", plugin.Name, err), "error")
continue
}
defer configResp.Body.Close()
if configResp.StatusCode != http.StatusOK {
sendMessage(fmt.Sprintf("Failed to get configuration for plugin %s: status code %d", plugin.Name, configResp.StatusCode), "error")
continue
}
var configRespJSON PluginConfigResponse
if err := json.NewDecoder(configResp.Body).Decode(&configRespJSON); err != nil {
sendMessage(fmt.Sprintf("Failed to parse configuration for plugin %s: %v", plugin.Name, err), "error")
continue
}
// Parse YAML configuration
var config PluginConfig
if err := yaml.Unmarshal([]byte(configRespJSON.Data.Merged), &config); err != nil {
sendMessage(fmt.Sprintf("Failed to parse YAML configuration for plugin %s: %v", plugin.Name, err), "error")
continue
}
// Check TCP configuration
if config.TCP.ListenAddr != "" {
host, port, err := net.SplitHostPort(config.TCP.ListenAddr)
if err != nil {
sendMessage(fmt.Sprintf("Invalid listenaddr format for plugin %s: %v", plugin.Name, err), "error")
} else {
sendMessage(fmt.Sprintf("Checking TCP listenaddr %s for plugin %s...", config.TCP.ListenAddr, plugin.Name), "info")
// Try to establish TCP connection
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%s", host, port), 5*time.Second)
if err != nil {
sendMessage(fmt.Sprintf("TCP listenaddr %s for plugin %s is not accessible: %v", config.TCP.ListenAddr, plugin.Name, err), "error")
} else {
conn.Close()
sendMessage(fmt.Sprintf("TCP listenaddr %s for plugin %s is accessible", config.TCP.ListenAddr, plugin.Name), "success")
}
}
}
if config.TCP.ListenAddrTLS != "" {
host, port, err := net.SplitHostPort(config.TCP.ListenAddrTLS)
if err != nil {
sendMessage(fmt.Sprintf("Invalid listenaddrtls format for plugin %s: %v", plugin.Name, err), "error")
} else {
sendMessage(fmt.Sprintf("Checking TCP TLS listenaddr %s for plugin %s...", config.TCP.ListenAddrTLS, plugin.Name), "info")
// Try to establish TCP connection
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%s", host, port), 5*time.Second)
if err != nil {
sendMessage(fmt.Sprintf("TCP TLS listenaddr %s for plugin %s is not accessible: %v", config.TCP.ListenAddrTLS, plugin.Name, err), "error")
} else {
conn.Close()
sendMessage(fmt.Sprintf("TCP TLS listenaddr %s for plugin %s is accessible", config.TCP.ListenAddrTLS, plugin.Name), "success")
}
}
}
}
sendMessage("Environment check completed", "complete")
})
}

View File

@@ -34,13 +34,13 @@ type DebugPlugin struct {
m7s.Plugin
ProfileDuration time.Duration `default:"10s" desc:"profile持续时间"`
Profile string `desc:"采集profile存储文件"`
ChartPeriod time.Duration `default:"1s" desc:"图表更新周期"`
Grfout string `default:"grf.out" desc:"grf输出文件"`
EnableChart bool `default:"true" desc:"是否启用图表功能"`
// 添加缓存字段
cpuProfileData *profile.Profile // 缓存 CPU Profile 数据
cpuProfileOnce sync.Once // 确保只采集一次
cpuProfileLock sync.Mutex // 保护缓存数据
chartServer server
}
type WriteToFile struct {
@@ -72,6 +72,10 @@ func (p *DebugPlugin) OnInit() error {
p.Info("cpu profile done")
}()
}
if p.EnableChart {
p.AddTask(&p.chartServer)
}
return nil
}
@@ -100,11 +104,11 @@ func (p *DebugPlugin) Charts_(w http.ResponseWriter, r *http.Request) {
}
func (p *DebugPlugin) Charts_data(w http.ResponseWriter, r *http.Request) {
dataHandler(w, r)
p.chartServer.dataHandler(w, r)
}
func (p *DebugPlugin) Charts_datafeed(w http.ResponseWriter, r *http.Request) {
s.dataFeedHandler(w, r)
p.chartServer.dataFeedHandler(w, r)
}
func (p *DebugPlugin) Grf(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Environment Check</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.input-group {
margin-bottom: 20px;
}
input[type="text"] {
padding: 8px;
width: 300px;
margin-right: 10px;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
#log {
background-color: #f8f9fa;
border: 1px solid #ddd;
padding: 10px;
height: 400px;
overflow-y: auto;
font-family: monospace;
white-space: pre-wrap;
}
.success {
color: #28a745;
}
.error {
color: #dc3545;
}
.info {
color: #17a2b8;
}
</style>
</head>
<body>
<div class="container">
<h1>Environment Check</h1>
<div class="input-group">
<input type="text" id="targetUrl" placeholder="Enter target URL (e.g., http://192.168.1.100:8080)">
<button onclick="startCheck()">Start Check</button>
</div>
<div id="log"></div>
</div>
<script>
function appendLog(message, type = 'info') {
const log = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = message;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function startCheck() {
const targetUrl = document.getElementById('targetUrl').value;
if (!targetUrl) {
appendLog('Please enter a target URL', 'error');
return;
}
// Clear previous log
document.getElementById('log').innerHTML = '';
appendLog('Starting environment check...');
// Create SSE connection
const eventSource = new EventSource(`/debug/envcheck?target=${encodeURIComponent(targetUrl)}`);
eventSource.onmessage = function (event) {
const data = JSON.parse(event.data);
appendLog(data.message, data.type);
if (data.type === 'complete') {
eventSource.close();
}
};
eventSource.onerror = function (error) {
appendLog('Connection error occurred', 'error');
eventSource.close();
};
}
</script>
</body>
</html>

View File

@@ -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)

View File

@@ -11,6 +11,8 @@ import (
"sync"
"time"
"gorm.io/gorm"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
"m7s.live/v5/pkg/util"
@@ -612,35 +614,47 @@ func (gb *GB28181Plugin) UpdateDevice(ctx context.Context, req *pb.Device) (*pb.
// 如果需要订阅目录,创建并启动目录订阅任务
if d.Online {
if d.CatalogSubscribeTask != nil {
if d.SubscribeCatalog > 0 {
if d.SubscribeCatalog > 0 {
if d.CatalogSubscribeTask != nil {
d.CatalogSubscribeTask.Ticker.Reset(time.Second * time.Duration(d.SubscribeCatalog))
d.CatalogSubscribeTask.Tick(nil)
} else {
catalogSubTask := NewCatalogSubscribeTask(d)
d.AddTask(catalogSubTask)
d.CatalogSubscribeTask.Tick(nil)
}
d.CatalogSubscribeTask.Tick(nil)
} else {
catalogSubTask := NewCatalogSubscribeTask(d)
d.AddTask(catalogSubTask)
d.CatalogSubscribeTask.Tick(nil)
if d.CatalogSubscribeTask != nil {
d.CatalogSubscribeTask.Stop(fmt.Errorf("catalog subscription disabled"))
}
}
if d.PositionSubscribeTask != nil {
if d.SubscribePosition > 0 {
if d.SubscribePosition > 0 {
if d.PositionSubscribeTask != nil {
d.PositionSubscribeTask.Ticker.Reset(time.Second * time.Duration(d.SubscribePosition))
d.PositionSubscribeTask.Tick(nil)
} else {
positionSubTask := NewPositionSubscribeTask(d)
d.AddTask(positionSubTask)
d.PositionSubscribeTask.Tick(nil)
}
d.PositionSubscribeTask.Tick(nil)
} else {
positionSubTask := NewPositionSubscribeTask(d)
d.AddTask(positionSubTask)
d.PositionSubscribeTask.Tick(nil)
if d.PositionSubscribeTask != nil {
d.PositionSubscribeTask.Stop(fmt.Errorf("position subscription disabled"))
}
}
if d.AlarmSubscribeTask != nil {
if d.SubscribeAlarm > 0 {
if d.SubscribeAlarm > 0 {
if d.AlarmSubscribeTask != nil {
d.AlarmSubscribeTask.Ticker.Reset(time.Second * time.Duration(d.SubscribeAlarm))
d.AlarmSubscribeTask.Tick(nil)
} else {
alarmSubTask := NewAlarmSubscribeTask(d)
d.AddTask(alarmSubTask)
d.AlarmSubscribeTask.Tick(nil)
}
d.AlarmSubscribeTask.Tick(nil)
} else {
alarmSubTask := NewAlarmSubscribeTask(d)
d.AddTask(alarmSubTask)
d.AlarmSubscribeTask.Tick(nil)
if d.AlarmSubscribeTask != nil {
d.AlarmSubscribeTask.Stop(fmt.Errorf("alarm subscription disabled"))
}
}
}
} else {
@@ -1272,32 +1286,36 @@ func (gb *GB28181Plugin) TestSip(ctx context.Context, req *pb.TestSipRequest) (*
// 创建一个临时设备用于测试
device := &Device{
DeviceId: "34020000002000000001",
SipIp: "192.168.1.17",
SipIp: "192.168.1.106",
Port: 5060,
IP: "192.168.1.102",
StreamMode: "TCP-PASSIVE",
}
//From: <sip:41010500002000000001@4101050000>;tag=4183af2ecc934758ad393dfe588f2dfd
// 初始化设备的SIP相关字段
device.fromHDR = sip.FromHeader{
Address: sip.Uri{
User: gb.Serial,
Host: gb.Realm,
User: "41010500002000000001",
Host: "4101050000",
},
Params: sip.NewParams(),
}
device.fromHDR.Params.Add("tag", sip.GenerateTagN(16))
device.fromHDR.Params.Add("tag", "4183af2ecc934758ad393dfe588f2dfd")
//Contact: <sip:41010500002000000001@192.168.1.106:5060>
device.contactHDR = sip.ContactHeader{
Address: sip.Uri{
User: gb.Serial,
Host: device.SipIp,
Port: device.Port,
User: "41010500002000000001",
Host: "192.168.1.106",
Port: 5060,
},
}
//Request-Line: INVITE sip:34020000001320000006@192.168.1.102:5060 SIP/2.0
// Method: INVITE
// Request-URI: sip:34020000001320000006@192.168.1.102:5060
// [Resent Packet: False]
// 初始化SIP客户端
device.client, _ = sipgo.NewClient(gb.ua, sipgo.WithClientLogger(zerolog.New(os.Stdout)), sipgo.WithClientHostname(device.SipIp))
device.client, _ = sipgo.NewClient(gb.ua, sipgo.WithClientLogger(zerolog.New(os.Stdout)), sipgo.WithClientHostname("192.168.1.106"))
if device.client == nil {
resp.Code = 500
resp.Message = "failed to create sip client"
@@ -1322,11 +1340,11 @@ func (gb *GB28181Plugin) TestSip(ctx context.Context, req *pb.TestSipRequest) (*
// 构建SDP消息体
sdpInfo := []string{
"v=0",
fmt.Sprintf("o=%s 0 0 IN IP4 %s", "34020000001320000004", device.SipIp),
fmt.Sprintf("o=%s 0 0 IN IP4 %s", "34020000001320000102", "192.168.1.106"),
"s=Play",
"c=IN IP4 " + device.SipIp,
"c=IN IP4 192.168.1.106",
"t=0 0",
"m=video 43970 TCP/RTP/AVP 96 97 98 99",
"m=video 40940 TCP/RTP/AVP 96 97 98 99",
"a=recvonly",
"a=rtpmap:96 PS/90000",
"a=rtpmap:98 H264/90000",
@@ -1334,36 +1352,40 @@ func (gb *GB28181Plugin) TestSip(ctx context.Context, req *pb.TestSipRequest) (*
"a=rtpmap:99 H265/90000",
"a=setup:passive",
"a=connection:new",
"y=0200005507",
"y=0105006213",
}
// 设置必需的头部
contentTypeHeader := sip.ContentTypeHeader("APPLICATION/SDP")
subjectHeader := sip.NewHeader("Subject", "34020000001320000006:0200005507,34020000002000000001:0")
//Subject: 34020000001320000006:0105006213,41010500002000000001:0
subjectHeader := sip.NewHeader("Subject", "34020000001320000006:0105006213,41010500002000000001:0")
//To: <sip:34020000001320000006@192.168.1.102:5060>
toHeader := sip.ToHeader{
Address: sip.Uri{
User: "34020000001320000006",
Host: device.IP,
Port: device.Port,
Host: "192.168.1.102",
Port: 5060,
},
}
userAgentHeader := sip.NewHeader("User-Agent", "WVP-Pro v2.7.3.20241218")
//Via: SIP/2.0/UDP 192.168.1.106:5060;branch=z9hG4bK9279674404;rport
viaHeader := sip.ViaHeader{
ProtocolName: "SIP",
ProtocolVersion: "2.0",
Transport: "UDP",
Host: device.SipIp,
Port: device.Port,
Host: "192.168.1.106",
Port: 5060,
Params: sip.HeaderParams(sip.NewParams()),
}
viaHeader.Params.Add("branch", sip.GenerateBranchN(16)).Add("rport", "")
viaHeader.Params.Add("branch", "z9hG4bK9279674404").Add("rport", "")
csqHeader := sip.CSeqHeader{
SeqNo: 13,
SeqNo: 3,
MethodName: "INVITE",
}
maxforward := sip.MaxForwardsHeader(70)
contentLengthHeader := sip.ContentLengthHeader(286)
//contentLengthHeader := sip.ContentLengthHeader(288)
request.AppendHeader(&contentTypeHeader)
request.AppendHeader(subjectHeader)
request.AppendHeader(&toHeader)
@@ -1375,7 +1397,7 @@ func (gb *GB28181Plugin) TestSip(ctx context.Context, req *pb.TestSipRequest) (*
// 创建会话并发送请求
dialogClientCache := sipgo.NewDialogClientCache(device.client, device.contactHDR)
session, err := dialogClientCache.Invite(gb, recipient, request.Body(), &csqHeader, &device.fromHDR, &toHeader, &viaHeader, &maxforward, userAgentHeader, &device.contactHDR, subjectHeader, &contentTypeHeader, &contentLengthHeader)
session, err := dialogClientCache.Invite(gb, recipient, request.Body(), &csqHeader, &device.fromHDR, &toHeader, &maxforward, userAgentHeader, &device.contactHDR, subjectHeader, &contentTypeHeader)
if err != nil {
resp.Code = 500
resp.Message = fmt.Sprintf("发送INVITE请求失败: %v", err)
@@ -2468,12 +2490,9 @@ func (gb *GB28181Plugin) PlaybackPause(ctx context.Context, req *pb.PlaybackPaus
resp.Message = fmt.Sprintf("发送暂停请求失败: %v", err)
return resp, nil
}
gb.Server.Streams.Call(func() error {
if s, ok := gb.Server.Streams.Get(req.StreamPath); ok {
s.Pause()
}
return nil
})
if s, ok := gb.Server.Streams.SafeGet(req.StreamPath); ok {
s.Pause()
}
gb.Info("暂停回放",
"streampath", req.StreamPath)
@@ -2522,12 +2541,9 @@ func (gb *GB28181Plugin) PlaybackResume(ctx context.Context, req *pb.PlaybackRes
resp.Message = fmt.Sprintf("发送恢复请求失败: %v", err)
return resp, nil
}
gb.Server.Streams.Call(func() error {
if s, ok := gb.Server.Streams.Get(req.StreamPath); ok {
s.Resume()
}
return nil
})
if s, ok := gb.Server.Streams.SafeGet(req.StreamPath); ok {
s.Resume()
}
gb.Info("恢复回放",
"streampath", req.StreamPath)
@@ -2595,14 +2611,11 @@ func (gb *GB28181Plugin) PlaybackSpeed(ctx context.Context, req *pb.PlaybackSpee
// 发送请求
_, err := dialog.session.TransactionRequest(ctx, request)
gb.Server.Streams.Call(func() error {
if s, ok := gb.Server.Streams.Get(req.StreamPath); ok {
s.Speed = float64(req.Speed)
s.Scale = float64(req.Speed)
s.Info("set stream speed", "speed", req.Speed)
}
return nil
})
if s, ok := gb.Server.Streams.SafeGet(req.StreamPath); ok {
s.Speed = float64(req.Speed)
s.Scale = float64(req.Speed)
s.Info("set stream speed", "speed", req.Speed)
}
if err != nil {
resp.Code = 500
resp.Message = fmt.Sprintf("发送倍速请求失败: %v", err)
@@ -2826,62 +2839,54 @@ func (gb *GB28181Plugin) RemoveDevice(ctx context.Context, req *pb.RemoveDeviceR
return resp, nil
}
// 检查数据库连接
if gb.DB == nil {
resp.Code = 500
resp.Message = "数据库未初始化"
return resp, nil
}
// 开启事务
tx := gb.DB.Begin()
// 先从数据库中查找设备
var dbDevice Device
if err := tx.Where(&Device{DeviceId: req.Id}).First(&dbDevice).Error; err != nil {
tx.Rollback()
resp.Code = 404
resp.Message = fmt.Sprintf("设备不存在: %v", err)
return resp, nil
}
// 使用数据库中的 DeviceId 从内存中查找设备
if device, ok := gb.devices.Get(dbDevice.DeviceId); ok {
if device, ok := gb.devices.Get(req.Id); ok {
device.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
device.channels.Range(func(channel *Channel) bool {
channel.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
return true
})
// 停止设备相关任务
device.Stop(fmt.Errorf("device removed"))
device.WaitStopped()
// device.Stop() 会调用 Dispose(),其中已包含从 gb.devices 中移除设备的逻辑
// 开启数据库事务
tx := gb.DB.Begin()
if tx.Error != nil {
resp.Code = 500
resp.Message = "开启事务失败"
return resp, tx.Error
}
// 删除设备
if err := tx.Delete(&Device{DeviceId: req.Id}).Error; err != nil {
tx.Rollback()
resp.Code = 500
resp.Message = "删除设备失败"
return resp, err
}
// 删除设备关联的通道
if err := tx.Delete(&gb28181.DeviceChannel{DeviceID: req.Id}).Error; err != nil {
tx.Rollback()
resp.Code = 500
resp.Message = "删除设备通道失败"
return resp, err
}
// 提交事务
if err := tx.Commit().Error; err != nil {
tx.Rollback()
resp.Code = 500
resp.Message = "提交事务失败"
return resp, err
}
resp.Code = 200
resp.Message = "设备删除成功"
}
// 删除设备关联的所有通道
if err := tx.Where(&gb28181.DeviceChannel{DeviceID: dbDevice.DeviceId}).Delete(&gb28181.DeviceChannel{}).Error; err != nil {
tx.Rollback()
resp.Code = 500
resp.Message = fmt.Sprintf("删除设备通道失败: %v", err)
return resp, nil
}
// 删除设备
if err := tx.Delete(&dbDevice).Error; err != nil {
tx.Rollback()
resp.Code = 500
resp.Message = fmt.Sprintf("删除设备失败: %v", err)
return resp, nil
}
// 提交事务
if err := tx.Commit().Error; err != nil {
resp.Code = 500
resp.Message = fmt.Sprintf("提交事务失败: %v", err)
return resp, nil
}
gb.Info("删除设备成功",
"deviceId", dbDevice.DeviceId,
"deviceName", dbDevice.Name)
resp.Code = 0
resp.Message = "success"
return resp, nil
}

View File

@@ -92,18 +92,16 @@ func (d *Device) TableName() string {
func (d *Device) Dispose() {
if d.plugin.DB != nil {
d.plugin.DB.Save(d)
if d.channels.Length > 0 {
d.channels.Range(func(channel *Channel) bool {
d.plugin.DB.Save(channel.DeviceChannel)
//d.plugin.DB.Model(&gb28181.DeviceChannel{}).Where("device_id = ? AND device_db_id = ?", channel.DeviceId, d.ID).Updates(channel.DeviceChannel)
return true
})
} else {
// 如果没有通道,则直接更新通道状态为 OFF
d.plugin.DB.Model(&gb28181.DeviceChannel{}).Where("device_id = ?", d.ID).Update("status", "OFF")
}
d.plugin.DB.Save(d)
}
d.plugin.devices.RemoveByKey(d.DeviceId)
}
func (d *Device) GetKey() string {
@@ -140,7 +138,7 @@ func (r *CatalogRequest) IsComplete(channelsLength int) bool {
}
func (d *Device) onMessage(req *sip.Request, tx sip.ServerTransaction, msg *gb28181.Message) (err error) {
d.plugin.Debug("into onMessage,deviceid is ", d.DeviceId)
d.plugin.Trace("into onMessage,deviceid is ", d.DeviceId)
source := req.Source()
hostname, portStr, _ := net.SplitHostPort(source)
port, _ := strconv.Atoi(portStr)
@@ -161,7 +159,7 @@ func (d *Device) onMessage(req *sip.Request, tx sip.ServerTransaction, msg *gb28
case "Keepalive":
d.KeepaliveInterval = int(time.Since(d.KeepaliveTime).Seconds())
d.KeepaliveTime = time.Now()
d.Debug("into keeplive,deviceid is ", d.DeviceId, "d.KeepaliveTime is", d.KeepaliveTime)
d.Trace("into keeplive,deviceid is ", d.DeviceId, "d.KeepaliveTime is", d.KeepaliveTime)
if d.plugin.DB != nil {
if err := d.plugin.DB.Model(d).Updates(map[string]interface{}{
"keepalive_interval": d.KeepaliveInterval,
@@ -191,7 +189,7 @@ func (d *Device) onMessage(req *sip.Request, tx sip.ServerTransaction, msg *gb28
if d.plugin.DB != nil {
// 如果是第一个响应,先清空现有通道
if isFirst {
d.Debug("清空现有通道", "deviceId", d.DeviceId)
d.Trace("清空现有通道", "deviceId", d.DeviceId)
if err := d.plugin.DB.Where("device_id = ?", d.DeviceId).Delete(&gb28181.DeviceChannel{}).Error; err != nil {
d.Error("删除通道失败", "error", err, "deviceId", d.DeviceId)
}
@@ -215,7 +213,7 @@ func (d *Device) onMessage(req *sip.Request, tx sip.ServerTransaction, msg *gb28
// 更新当前设备的通道数
d.ChannelCount = msg.SumNum
d.UpdateTime = time.Now()
d.Debug("save channel", "deviceid", d.DeviceId, "channels count", d.channels.Length)
d.Trace("save channel", "deviceid", d.DeviceId, "channels count", d.channels.Length)
if err := d.plugin.DB.Model(d).Updates(map[string]interface{}{
"channel_count": d.ChannelCount,
"update_time": d.UpdateTime,
@@ -325,7 +323,10 @@ func (d *Device) onMessage(req *sip.Request, tx sip.ServerTransaction, msg *gb28
}
case "DeviceInfo":
// 主设备信息
d.Name = msg.DeviceName
d.Info("DeviceInfo message", "body", req.Body(), "d.Name", d.Name, "d.DeviceId", d.DeviceId, "msg.DeviceName", msg.DeviceName)
if d.Name == "" && msg.DeviceName != "" {
d.Name = msg.DeviceName
}
d.Manufacturer = msg.Manufacturer
d.Model = msg.Model
d.Firmware = msg.Firmware
@@ -400,12 +401,12 @@ func (d *Device) onMessage(req *sip.Request, tx sip.ServerTransaction, msg *gb28
func (d *Device) send(req *sip.Request) (*sip.Response, error) {
d.SN++
d.Debug("send", "req", req.String())
d.Trace("send", "req", req.String())
return d.client.Do(context.Background(), req)
}
func (d *Device) Go() (err error) {
d.Debug("into device.Go,deviceid is ", d.DeviceId)
d.Trace("into device.Go,deviceid is ", d.DeviceId)
var response *sip.Response
// 初始化catalogReqs
@@ -423,7 +424,7 @@ func (d *Device) Go() (err error) {
if err != nil {
d.Error("catalog", "err", err)
} else {
d.Debug("catalog", "response", response.String())
d.Trace("catalog", "response", response.String())
}
// 创建并启动目录订阅任务
@@ -450,7 +451,7 @@ func (d *Device) Go() (err error) {
select {
case <-d.Done():
case <-keepLiveTick.C:
d.Debug("keepLiveTick,deviceid is", d.DeviceId, "d.KeepaliveTime is ", d.KeepaliveTime)
d.Trace("keepLiveTick,deviceid is", d.DeviceId, "d.KeepaliveTime is ", d.KeepaliveTime)
if timeDiff := time.Since(d.KeepaliveTime); timeDiff > time.Duration(3*keepaliveSeconds)*time.Second {
d.Online = false
d.Status = DeviceOfflineStatus
@@ -471,7 +472,7 @@ func (d *Device) Go() (err error) {
if err != nil {
d.Error("catalog", "err", err)
} else {
d.Debug("catalogTick", "response", response.String())
d.Trace("catalogTick", "response", response.String())
}
//case event := <-d.eventChan:
// d.Debug("eventChan", "event", event)

View File

@@ -44,7 +44,11 @@ type Dialog struct {
}
func (d *Dialog) GetCallID() string {
return d.session.InviteRequest.CallID().Value()
if d.session != nil && d.session.InviteRequest != nil && d.session.InviteRequest.CallID() != nil {
return d.session.InviteRequest.CallID().Value()
} else {
return ""
}
}
func (d *Dialog) GetPullJob() *m7s.PullJob {
@@ -115,9 +119,9 @@ func (d *Dialog) Start() (err error) {
}
// 非直播模式下添加u行保持在s=和c=之间
//if !d.IsLive() {
sdpInfo = append(sdpInfo, fmt.Sprintf("u=%s:0", channelId))
//}
if !d.IsLive() {
sdpInfo = append(sdpInfo, fmt.Sprintf("u=%s:0", channelId))
}
// 添加c行
sdpInfo = append(sdpInfo, "c=IN IP4 "+device.MediaIp)
@@ -126,7 +130,7 @@ func (d *Dialog) Start() (err error) {
if !d.IsLive() {
startTime, endTime, err := util.TimeRangeQueryParse(url.Values{"start": []string{d.start}, "end": []string{d.end}})
if err != nil {
d.Stop(errors.New("parse end time error"))
return errors.New("parse end time error")
}
sdpInfo = append(sdpInfo, fmt.Sprintf("t=%d %d", startTime.Unix(), endTime.Unix()))
} else {
@@ -145,10 +149,12 @@ func (d *Dialog) Start() (err error) {
}
sdpInfo = append(sdpInfo, mediaLine)
sdpInfo = append(sdpInfo, "a=recvonly")
if d.stream != "" {
sdpInfo = append(sdpInfo, "a="+d.stream)
}
sdpInfo = append(sdpInfo, "a=rtpmap:96 PS/90000")
//根据传输模式添加 setup 和 connection 属性
switch strings.ToUpper(device.StreamMode) {
@@ -163,14 +169,13 @@ func (d *Dialog) Start() (err error) {
"a=connection:new",
)
case "UDP":
d.Stop(errors.New("do not support udp mode"))
return errors.New("do not support udp mode")
default:
sdpInfo = append(sdpInfo,
"a=setup:passive",
"a=connection:new",
)
}
sdpInfo = append(sdpInfo, "a=rtpmap:96 PS/90000")
// 添加 SSRC
sdpInfo = append(sdpInfo, fmt.Sprintf("y=%s", ssrc))
@@ -238,21 +243,20 @@ func (d *Dialog) Start() (err error) {
//}
// 最后添加Content-Length头部
if err != nil {
d.gb.Error("invite error", err)
return errors.New("dialog invite error" + err.Error())
}
return
}
func (d *Dialog) Run() (err error) {
d.Channel.Info("before WaitAnswer")
d.gb.Info("before WaitAnswer")
err = d.session.WaitAnswer(d.gb, sipgo.AnswerOptions{})
d.Channel.Info("after WaitAnswer")
d.gb.Error(" WaitAnswer error", err)
d.gb.Info("after WaitAnswer")
if err != nil {
return
return errors.New("wait answer error" + err.Error())
}
inviteResponseBody := string(d.session.InviteResponse.Body())
d.Channel.Info("inviteResponse", "body", inviteResponseBody)
d.gb.Info("inviteResponse", "body", inviteResponseBody)
ds := strings.Split(inviteResponseBody, "\r\n")
for _, l := range ds {
if ls := strings.Split(l, "="); len(ls) > 1 {
@@ -299,6 +303,10 @@ func (d *Dialog) Run() (err error) {
}
pub.Receiver.StreamMode = d.StreamMode
d.AddTask(&pub.Receiver)
startResult := pub.Receiver.WaitStarted()
if startResult != nil {
return fmt.Errorf("pub.Receiver.WaitStarted %s", startResult)
}
pub.Demux()
return
}

View File

@@ -5,12 +5,15 @@ import (
"fmt"
"net/http"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"m7s.live/v5/pkg"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
"github.com/rs/zerolog"
@@ -129,6 +132,9 @@ func (gb *GB28181Plugin) initDatabase() error {
}
func (gb *GB28181Plugin) OnInit() (err error) {
if gb.DB == nil {
return pkg.ErrNoDB
}
gb.Info("GB28181 initing", gb.Platforms)
gb.AddTask(&gb.deviceManager)
logger := zerolog.New(os.Stdout)
@@ -377,45 +383,48 @@ func (gb *GB28181Plugin) checkPlatform() {
// 遍历所有平台进行初始化和注册
for _, platformModel := range platformModels {
// 创建Platform实例
platform := NewPlatform(platformModel, gb, true)
if platformModel.Enable {
if platformModel.PlatformChannels != nil && len(platformModel.PlatformChannels) > 0 {
for i := range platformModel.PlatformChannels {
channelDbId := platformModel.PlatformChannels[i].ChannelDBID
if channelDbId != "" {
if channel, ok := gb.channels.Get(channelDbId); ok {
platform.channels.Set(channel)
}
}
}
} else {
// 查询通道列表
var channels []gb28181.DeviceChannel
if gb.DB != nil {
if err := gb.DB.Table("gb28181_channel gc").
Select(`gc.*`).
Joins("left join gb28181_platform_channel gpc on gc.id=gpc.channel_db_id").
Where("gpc.platform_server_gb_id = ? and gc.status='ON'", platformModel.ServerGBID).
Find(&channels).Error; err != nil {
gb.Error("<UNK>", "error", err.Error())
}
if channels != nil && len(channels) > 0 {
for i := range channels {
if channel, ok := gb.channels.Get(channels[i].ID); ok {
// 创建Platform实例
platform := NewPlatform(platformModel, gb, true)
if platformModel.PlatformChannels != nil && len(platformModel.PlatformChannels) > 0 {
for i := range platformModel.PlatformChannels {
channelDbId := platformModel.PlatformChannels[i].ChannelDBID
if channelDbId != "" {
if channel, ok := gb.channels.Get(channelDbId); ok {
platform.channels.Set(channel)
}
}
}
} else {
// 查询通道列表
var channels []gb28181.DeviceChannel
if gb.DB != nil {
if err := gb.DB.Table("gb28181_channel gc").
Select(`gc.*`).
Joins("left join gb28181_platform_channel gpc on gc.id=gpc.channel_db_id").
Where("gpc.platform_server_gb_id = ? and gc.status='ON'", platformModel.ServerGBID).
Find(&channels).Error; err != nil {
gb.Error("<UNK>", "error", err.Error())
}
if channels != nil && len(channels) > 0 {
for i := range channels {
if channel, ok := gb.channels.Get(channels[i].ID); ok {
platform.channels.Set(channel)
}
}
}
}
}
//go platform.Unregister()
//if err != nil {
// gb.Error("unregister err ", err)
//}
// 添加到任务系统
gb.AddTask(platform)
gb.Info("平台初始化完成", "ID", platformModel.ServerGBID, "Name", platformModel.Name)
}
//go platform.Unregister()
//if err != nil {
// gb.Error("unregister err ", err)
//}
// 添加到任务系统
gb.AddTask(platform)
gb.Info("平台初始化完成", "ID", platformModel.ServerGBID, "Name", platformModel.Name)
}
}
@@ -429,9 +438,22 @@ 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,
@@ -494,7 +516,6 @@ func (gb *GB28181Plugin) OnMessage(req *sip.Request, tx sip.ServerTransaction) {
}
}
gb.Debug("00000000000001,deviceid is ", id)
// 如果设备和平台都存在,通过源地址判断真实来源
if d != nil && d.Online && p != nil {
source := req.Source()
@@ -506,7 +527,6 @@ func (gb *GB28181Plugin) OnMessage(req *sip.Request, tx sip.ServerTransaction) {
d = nil
}
}
gb.Debug("00000000000002,deviceid is ", id)
// 如果既不是设备也不是平台返回404
if (d == nil && p == nil) || (d != nil && !d.Online) {
@@ -519,7 +539,6 @@ func (gb *GB28181Plugin) OnMessage(req *sip.Request, tx sip.ServerTransaction) {
gb.Debug("after on message respond")
return
}
gb.Debug("00000000000003,deviceid is ", id)
// 根据来源调用不同的处理方法
if d != nil && d.Online {

View File

@@ -335,6 +335,8 @@ func (p *Platform) Register(isUnregister bool) error {
newReq := req.Clone()
newReq.RemoveHeader("Via") // 必须由传输层重新生成
newReq.AppendHeader(sip.NewHeader("Authorization", cred.String()))
newReq.CSeq().SeqNo = uint32(p.SN) // 更新CSeq序号
p.SN++
// 发送认证请求
tx, err = p.Client.TransactionRequest(p.ctx, newReq, sipgo.ClientRequestAddVia)

View File

@@ -36,6 +36,14 @@ type registerHandlerTask struct {
tx sip.ServerTransaction
}
// getDevicePassword 获取设备密码
func (task *registerHandlerTask) getDevicePassword(device *Device) string {
if device != nil && device.Password != "" {
return device.Password
}
return task.gb.Password
}
func (task *registerHandlerTask) Run() (err error) {
var password string
var device *Device
@@ -47,23 +55,25 @@ func (task *registerHandlerTask) Run() (err error) {
}
isUnregister := false
deviceid := from.Address.User
if devicetmp, ok := task.gb.devices.Get(deviceid); ok {
device = devicetmp
if existingDevice, exists := task.gb.devices.Get(deviceid); exists && existingDevice != nil {
device = existingDevice
recover = true
} else {
if err := task.gb.DB.First(&device, Device{DeviceId: deviceid}).Error; err == nil {
if device.Password != "" {
password = device.Password
} else if task.gb.Password != "" {
password = task.gb.Password
}
} else {
if !errors.Is(err, gorm.ErrRecordNotFound) {
task.gb.Error("OnRegister", "error", err)
// 尝试从数据库加载设备信息
device = &Device{DeviceId: deviceid}
if task.gb.DB != nil {
if err := task.gb.DB.First(device, Device{DeviceId: deviceid}).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
task.gb.Error("OnRegister", "error", err)
}
}
}
}
// 获取设备密码
password = task.getDevicePassword(device)
exp := task.req.GetHeader("Expires")
if exp == nil {
task.gb.Error("OnRegister", "error", "no expires")
@@ -136,7 +146,7 @@ func (task *registerHandlerTask) Run() (err error) {
Method: "REGISTER",
URI: cred.URI,
Username: deviceid,
Password: task.gb.Password,
Password: password,
Cnonce: cred.Cnonce,
Count: int(cred.Nc),
}
@@ -222,11 +232,14 @@ func (task *registerHandlerTask) Run() (err error) {
device.Status = DeviceOnlineStatus
task.RecoverDevice(device, task.req)
} else {
device := &Device{
DeviceId: deviceid,
var newDevice *Device
if device == nil {
newDevice = &Device{DeviceId: deviceid}
} else {
newDevice = device
}
task.gb.Info("into StoreDevice", "deviceId", from)
task.StoreDevice(deviceid, task.req, device)
task.StoreDevice(deviceid, task.req, newDevice)
}
}
task.gb.Info("registerHandlerTask start end", "deviceid", deviceid, "expires", expSec, "isUnregister", isUnregister)

View File

@@ -104,9 +104,8 @@ func (config *HLSPlugin) vod(w http.ResponseWriter, r *http.Request) {
playlist.Init()
for _, record := range records {
duration := record.EndTime.Sub(record.StartTime).Seconds()
playlist.WriteInf(hls.PlaylistInf{
Duration: duration,
Duration: float64(record.Duration) / 1000,
URL: fmt.Sprintf("/mp4/download/%s.fmp4?id=%d", streamPath, record.ID),
Title: record.StartTime.Format(time.RFC3339),
})
@@ -128,9 +127,8 @@ func (config *HLSPlugin) vod(w http.ResponseWriter, r *http.Request) {
playlist.Init()
for _, record := range records {
duration := record.EndTime.Sub(record.StartTime).Seconds()
playlist.WriteInf(hls.PlaylistInf{
Duration: duration,
Duration: float64(record.Duration) / 1000,
URL: record.FilePath,
})
}

View File

@@ -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)
}
// 创建第一个片段记录

View File

@@ -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)
@@ -458,26 +457,34 @@ func (p *MP4Plugin) StartRecord(ctx context.Context, req *mp4pb.ReqStartRecord)
filePath = req.FilePath
}
res = &mp4pb.ResponseStartRecord{}
p.Server.Records.Call(func() error {
_, recordExists = p.Server.Records.Find(func(job *m7s.RecordJob) bool {
return job.StreamPath == req.StreamPath && job.RecConf.FilePath == req.FilePath
})
return nil
_, recordExists = p.Server.Records.SafeFind(func(job *m7s.RecordJob) bool {
return job.StreamPath == req.StreamPath && job.RecConf.FilePath == req.FilePath
})
if recordExists {
err = pkg.ErrRecordExists
return
}
recordConf := config.Record{
Append: false,
Fragment: fragment,
FilePath: filePath,
}
if stream, ok := p.Server.Streams.SafeGet(req.StreamPath); ok {
recordConf := config.Record{
Append: false,
Fragment: fragment,
FilePath: filePath,
}
job := p.Record(stream, recordConf, nil)
res.Data = uint64(uintptr(unsafe.Pointer(job.GetTask())))
} else {
err = pkg.ErrNotFound
sub, err := p.Subscribe(ctx, req.StreamPath)
if err == nil && sub != nil {
if stream, ok := p.Server.Streams.SafeGet(req.StreamPath); ok {
job := p.Record(stream, recordConf, nil)
res.Data = uint64(uintptr(unsafe.Pointer(job.GetTask())))
} else {
err = pkg.ErrNotFound
}
} else {
err = pkg.ErrNotFound
}
}
return
}
@@ -485,19 +492,16 @@ func (p *MP4Plugin) StartRecord(ctx context.Context, req *mp4pb.ReqStartRecord)
func (p *MP4Plugin) StopRecord(ctx context.Context, req *mp4pb.ReqStopRecord) (res *mp4pb.ResponseStopRecord, err error) {
res = &mp4pb.ResponseStopRecord{}
var recordJob *m7s.RecordJob
p.Server.Records.Call(func() error {
recordJob, _ = p.Server.Records.Find(func(job *m7s.RecordJob) bool {
return job.StreamPath == req.StreamPath
})
if recordJob != nil {
t := recordJob.GetTask()
if t != nil {
res.Data = uint64(uintptr(unsafe.Pointer(t)))
t.Stop(task.ErrStopByUser)
}
}
return nil
recordJob, _ = p.Server.Records.SafeFind(func(job *m7s.RecordJob) bool {
return job.StreamPath == req.StreamPath
})
if recordJob != nil {
t := recordJob.GetTask()
if t != nil {
res.Data = uint64(uintptr(unsafe.Pointer(t)))
t.Stop(task.ErrStopByUser)
}
}
return
}
@@ -519,11 +523,8 @@ func (p *MP4Plugin) EventStart(ctx context.Context, req *mp4pb.ReqEventRecord) (
}
//recorder := p.Meta.Recorder(config.Record{})
var tmpJob *m7s.RecordJob
p.Server.Records.Call(func() error {
tmpJob, _ = p.Server.Records.Find(func(job *m7s.RecordJob) bool {
return job.StreamPath == req.StreamPath
})
return nil
tmpJob, _ = p.Server.Records.SafeFind(func(job *m7s.RecordJob) bool {
return job.StreamPath == req.StreamPath
})
if tmpJob == nil { //为空表示没有正在进行的录制,也就是没有自动录像,则进行正常的事件录像
if stream, ok := p.Server.Streams.SafeGet(req.StreamPath); ok {
@@ -531,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,
},
})
}
}
}

View File

@@ -37,9 +37,27 @@
| | | | | | sbgp | | sample-to-group |
| | | | | | sgpd | | sample group description |
| | | | | | subs | | sub-sample information |
| | | udta | | | | | user-data (track level)<br>轨道级别的用户数据容器 |
| | | | cprt | | | | copyright etc.<br>版权信息 |
| | | | titl | | | | title<br>标题 |
| | | | auth | | | | author<br>作者 |
| | mvex | | | | | | movie extends box |
| | | mehd | | | | | movie extends header box |
| | | trex | | | | ✓ | track extends defaults |
| | udta | | | | | | user-data (movie level)<br>电影级别的用户数据容器 |
| | | cprt | | | | | copyright etc.<br>版权信息 |
| | | titl | | | | | title<br>标题 |
| | | auth | | | | | author<br>作者 |
| | | albm | | | | | album<br>专辑 |
| | | yrrc | | | | | year<br>年份 |
| | | rtng | | | | | rating<br>评级 |
| | | clsf | | | | | classification<br>分类 |
| | | kywd | | | | | keywords<br>关键词 |
| | | loci | | | | | location information<br>位置信息 |
| | | dscp | | | | | description<br>描述 |
| | | perf | | | | | performer<br>表演者 |
| | | gnre | | | | | genre<br>类型 |
| | | meta | | | | | metadata atom<br>元数据原子 |
| | ipmc | | | | | | IPMP Control Box |
| moof | | | | | | | movie fragment |
| | mfhd | | | | | ✓ | movie fragment header |
@@ -54,8 +72,10 @@
| mdat | | | | | | | media data container |
| free | | | | | | | free space |
| skip | | | | | | | free space |
| | udta | | | | | | user-data |
| | | cprt | | | | | copyright etc. |
| udta | | | | | | | user-data (file level)<br>文件级别的用户数据容器 |
| | cprt | | | | | | copyright etc.<br>版权信息 |
| | titl | | | | | | title<br>标题 |
| | auth | | | | | | author<br>作者 |
| meta | | | | | | | metadata |
| | hdlr | | | | | ✓ | handler, declares the metadata (handler) type |
| | dinf | | | | | | data information box, container |

View File

@@ -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)

View File

@@ -343,7 +343,25 @@ var (
TypeAUXV = f("auxv")
TypeHINT = f("hint")
TypeUDTA = f("udta")
TypeM7SP = f("m7sp") // Custom box type for M7S StreamPath
// Common metadata box types
TypeTITL = f("©nam") // Title
TypeART = f("©ART") // Artist/Author
TypeALB = f("©alb") // Album
TypeDAY = f("©day") // Date/Year
TypeCMT = f("©cmt") // Comment/Description
TypeGEN = f("©gen") // Genre
TypeCPRT = f("cprt") // Copyright
TypeENCO = f("©too") // Encoder/Tool
TypeWRT = f("©wrt") // Writer/Composer
TypePRD = f("©prd") // Producer
TypePRF = f("©prf") // Performer
TypeGRP = f("©grp") // Grouping
TypeLYR = f("©lyr") // Lyrics
TypeKEYW = f("keyw") // Keywords
TypeLOCI = f("loci") // Location Information
TypeRTNG = f("rtng") // Rating
TypeMETA_CUST = f("----") // Custom metadata (iTunes-style)
)
// aligned(8) class Box (unsigned int(32) boxtype, optional unsigned int(8)[16] extended_type) {

View File

@@ -0,0 +1,334 @@
package box
import (
"encoding/binary"
"io"
"time"
)
// Metadata holds various metadata information for MP4
type Metadata struct {
Title string // 标题
Artist string // 艺术家/作者
Album string // 专辑
Date string // 日期
Comment string // 注释/描述
Genre string // 类型
Copyright string // 版权信息
Encoder string // 编码器
Writer string // 作词者
Producer string // 制作人
Performer string // 表演者
Grouping string // 分组
Lyrics string // 歌词
Keywords string // 关键词
Location string // 位置信息
Rating uint8 // 评级 (0-5)
Custom map[string]string // 自定义键值对
}
// Text Data Box - for storing text metadata
type TextDataBox struct {
FullBox
Text string
}
// Metadata Data Box - for storing binary metadata with type indicator
type MetadataDataBox struct {
FullBox
DataType uint32 // Data type indicator
Country uint32 // Country code
Language uint32 // Language code
Data []byte // Actual data
}
// Copyright Box
type CopyrightBox struct {
FullBox
Language [3]byte
Notice string
}
// Custom Metadata Box (iTunes-style ---- box)
type CustomMetadataBox struct {
BaseBox
Mean string // Mean (namespace)
Name string // Name (key)
Data []byte // Data
}
// Create functions
func CreateTextDataBox(boxType BoxType, text string) *TextDataBox {
return &TextDataBox{
FullBox: FullBox{
BaseBox: BaseBox{
typ: boxType,
size: uint32(FullBoxLen + len(text)),
},
Version: 0,
Flags: [3]byte{0, 0, 0},
},
Text: text,
}
}
func CreateMetadataDataBox(dataType uint32, data []byte) *MetadataDataBox {
return &MetadataDataBox{
FullBox: FullBox{
BaseBox: BaseBox{
typ: f("data"),
size: uint32(FullBoxLen + 8 + len(data)), // 8 bytes for type+country+language
},
Version: 0,
Flags: [3]byte{0, 0, 0},
},
DataType: dataType,
Country: 0,
Language: 0,
Data: data,
}
}
func CreateCopyrightBox(language [3]byte, notice string) *CopyrightBox {
return &CopyrightBox{
FullBox: FullBox{
BaseBox: BaseBox{
typ: TypeCPRT,
size: uint32(FullBoxLen + 3 + 1 + len(notice)), // 3 for language, 1 for null terminator
},
Version: 0,
Flags: [3]byte{0, 0, 0},
},
Language: language,
Notice: notice,
}
}
func CreateCustomMetadataBox(mean, name string, data []byte) *CustomMetadataBox {
size := uint32(BasicBoxLen + 4 + len(mean) + 4 + len(name) + len(data))
return &CustomMetadataBox{
BaseBox: BaseBox{
typ: TypeMETA_CUST,
size: size,
},
Mean: mean,
Name: name,
Data: data,
}
}
// WriteTo methods
func (box *TextDataBox) WriteTo(w io.Writer) (n int64, err error) {
nn, err := w.Write([]byte(box.Text))
return int64(nn), err
}
func (box *MetadataDataBox) WriteTo(w io.Writer) (n int64, err error) {
var tmp [8]byte
binary.BigEndian.PutUint32(tmp[0:4], box.DataType)
binary.BigEndian.PutUint32(tmp[4:8], box.Country)
// Language field is implicit zero
nn, err := w.Write(tmp[:8])
if err != nil {
return int64(nn), err
}
n = int64(nn)
nn, err = w.Write(box.Data)
return n + int64(nn), err
}
func (box *CopyrightBox) WriteTo(w io.Writer) (n int64, err error) {
// Write language code
nn, err := w.Write(box.Language[:])
if err != nil {
return int64(nn), err
}
n = int64(nn)
// Write notice + null terminator
nn, err = w.Write([]byte(box.Notice + "\x00"))
return n + int64(nn), err
}
func (box *CustomMetadataBox) WriteTo(w io.Writer) (n int64, err error) {
var tmp [4]byte
// Write mean length + mean
binary.BigEndian.PutUint32(tmp[:], uint32(len(box.Mean)))
nn, err := w.Write(tmp[:])
if err != nil {
return int64(nn), err
}
n = int64(nn)
nn, err = w.Write([]byte(box.Mean))
if err != nil {
return n + int64(nn), err
}
n += int64(nn)
// Write name length + name
binary.BigEndian.PutUint32(tmp[:], uint32(len(box.Name)))
nn, err = w.Write(tmp[:])
if err != nil {
return n + int64(nn), err
}
n += int64(nn)
nn, err = w.Write([]byte(box.Name))
if err != nil {
return n + int64(nn), err
}
n += int64(nn)
// Write data
nn, err = w.Write(box.Data)
return n + int64(nn), err
}
// Unmarshal methods
func (box *TextDataBox) Unmarshal(buf []byte) (IBox, error) {
box.Text = string(buf)
return box, nil
}
func (box *MetadataDataBox) Unmarshal(buf []byte) (IBox, error) {
if len(buf) < 8 {
return nil, io.ErrShortBuffer
}
box.DataType = binary.BigEndian.Uint32(buf[0:4])
box.Country = binary.BigEndian.Uint32(buf[4:8])
box.Data = buf[8:]
return box, nil
}
func (box *CopyrightBox) Unmarshal(buf []byte) (IBox, error) {
if len(buf) < 4 {
return nil, io.ErrShortBuffer
}
copy(box.Language[:], buf[0:3])
// Find null terminator
for i := 3; i < len(buf); i++ {
if buf[i] == 0 {
box.Notice = string(buf[3:i])
break
}
}
if box.Notice == "" && len(buf) > 3 {
box.Notice = string(buf[3:])
}
return box, nil
}
func (box *CustomMetadataBox) Unmarshal(buf []byte) (IBox, error) {
if len(buf) < 8 {
return nil, io.ErrShortBuffer
}
offset := 0
// Read mean length + mean
meanLen := binary.BigEndian.Uint32(buf[offset:])
offset += 4
if offset+int(meanLen) > len(buf) {
return nil, io.ErrShortBuffer
}
box.Mean = string(buf[offset : offset+int(meanLen)])
offset += int(meanLen)
// Read name length + name
if offset+4 > len(buf) {
return nil, io.ErrShortBuffer
}
nameLen := binary.BigEndian.Uint32(buf[offset:])
offset += 4
if offset+int(nameLen) > len(buf) {
return nil, io.ErrShortBuffer
}
box.Name = string(buf[offset : offset+int(nameLen)])
offset += int(nameLen)
// Read remaining data
box.Data = buf[offset:]
return box, nil
}
// Create metadata entries from Metadata struct
func CreateMetadataEntries(metadata *Metadata) []IBox {
var entries []IBox
// Standard text metadata
if metadata.Title != "" {
entries = append(entries, CreateTextDataBox(TypeTITL, metadata.Title))
}
if metadata.Artist != "" {
entries = append(entries, CreateTextDataBox(TypeART, metadata.Artist))
}
if metadata.Album != "" {
entries = append(entries, CreateTextDataBox(TypeALB, metadata.Album))
}
if metadata.Date != "" {
entries = append(entries, CreateTextDataBox(TypeDAY, metadata.Date))
}
if metadata.Comment != "" {
entries = append(entries, CreateTextDataBox(TypeCMT, metadata.Comment))
}
if metadata.Genre != "" {
entries = append(entries, CreateTextDataBox(TypeGEN, metadata.Genre))
}
if metadata.Encoder != "" {
entries = append(entries, CreateTextDataBox(TypeENCO, metadata.Encoder))
}
if metadata.Writer != "" {
entries = append(entries, CreateTextDataBox(TypeWRT, metadata.Writer))
}
if metadata.Producer != "" {
entries = append(entries, CreateTextDataBox(TypePRD, metadata.Producer))
}
if metadata.Performer != "" {
entries = append(entries, CreateTextDataBox(TypePRF, metadata.Performer))
}
if metadata.Grouping != "" {
entries = append(entries, CreateTextDataBox(TypeGRP, metadata.Grouping))
}
if metadata.Lyrics != "" {
entries = append(entries, CreateTextDataBox(TypeLYR, metadata.Lyrics))
}
if metadata.Keywords != "" {
entries = append(entries, CreateTextDataBox(TypeKEYW, metadata.Keywords))
}
if metadata.Location != "" {
entries = append(entries, CreateTextDataBox(TypeLOCI, metadata.Location))
}
// Copyright (special format)
if metadata.Copyright != "" {
entries = append(entries, CreateCopyrightBox([3]byte{'u', 'n', 'd'}, metadata.Copyright))
}
// Custom metadata
for key, value := range metadata.Custom {
entries = append(entries, CreateCustomMetadataBox("live.m7s.custom", key, []byte(value)))
}
return entries
}
// Helper function to create current date string
func GetCurrentDateString() string {
return time.Now().Format("2006-01-02")
}
func init() {
RegisterBox[*TextDataBox](TypeTITL, TypeART, TypeALB, TypeDAY, TypeCMT, TypeGEN, TypeENCO, TypeWRT, TypePRD, TypePRF, TypeGRP, TypeLYR, TypeKEYW, TypeLOCI, TypeRTNG)
RegisterBox[*MetadataDataBox](f("data"))
RegisterBox[*CopyrightBox](TypeCPRT)
RegisterBox[*CustomMetadataBox](TypeMETA_CUST)
}

View File

@@ -12,12 +12,6 @@ type UserDataBox struct {
Entries []IBox
}
// Custom metadata box for storing stream path
type StreamPathBox struct {
FullBox
StreamPath string
}
// Create a new User Data Box
func CreateUserDataBox(entries ...IBox) *UserDataBox {
size := uint32(BasicBoxLen)
@@ -33,21 +27,6 @@ func CreateUserDataBox(entries ...IBox) *UserDataBox {
}
}
// Create a new StreamPath Box
func CreateStreamPathBox(streamPath string) *StreamPathBox {
return &StreamPathBox{
FullBox: FullBox{
BaseBox: BaseBox{
typ: TypeM7SP, // Custom box type for M7S StreamPath
size: uint32(FullBoxLen + len(streamPath)),
},
Version: 0,
Flags: [3]byte{0, 0, 0},
},
StreamPath: streamPath,
}
}
// WriteTo writes the UserDataBox to the given writer
func (box *UserDataBox) WriteTo(w io.Writer) (n int64, err error) {
return WriteTo(w, box.Entries...)
@@ -69,19 +48,6 @@ func (box *UserDataBox) Unmarshal(buf []byte) (IBox, error) {
return box, nil
}
// WriteTo writes the StreamPathBox to the given writer
func (box *StreamPathBox) WriteTo(w io.Writer) (n int64, err error) {
nn, err := w.Write([]byte(box.StreamPath))
return int64(nn), err
}
// Unmarshal parses the given buffer into a StreamPathBox
func (box *StreamPathBox) Unmarshal(buf []byte) (IBox, error) {
box.StreamPath = string(buf)
return box, nil
}
func init() {
RegisterBox[*UserDataBox](TypeUDTA)
RegisterBox[*StreamPathBox](TypeM7SP)
}

View File

@@ -29,7 +29,8 @@ type (
moov IBox
mdatOffset uint64
mdatSize uint64
StreamPath string // Added to store the stream path
StreamPath string // Added to store the stream path
Metadata *Metadata // 添加元数据支持
}
)
@@ -52,6 +53,7 @@ func NewMuxer(flag Flag) *Muxer {
Tracks: make(map[uint32]*Track),
Flag: flag,
fragDuration: 2000,
Metadata: &Metadata{Custom: make(map[string]string)},
}
}
@@ -59,6 +61,8 @@ func NewMuxer(flag Flag) *Muxer {
func NewMuxerWithStreamPath(flag Flag, streamPath string) *Muxer {
muxer := NewMuxer(flag)
muxer.StreamPath = streamPath
muxer.Metadata.Producer = "M7S Live"
muxer.Metadata.Album = streamPath
return muxer
}
@@ -232,10 +236,10 @@ func (m *Muxer) MakeMoov() IBox {
children = append(children, m.makeMvex())
}
// Add user data box with stream path if available
if m.StreamPath != "" {
streamPathBox := CreateStreamPathBox(m.StreamPath)
udta := CreateUserDataBox(streamPathBox)
// Add user data box with metadata if available
metadataEntries := CreateMetadataEntries(m.Metadata)
if len(metadataEntries) > 0 {
udta := CreateUserDataBox(metadataEntries...)
children = append(children, udta)
}
@@ -365,3 +369,82 @@ func (m *Muxer) WriteTrailer(file *os.File) (err error) {
func (m *Muxer) SetFragmentDuration(duration uint32) {
m.fragDuration = duration
}
// SetMetadata sets the metadata for the MP4 file
func (m *Muxer) SetMetadata(metadata *Metadata) {
m.Metadata = metadata
if metadata.Custom == nil {
metadata.Custom = make(map[string]string)
}
}
// SetTitle sets the title metadata
func (m *Muxer) SetTitle(title string) {
m.Metadata.Title = title
}
// SetArtist sets the artist/author metadata
func (m *Muxer) SetArtist(artist string) {
m.Metadata.Artist = artist
}
// SetAlbum sets the album metadata
func (m *Muxer) SetAlbum(album string) {
m.Metadata.Album = album
}
// SetComment sets the comment/description metadata
func (m *Muxer) SetComment(comment string) {
m.Metadata.Comment = comment
}
// SetGenre sets the genre metadata
func (m *Muxer) SetGenre(genre string) {
m.Metadata.Genre = genre
}
// SetCopyright sets the copyright metadata
func (m *Muxer) SetCopyright(copyright string) {
m.Metadata.Copyright = copyright
}
// SetEncoder sets the encoder metadata
func (m *Muxer) SetEncoder(encoder string) {
m.Metadata.Encoder = encoder
}
// SetDate sets the date metadata (format: YYYY-MM-DD)
func (m *Muxer) SetDate(date string) {
m.Metadata.Date = date
}
// SetCurrentDate sets the date metadata to current date
func (m *Muxer) SetCurrentDate() {
m.Metadata.Date = GetCurrentDateString()
}
// AddCustomMetadata adds custom key-value metadata
func (m *Muxer) AddCustomMetadata(key, value string) {
if m.Metadata.Custom == nil {
m.Metadata.Custom = make(map[string]string)
}
m.Metadata.Custom[key] = value
}
// SetKeywords sets the keywords metadata
func (m *Muxer) SetKeywords(keywords string) {
m.Metadata.Keywords = keywords
}
// SetLocation sets the location metadata
func (m *Muxer) SetLocation(location string) {
m.Metadata.Location = location
}
// SetRating sets the rating metadata (0-5)
func (m *Muxer) SetRating(rating uint8) {
if rating > 5 {
rating = 5
}
m.Metadata.Rating = rating
}

View File

@@ -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,8 +199,9 @@ func (r *Recorder) Run() (err error) {
}
return m7s.PlayBlock(sub, func(audio *pkg.RawAudio) error {
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
@@ -313,8 +244,9 @@ func (r *Recorder) Run() (err error) {
Timestamp: uint32(dts),
})
}, func(video *rtmp.RTMPVideo) error {
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

View File

@@ -185,8 +185,6 @@ func (t *RecordRecoveryTask) recoverRecordFromFile(filePath string) error {
FilePath: filePath,
StreamPath: streamPath,
Type: "mp4",
Mode: m7s.RecordModeAuto, // 默认为自动录制模式
EventLevel: m7s.EventLevelLow, // 默认为低级别事件
}
// 设置开始和结束时间
@@ -220,8 +218,8 @@ func extractStreamPathFromMP4(demuxer *mp4.Demuxer) string {
moov := demuxer.GetMoovBox()
if moov != nil && moov.UDTA != nil {
for _, entry := range moov.UDTA.Entries {
if streamPathBox, ok := entry.(*box.StreamPathBox); ok {
return streamPathBox.StreamPath
if entry.Type() == box.TypeALB {
return entry.(*box.TextDataBox).Text
}
}
}

View File

@@ -79,10 +79,10 @@ func (nc *NetConnection) Handshake(checkC2 bool) (err error) {
if len(C1) != C1S1_SIZE {
return errors.New("C1 Error")
}
var ts int
util.GetBE(C1[4:8], &ts)
var zero int
util.GetBE(C1[4:8], &zero)
if ts == 0 {
if zero == 0 {
return nc.simple_handshake(C1, checkC2)
}
@@ -92,12 +92,26 @@ func (nc *NetConnection) Handshake(checkC2 bool) (err error) {
func (nc *NetConnection) ClientHandshake() (err error) {
C0C1 := nc.mediaDataPool.NextN(C1S1_SIZE + 1)
defer nc.mediaDataPool.Recycle()
// 构造 C0
C0C1[0] = RTMP_HANDSHAKE_VERSION
// 构造 C1 使用简单握手格式
C1 := C0C1[1:]
// Time (4 bytes): 当前时间戳
util.PutBE(C1[0:4], time.Now().Unix()&0xFFFFFFFF)
// Zero (4 bytes): 必须为 0确保使用简单握手
util.PutBE(C1[4:8], 0)
// Random data (1528 bytes): 填充随机数据
for i := 8; i < C1S1_SIZE; i++ {
C1[i] = byte(rand.Int() % 256)
}
if _, err = nc.Write(C0C1); err == nil {
// read S0 S1
if _, err = io.ReadFull(nc.Conn, C0C1); err == nil {
if C0C1[0] != RTMP_HANDSHAKE_VERSION {
err = errors.New("S1 C1 Error")
err = errors.New("S0 Error")
// C2
} else if _, err = nc.Write(C0C1[1:]); err == nil {
_, err = io.ReadFull(nc.Conn, C0C1[1:]) // S2
@@ -222,13 +236,7 @@ func clientScheme(C1 []byte, schem int) (scheme int, challenge []byte, digest []
return 0, nil, nil, false, err
}
// ok
if bytes.Compare(digest, tmp_Hash) == 0 {
ok = true
} else {
ok = false
}
ok = bytes.Equal(digest, tmp_Hash)
// challenge scheme
challenge = C1[key_offset : key_offset+C1S1_KEY_DATA_SIZE]
scheme = schem

View File

@@ -40,7 +40,11 @@ func (conf *WebRTCPlugin) servePush(w http.ResponseWriter, r *http.Request) {
}
conn.SDP = string(bytes)
conn.Logger = conf.Logger
if conn.PeerConnection, err = conf.api.NewPeerConnection(Configuration{
if conn.PeerConnection, err = conf.CreatePC(SessionDescription{
Type: SDPTypeOffer,
SDP: conn.SDP,
}, Configuration{
ICEServers: conf.ICEServers,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -56,10 +60,7 @@ func (conf *WebRTCPlugin) servePush(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := conn.SetRemoteDescription(SessionDescription{Type: SDPTypeOffer, SDP: conn.SDP}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if answer, err := conn.GetAnswer(); err == nil {
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, answer.SDP)
@@ -89,9 +90,15 @@ func (conf *WebRTCPlugin) servePlay(w http.ResponseWriter, r *http.Request) {
if strings.Contains(strings.ToLower(conn.SDP), "h265") {
conn.SupportsH265 = true
}
if conn.PeerConnection, err = conf.api.NewPeerConnection(Configuration{
conn.PeerConnection, err = conf.CreatePC(SessionDescription{
Type: SDPTypeOffer,
SDP: conn.SDP,
}, Configuration{
ICEServers: conf.ICEServers,
}); err != nil {
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rawQuery != "" {
@@ -106,10 +113,7 @@ func (conf *WebRTCPlugin) servePlay(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err = conn.SetRemoteDescription(SessionDescription{Type: SDPTypeOffer, SDP: conn.SDP}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sdp, err := conn.GetAnswer(); err == nil {
w.Write([]byte(sdp.SDP))
} else {

View File

@@ -2,6 +2,7 @@ package plugin_webrtc
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
@@ -35,17 +36,7 @@ func (conf *WebRTCPlugin) BatchV2(w http.ResponseWriter, r *http.Request) {
conn: wsConn,
config: conf,
}
// 创建PeerConnection并设置高级配置
if wsHandler.PeerConnection, err = conf.api.NewPeerConnection(Configuration{
// 本地测试不需要配置 ICE 服务器
ICETransportPolicy: ICETransportPolicyAll,
BundlePolicy: BundlePolicyMaxBundle,
RTCPMuxPolicy: RTCPMuxPolicyRequire,
ICECandidatePoolSize: 1,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 添加任务
conf.AddTask(wsHandler).WaitStopped()
}
@@ -89,15 +80,43 @@ func (wsh *WebSocketHandler) Go() (err error) {
if strings.Contains(strings.ToLower(wsh.SDP), "h265") {
wsh.SupportsH265 = true
}
// 设置远程描述
if err = wsh.SetRemoteDescription(SessionDescription{
if wsh.PeerConnection, err = wsh.config.CreatePC(SessionDescription{
Type: SDPTypeOffer,
SDP: initialSignal.SDP,
}, Configuration{
// 本地测试不需要配置 ICE 服务器
ICETransportPolicy: ICETransportPolicyAll,
BundlePolicy: BundlePolicyMaxBundle,
RTCPMuxPolicy: RTCPMuxPolicyRequire,
ICECandidatePoolSize: 1,
}); err != nil {
wsh.Error("Failed to set remote description", "error", err)
return err
return
}
wsh.OnICECandidate(func(ice *ICECandidate) {
if ice != nil {
wsh.Info(ice.ToJSON().Candidate)
}
})
// 监听ICE连接状态变化
wsh.OnICEConnectionStateChange(func(state ICEConnectionState) {
wsh.Debug("ICE connection state changed", "state", state.String())
if state == ICEConnectionStateFailed {
wsh.Error("ICE connection failed")
}
})
wsh.OnConnectionStateChange(func(state PeerConnectionState) {
wsh.Info("Connection State has changed:" + state.String())
switch state {
case PeerConnectionStateConnected:
case PeerConnectionStateDisconnected, PeerConnectionStateFailed, PeerConnectionStateClosed:
wsh.Stop(errors.New("connection state:" + state.String()))
}
})
// 创建并发送应答
if answer, err := wsh.GetAnswer(); err == nil {
wsh.sendAnswer(answer.SDP)

View File

@@ -0,0 +1,5 @@
mimetype:
- video/H264
- video/H265
- audio/PCMA
- audio/PCMU

View File

@@ -2,16 +2,17 @@ package plugin_webrtc
import (
"embed"
_ "embed"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/pion/logging"
"github.com/pion/sdp/v3"
"m7s.live/v5/pkg/config"
"github.com/pion/interceptor"
@@ -23,23 +24,26 @@ import (
var (
//go:embed web
web embed.FS
reg_level = regexp.MustCompile("profile-level-id=(4.+f)")
_ = m7s.InstallPlugin[WebRTCPlugin](NewPuller, NewPusher)
web embed.FS
//go:embed default.yaml
defaultYaml m7s.DefaultYaml
reg_level = regexp.MustCompile("profile-level-id=(4.+f)")
_ = m7s.InstallPlugin[WebRTCPlugin](m7s.PluginMeta{
DefaultYaml: defaultYaml,
NewPuller: NewPuller,
NewPusher: NewPusher,
})
)
type WebRTCPlugin struct {
m7s.Plugin
ICEServers []ICEServer `desc:"ice服务器配置"`
Port string `default:"tcp:9000" desc:"监听端口"`
PLI time.Duration `default:"2s" desc:"发送PLI请求间隔"` // 视频流丢包后发送PLI请求
EnableOpus bool `default:"true" desc:"是否启用opus编码"` // 是否启用opus编码
EnableVP9 bool `default:"false" desc:"是否启用vp9编码"` // 是否启用vp9编码
EnableAv1 bool `default:"false" desc:"是否启用av1编码"` // 是否启用av1编码
EnableDC bool `default:"true" desc:"是否启用DataChannel"` // 在不支持编码格式的情况下是否启用DataChannel传输
m MediaEngine
s SettingEngine
api *API
ICEServers []ICEServer `desc:"ice服务器配置"`
Port string `default:"tcp:9000" desc:"监听端口"`
PLI time.Duration `default:"2s" desc:"发送PLI请求间隔"` // 视频流丢包后发送PLI请求
EnableDC bool `default:"true" desc:"是否启用DataChannel"` // 在不支持编码格式的情况下是否启用DataChannel传输
MimeType []string `desc:"MimeType过滤列表为空则不过滤"` // MimeType过滤列表支持的格式如video/H264, audio/opus
s SettingEngine
portMapping map[int]int // 内部端口到外部端口的映射
}
func (p *WebRTCPlugin) RegisterHandler() map[string]http.HandlerFunc {
@@ -54,6 +58,387 @@ func (p *WebRTCPlugin) NewLogger(scope string) logging.LeveledLogger {
return &LoggerTransform{Logger: p.Logger.With("scope", scope)}
}
// createMediaEngine 创建新的MediaEngine实例
func (p *WebRTCPlugin) createMediaEngine(ssd *sdp.SessionDescription) *MediaEngine {
m := &MediaEngine{}
// 如果没有提供SDP则使用传统方式注册编解码器
if ssd == nil {
p.registerLegacyCodecs(m)
return m
}
// 从SDP中解析MediaDescription并注册编解码器
p.registerCodecsFromSDP(m, ssd)
return m
}
// registerLegacyCodecs 注册传统方式的编解码器(向后兼容)
func (p *WebRTCPlugin) registerLegacyCodecs(m *MediaEngine) {
// 注册基础编解码器
if defaultCodecs, err := GetDefaultCodecs(); err != nil {
p.Error("Failed to get default codecs", "error", err)
} else {
for _, codecWithType := range defaultCodecs {
// 检查MimeType过滤
if p.isMimeTypeAllowed(codecWithType.Codec.MimeType) {
if err := m.RegisterCodec(codecWithType.Codec, codecWithType.Type); err != nil {
p.Warn("Failed to register default codec", "error", err, "mimeType", codecWithType.Codec.MimeType)
} else {
p.Debug("Registered default codec", "mimeType", codecWithType.Codec.MimeType, "payloadType", codecWithType.Codec.PayloadType)
}
} else {
p.Debug("Default codec filtered", "mimeType", codecWithType.Codec.MimeType)
}
}
}
}
// registerCodecsFromSDP 从SDP的MediaDescription中注册编解码器
func (p *WebRTCPlugin) registerCodecsFromSDP(m *MediaEngine, ssd *sdp.SessionDescription) {
for _, md := range ssd.MediaDescriptions {
// 跳过非音视频媒体类型
if md.MediaName.Media != "audio" && md.MediaName.Media != "video" {
continue
}
// 解析每个format编解码器
for _, format := range md.MediaName.Formats {
codec := p.parseCodecFromSDP(md, format)
if codec == nil {
continue
}
// 检查MimeType过滤
if !p.isMimeTypeAllowed(codec.MimeType) {
p.Debug("MimeType filtered", "mimeType", codec.MimeType)
continue
}
// 确定编解码器类型
var codecType RTPCodecType
if md.MediaName.Media == "audio" {
codecType = RTPCodecTypeAudio
} else {
codecType = RTPCodecTypeVideo
}
// 注册编解码器
if err := m.RegisterCodec(*codec, codecType); err != nil {
p.Warn("Failed to register codec from SDP", "error", err, "mimeType", codec.MimeType)
} else {
p.Debug("Registered codec from SDP", "mimeType", codec.MimeType, "payloadType", codec.PayloadType)
}
}
}
}
// parseCodecFromSDP 从SDP的MediaDescription中解析单个编解码器
func (p *WebRTCPlugin) parseCodecFromSDP(md *sdp.MediaDescription, format string) *RTPCodecParameters {
var codecName string
var clockRate uint32
var channels uint16
var fmtpLine string
// 从rtpmap属性中解析编解码器名称、时钟频率和通道数
for _, attr := range md.Attributes {
if attr.Key == "rtpmap" && strings.HasPrefix(attr.Value, format+" ") {
// 格式payloadType codecName/clockRate[/channels]
parts := strings.Split(attr.Value, " ")
if len(parts) >= 2 {
codecParts := strings.Split(parts[1], "/")
if len(codecParts) >= 2 {
codecName = strings.ToUpper(codecParts[0])
if rate, err := strconv.ParseUint(codecParts[1], 10, 32); err == nil {
clockRate = uint32(rate)
}
if len(codecParts) >= 3 {
if ch, err := strconv.ParseUint(codecParts[2], 10, 16); err == nil {
channels = uint16(ch)
}
}
}
}
break
}
}
// 从fmtp属性中解析格式参数
for _, attr := range md.Attributes {
if attr.Key == "fmtp" && strings.HasPrefix(attr.Value, format+" ") {
if spaceIdx := strings.Index(attr.Value, " "); spaceIdx >= 0 {
fmtpLine = attr.Value[spaceIdx+1:]
}
break
}
}
// 如果没有找到rtpmap尝试静态payload类型
if codecName == "" {
codecName, clockRate, channels = p.getStaticPayloadInfo(format)
}
if codecName == "" {
return nil
}
// 构造MimeType
var mimeType string
if md.MediaName.Media == "audio" {
mimeType = "audio/" + codecName
} else {
mimeType = "video/" + codecName
}
// 解析PayloadType
payloadType, err := strconv.ParseUint(format, 10, 8)
if err != nil {
return nil
}
return &RTPCodecParameters{
RTPCodecCapability: RTPCodecCapability{
MimeType: mimeType,
ClockRate: clockRate,
Channels: channels,
SDPFmtpLine: fmtpLine,
RTCPFeedback: p.getDefaultRTCPFeedback(mimeType),
},
PayloadType: PayloadType(payloadType),
}
}
// getStaticPayloadInfo 获取静态payload类型的编解码器信息
func (p *WebRTCPlugin) getStaticPayloadInfo(format string) (string, uint32, uint16) {
switch format {
case "0":
return "PCMU", 8000, 1
case "8":
return "PCMA", 8000, 1
case "96":
return "H264", 90000, 0
case "97":
return "H264", 90000, 0
case "98":
return "H264", 90000, 0
case "111":
return "OPUS", 48000, 2
default:
return "", 0, 0
}
}
// getDefaultRTCPFeedback 获取默认的RTCP反馈
func (p *WebRTCPlugin) getDefaultRTCPFeedback(mimeType string) []RTCPFeedback {
if strings.HasPrefix(mimeType, "video/") {
return []RTCPFeedback{
{Type: "goog-remb", Parameter: ""},
{Type: "ccm", Parameter: "fir"},
{Type: "nack", Parameter: ""},
{Type: "nack", Parameter: "pli"},
{Type: "transport-cc", Parameter: ""},
}
}
return nil
}
// isMimeTypeAllowed 检查MimeType是否在允许列表中
func (p *WebRTCPlugin) isMimeTypeAllowed(mimeType string) bool {
// 如果过滤列表为空,则允许所有类型
if len(p.MimeType) == 0 {
return true
}
// 检查精确匹配
for _, filter := range p.MimeType {
if strings.EqualFold(filter, mimeType) {
return true
}
// 支持通配符匹配,如 "video/*" 或 "audio/*"
if strings.HasSuffix(filter, "/*") {
prefix := strings.TrimSuffix(filter, "/*")
if strings.HasPrefix(strings.ToLower(mimeType), strings.ToLower(prefix)+"/") {
return true
}
}
}
return false
}
// createAPI 创建新的API实例
func (p *WebRTCPlugin) createAPI(ssd *sdp.SessionDescription) (api *API, err error) {
m := p.createMediaEngine(ssd)
i := &interceptor.Registry{}
// 注册默认拦截器
if err := RegisterDefaultInterceptors(m, i); err != nil {
p.Error("register default interceptors error", "error", err)
return nil, err
}
// 创建API
api = NewAPI(WithMediaEngine(m), WithInterceptorRegistry(i), WithSettingEngine(p.s))
return
}
// initSettingEngine 初始化SettingEngine
func (p *WebRTCPlugin) initSettingEngine() error {
// 设置LoggerFactory
p.s.LoggerFactory = p
// 配置NAT 1:1 IP映射
if p.GetCommonConf().PublicIP != "" {
ips := []string{p.GetCommonConf().PublicIP}
if p.GetCommonConf().PublicIPv6 != "" {
ips = append(ips, p.GetCommonConf().PublicIPv6)
}
p.s.SetNAT1To1IPs(ips, ICECandidateTypeHost)
}
// 配置端口
if err := p.configurePort(); err != nil {
return err
}
return nil
}
// configurePort 配置端口设置
func (p *WebRTCPlugin) configurePort() error {
// 使用 ParsePort 而不是 ParsePort2 来获取端口映射信息
portInfo, err := ParsePort(p.Port)
if err != nil {
p.Error("webrtc port config error", "error", err, "port", p.Port)
return err
}
// 初始化端口映射
p.portMapping = make(map[int]int)
// 如果有端口映射,存储映射关系
if portInfo.HasMapping() {
if portInfo.IsRange() {
// 端口范围映射
for i := 0; i <= portInfo.Ports[1]-portInfo.Ports[0]; i++ {
internalPort := portInfo.Ports[0] + i
var externalPort int
if portInfo.IsRangeMapping() {
// 映射端口也是范围
externalPort = portInfo.Map[0] + i
} else {
// 映射端口是单个端口
externalPort = portInfo.Map[0]
}
p.portMapping[internalPort] = externalPort
}
} else {
// 单端口映射
p.portMapping[portInfo.Ports[0]] = portInfo.Map[0]
}
p.Info("Port mapping configured", "mapping", p.portMapping)
}
// 根据协议类型进行配置
if portInfo.IsTCP() {
if portInfo.IsRange() {
// TCP端口范围这里可能需要特殊处理
p.Error("TCP port range not supported in current implementation")
return fmt.Errorf("TCP port range not supported")
} else {
// TCP单端口
tcpport := portInfo.Ports[0]
tcpl, err := net.ListenTCP("tcp", &net.TCPAddr{
IP: net.IP{0, 0, 0, 0},
Port: tcpport,
})
p.OnDispose(func() {
_ = tcpl.Close()
})
if err != nil {
p.Error("webrtc listener tcp", "error", err)
return err
}
p.SetDescription("tcp", fmt.Sprintf("%d", tcpport))
p.Info("webrtc start listen", "port", tcpport)
p.s.SetICETCPMux(NewICETCPMux(nil, tcpl, 4096))
p.s.SetNetworkTypes([]NetworkType{NetworkTypeTCP4, NetworkTypeTCP6})
p.s.DisableSRTPReplayProtection(true)
}
} else {
// UDP配置
if portInfo.IsRange() {
// UDP端口范围
p.s.SetEphemeralUDPPortRange(uint16(portInfo.Ports[0]), uint16(portInfo.Ports[1]))
p.SetDescription("udp", fmt.Sprintf("%d-%d", portInfo.Ports[0], portInfo.Ports[1]))
} else {
// UDP单端口
udpport := portInfo.Ports[0]
udpListener, err := net.ListenUDP("udp", &net.UDPAddr{
IP: net.IP{0, 0, 0, 0},
Port: udpport,
})
p.OnDispose(func() {
_ = udpListener.Close()
})
if err != nil {
p.Error("webrtc listener udp", "error", err)
return err
}
p.SetDescription("udp", fmt.Sprintf("%d", udpport))
p.Info("webrtc start listen", "port", udpport)
p.s.SetICEUDPMux(NewICEUDPMux(nil, udpListener))
p.s.SetNetworkTypes([]NetworkType{NetworkTypeUDP4, NetworkTypeUDP6})
}
}
return nil
}
func (p *WebRTCPlugin) CreatePC(sd SessionDescription, conf Configuration) (pc *PeerConnection, err error) {
var ssd *sdp.SessionDescription
ssd, err = sd.Unmarshal()
if err != nil {
return
}
var api *API
// 创建PeerConnection并设置高级配置
api, err = p.createAPI(ssd)
if err != nil {
return
}
pc, err = api.NewPeerConnection(conf)
if err != nil {
return
}
// 如果有端口映射配置,记录 ICE 候选者信息以供调试
if len(p.portMapping) > 0 {
pc.OnICECandidate(func(candidate *ICECandidate) {
if candidate != nil {
// 记录端口映射信息(用于调试和监控)
if mappedPort, exists := p.portMapping[int(candidate.Port)]; exists {
p.Debug("ICE candidate with port mapping detected",
"original_port", candidate.Port,
"mapped_port", mappedPort,
"candidate_address", candidate.Address,
"candidate_type", candidate.Typ)
candidate.Port = uint16(mappedPort) // 更新候选者端口为映射后的端口
} else {
p.Debug("ICE candidate generated",
"port", candidate.Port,
"address", candidate.Address,
"type", candidate.Typ)
}
}
})
}
err = pc.SetRemoteDescription(sd)
return
}
func (p *WebRTCPlugin) OnInit() (err error) {
if len(p.ICEServers) > 0 {
for i := range p.ICEServers {
@@ -62,84 +447,10 @@ func (p *WebRTCPlugin) OnInit() (err error) {
}
}
p.s.LoggerFactory = p
RegisterCodecs(&p.m)
if p.EnableOpus {
p.m.RegisterCodec(RTPCodecParameters{
RTPCodecCapability: RTPCodecCapability{MimeTypeOpus, 48000, 2, "minptime=10;useinbandfec=1", nil},
PayloadType: 111,
}, RTPCodecTypeAudio)
}
if p.EnableVP9 {
p.m.RegisterCodec(RTPCodecParameters{
RTPCodecCapability: RTPCodecCapability{MimeTypeVP9, 90000, 0, "", nil},
PayloadType: 100,
}, RTPCodecTypeVideo)
}
if p.EnableAv1 {
p.m.RegisterCodec(RTPCodecParameters{
RTPCodecCapability: RTPCodecCapability{MimeTypeAV1, 90000, 0, "profile=2;level-idx=8;tier=1", nil},
PayloadType: 45,
}, RTPCodecTypeVideo)
}
i := &interceptor.Registry{}
if p.GetCommonConf().PublicIP != "" {
ips := []string{p.GetCommonConf().PublicIP}
if p.GetCommonConf().PublicIPv6 != "" {
ips = append(ips, p.GetCommonConf().PublicIPv6)
}
p.s.SetNAT1To1IPs(ips, ICECandidateTypeHost)
}
ports, err := ParsePort2(p.Port)
if err != nil {
p.Error("webrtc port config error", "error", err, "port", p.Port)
if err = p.initSettingEngine(); err != nil {
return err
}
switch v := ports.(type) {
case TCPPort:
tcpport := int(v)
tcpl, err := net.ListenTCP("tcp", &net.TCPAddr{
IP: net.IP{0, 0, 0, 0},
Port: tcpport,
})
p.OnDispose(func() {
_ = tcpl.Close()
})
if err != nil {
p.Error("webrtc listener tcp", "error", err)
}
p.SetDescription("tcp", fmt.Sprintf("%d", tcpport))
p.Info("webrtc start listen", "port", tcpport)
p.s.SetICETCPMux(NewICETCPMux(nil, tcpl, 4096))
p.s.SetNetworkTypes([]NetworkType{NetworkTypeTCP4, NetworkTypeTCP6})
p.s.DisableSRTPReplayProtection(true)
case UDPRangePort:
p.s.SetEphemeralUDPPortRange(uint16(v[0]), uint16(v[1]))
p.SetDescription("udp", fmt.Sprintf("%d-%d", v[0], v[1]))
case UDPPort:
// 创建共享WEBRTC端口 默认9000
udpListener, err := net.ListenUDP("udp", &net.UDPAddr{
IP: net.IP{0, 0, 0, 0},
Port: int(v),
})
p.OnDispose(func() {
_ = udpListener.Close()
})
if err != nil {
p.Error("webrtc listener udp", "error", err)
return err
}
p.SetDescription("udp", fmt.Sprintf("%d", v))
p.Info("webrtc start listen", "port", v)
p.s.SetICEUDPMux(NewICEUDPMux(nil, udpListener))
p.s.SetNetworkTypes([]NetworkType{NetworkTypeUDP4, NetworkTypeUDP6})
}
if err = RegisterDefaultInterceptors(&p.m, i); err != nil {
return err
}
p.api = NewAPI(WithMediaEngine(&p.m),
WithInterceptorRegistry(i), WithSettingEngine(p.s))
_, port, _ := strings.Cut(p.GetCommonConf().HTTP.ListenAddr, ":")
if port == "80" {
p.PushAddr = append(p.PushAddr, "http://{hostName}/webrtc/push")
@@ -203,8 +514,12 @@ func (p *WebRTCPlugin) testPage(w http.ResponseWriter, r *http.Request) {
func (p *WebRTCPlugin) Pull(streamPath string, conf config.Pull, pubConf *config.Publish) {
if strings.HasPrefix(conf.URL, "https://rtc.live.cloudflare.com") {
cfClient := NewCFClient(DIRECTION_PULL)
var err error
cfClient.PeerConnection, err = p.api.NewPeerConnection(Configuration{
api, err := p.createAPI(nil)
if err != nil {
p.Error("create API failed", "error", err)
return
}
cfClient.PeerConnection, err = api.NewPeerConnection(Configuration{
ICEServers: p.ICEServers,
BundlePolicy: BundlePolicyMaxBundle,
})

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"m7s.live/v5"
"m7s.live/v5/pkg/config"
)
const (
@@ -31,7 +32,7 @@ func (c *Client) GetPushJob() *m7s.PushJob {
return &c.pushCtx
}
func NewPuller() m7s.IPuller {
func NewPuller(config.Pull) m7s.IPuller {
return &Client{
direction: DIRECTION_PULL,
}

View File

@@ -4,7 +4,17 @@ import (
. "github.com/pion/webrtc/v4"
)
func RegisterCodecs(m *MediaEngine) error {
var videoRTCPFeedback = []RTCPFeedback{{"goog-remb", ""}, {"ccm", "fir"}, {"nack", ""}, {"nack", "pli"}, {"transport-cc", ""}}
type CodecWithType struct {
Codec RTPCodecParameters
Type RTPCodecType
}
func GetDefaultCodecs() ([]CodecWithType, error) {
var codecs []CodecWithType
// 音频编解码器
for _, codec := range []RTPCodecParameters{
{
RTPCodecCapability: RTPCodecCapability{MimeTypePCMU, 8000, 0, "", nil},
@@ -14,12 +24,15 @@ func RegisterCodecs(m *MediaEngine) error {
RTPCodecCapability: RTPCodecCapability{MimeTypePCMA, 8000, 0, "", nil},
PayloadType: 8,
},
{
RTPCodecCapability: RTPCodecCapability{MimeTypeOpus, 48000, 2, "minptime=10;useinbandfec=1", nil},
PayloadType: 111,
},
} {
if err := m.RegisterCodec(codec, RTPCodecTypeAudio); err != nil {
return err
}
codecs = append(codecs, CodecWithType{Codec: codec, Type: RTPCodecTypeAudio})
}
videoRTCPFeedback := []RTCPFeedback{{"goog-remb", ""}, {"ccm", "fir"}, {"nack", ""}, {"nack", "pli"}}
// 视频编解码器
for _, codec := range []RTPCodecParameters{
// {
// RTPCodecCapability: RTPCodecCapability{"video/rtx", 90000, 0, "apt=96", nil},
@@ -95,10 +108,28 @@ func RegisterCodecs(m *MediaEngine) error {
RTPCodecCapability: RTPCodecCapability{MimeTypeH265, 90000, 0, "level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST", videoRTCPFeedback},
PayloadType: 49,
},
{
RTPCodecCapability: RTPCodecCapability{MimeTypeH265, 90000, 0, "level-id=186;profile-id=1;tier-flag=0;tx-mode=SRST", videoRTCPFeedback},
PayloadType: 50,
},
{
RTPCodecCapability: RTPCodecCapability{MimeTypeH265, 90000, 0, "level-id=180;profile-id=2;tier-flag=0;tx-mode=SRST", videoRTCPFeedback},
PayloadType: 51,
},
{
RTPCodecCapability: RTPCodecCapability{MimeTypeH265, 90000, 0, "level-id=186;profile-id=2;tier-flag=0;tx-mode=SRST", videoRTCPFeedback},
PayloadType: 52,
},
{
RTPCodecCapability: RTPCodecCapability{MimeTypeVP9, 90000, 0, "", nil},
PayloadType: 100,
},
{
RTPCodecCapability: RTPCodecCapability{MimeTypeAV1, 90000, 0, "profile=2;level-idx=8;tier=1", nil},
PayloadType: 45,
},
} {
if err := m.RegisterCodec(codec, RTPCodecTypeVideo); err != nil {
return err
}
codecs = append(codecs, CodecWithType{Codec: codec, Type: RTPCodecTypeVideo})
}
return nil
return codecs, nil
}

View File

@@ -241,7 +241,7 @@ func (IO *MultipleConnection) SendSubscriber(subscriber *m7s.Subscriber) (audioS
return
}
}
rcc.RTCPFeedback = videoRTCPFeedback
videoTLSRTP, err = NewTrackLocalStaticRTP(rcc.RTPCodecCapability, videoCodec.String(), subscriber.StreamPath)
if err != nil {
return
@@ -452,32 +452,6 @@ type SingleConnection struct {
Connection
}
func (c *SingleConnection) Start() (err error) {
c.OnICECandidate(func(ice *ICECandidate) {
if ice != nil {
c.Info(ice.ToJSON().Candidate)
}
})
// 监听ICE连接状态变化
c.OnICEConnectionStateChange(func(state ICEConnectionState) {
c.Debug("ICE connection state changed", "state", state.String())
if state == ICEConnectionStateFailed {
c.Error("ICE connection failed")
}
})
c.OnConnectionStateChange(func(state PeerConnectionState) {
c.Info("Connection State has changed:" + state.String())
switch state {
case PeerConnectionStateConnected:
case PeerConnectionStateDisconnected, PeerConnectionStateFailed, PeerConnectionStateClosed:
c.Stop(errors.New("connection state:" + state.String()))
}
})
return
}
func (c *SingleConnection) Receive() {
c.OnTrack(func(track *TrackRemote, receiver *RTPReceiver) {
c.Info("OnTrack", "kind", track.Kind().String(), "payloadType", uint8(track.Codec().PayloadType))

View File

@@ -76,13 +76,10 @@ func (s *Server) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.Net.ReceiveSpeed, prometheus.GaugeValue, float64(net.ReceiveSpeed), net.Name)
}
}
s.Call(func() error {
for stream := range s.Streams.Range {
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.BPS, prometheus.GaugeValue, float64(stream.VideoTrack.AVTrack.BPS), stream.StreamPath, stream.Plugin.Meta.Name, "video")
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.FPS, prometheus.GaugeValue, float64(stream.VideoTrack.AVTrack.FPS), stream.StreamPath, stream.Plugin.Meta.Name, "video")
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.BPS, prometheus.GaugeValue, float64(stream.AudioTrack.AVTrack.BPS), stream.StreamPath, stream.Plugin.Meta.Name, "audio")
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.FPS, prometheus.GaugeValue, float64(stream.AudioTrack.AVTrack.FPS), stream.StreamPath, stream.Plugin.Meta.Name, "audio")
}
return nil
})
for stream := range s.Streams.SafeRange {
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.BPS, prometheus.GaugeValue, float64(stream.VideoTrack.AVTrack.BPS), stream.StreamPath, stream.Plugin.Meta.Name, "video")
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.FPS, prometheus.GaugeValue, float64(stream.VideoTrack.AVTrack.FPS), stream.StreamPath, stream.Plugin.Meta.Name, "video")
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.BPS, prometheus.GaugeValue, float64(stream.AudioTrack.AVTrack.BPS), stream.StreamPath, stream.Plugin.Meta.Name, "audio")
ch <- prometheus.MustNewConstMetric(s.prometheusDesc.FPS, prometheus.GaugeValue, float64(stream.AudioTrack.AVTrack.FPS), stream.StreamPath, stream.Plugin.Meta.Name, "audio")
}
}

View File

@@ -260,7 +260,7 @@ func (s *Server) GetPullProxyList(ctx context.Context, req *emptypb.Empty) (res
}
func (s *Server) AddPullProxy(ctx context.Context, req *pb.PullProxyInfo) (res *pb.SuccessResponse, err error) {
device := &PullProxyConfig{
pullProxyConfig := &PullProxyConfig{
Name: req.Name,
Type: req.Type,
ParentID: uint(req.ParentID),
@@ -268,7 +268,7 @@ func (s *Server) AddPullProxy(ctx context.Context, req *pb.PullProxyInfo) (res *
Description: req.Description,
StreamPath: req.StreamPath,
}
if device.Type == "" {
if pullProxyConfig.Type == "" {
var u *url.URL
u, err = url.Parse(req.PullURL)
if err != nil {
@@ -277,35 +277,49 @@ func (s *Server) AddPullProxy(ctx context.Context, req *pb.PullProxyInfo) (res *
}
switch u.Scheme {
case "srt", "rtsp", "rtmp":
device.Type = u.Scheme
pullProxyConfig.Type = u.Scheme
default:
ext := filepath.Ext(u.Path)
switch ext {
case ".m3u8":
device.Type = "hls"
pullProxyConfig.Type = "hls"
case ".flv":
device.Type = "flv"
pullProxyConfig.Type = "flv"
case ".mp4":
device.Type = "mp4"
pullProxyConfig.Type = "mp4"
}
}
}
defaults.SetDefaults(&device.Pull)
defaults.SetDefaults(&device.Record)
device.URL = req.PullURL
device.Audio = req.Audio
device.StopOnIdle = req.StopOnIdle
device.Record.FilePath = req.RecordPath
device.Record.Fragment = req.RecordFragment.AsDuration()
defaults.SetDefaults(&pullProxyConfig.Pull)
defaults.SetDefaults(&pullProxyConfig.Record)
pullProxyConfig.URL = req.PullURL
pullProxyConfig.Audio = req.Audio
pullProxyConfig.StopOnIdle = req.StopOnIdle
pullProxyConfig.Record.FilePath = req.RecordPath
pullProxyConfig.Record.Fragment = req.RecordFragment.AsDuration()
if s.DB == nil {
err = pkg.ErrNoDB
return
}
s.DB.Create(device)
if req.StreamPath == "" {
device.StreamPath = device.GetStreamPath()
// 检查数据库中是否有相同的 streamPath 且状态不是 disabled 的记录
var existingCount int64
streamPath := pullProxyConfig.StreamPath
if streamPath == "" {
streamPath = pullProxyConfig.GetStreamPath()
}
_, err = s.createPullProxy(device)
s.DB.Model(&PullProxyConfig{}).Where("stream_path = ? AND status != ?", streamPath, PullProxyStatusDisabled).Count(&existingCount)
// 如果存在相同 streamPath 且状态不是 disabled 的记录,将当前记录状态设置为 disabled
if existingCount > 0 {
pullProxyConfig.Status = PullProxyStatusDisabled
}
s.DB.Create(pullProxyConfig)
if req.StreamPath == "" {
pullProxyConfig.StreamPath = pullProxyConfig.GetStreamPath()
}
_, err = s.createPullProxy(pullProxyConfig)
res = &pb.SuccessResponse{}
return
@@ -321,6 +335,10 @@ func (s *Server) UpdatePullProxy(ctx context.Context, req *pb.PullProxyInfo) (re
if err != nil {
return
}
// 记录原始状态,用于后续判断状态变化
originalStatus := target.Status
target.Name = req.Name
target.URL = req.PullURL
target.ParentID = uint(req.ParentID)
@@ -355,19 +373,50 @@ func (s *Server) UpdatePullProxy(ctx context.Context, req *pb.PullProxyInfo) (re
target.Record.Fragment = req.RecordFragment.AsDuration()
target.RTT = time.Duration(int(req.Rtt)) * time.Millisecond
target.StreamPath = req.StreamPath
// 如果设置状态为非 disable需要检查是否有相同 streamPath 的其他非 disable 代理
if req.Status != uint32(PullProxyStatusDisabled) {
var existingCount int64
streamPath := target.StreamPath
if streamPath == "" {
streamPath = target.GetStreamPath()
}
s.DB.Model(&PullProxyConfig{}).Where("stream_path = ? AND id != ? AND status != ?", streamPath, req.ID, PullProxyStatusDisabled).Count(&existingCount)
// 如果存在相同 streamPath 且状态不是 disabled 的其他记录,更新失败
if existingCount > 0 {
err = fmt.Errorf("已存在相同 streamPath [%s] 的非禁用代理,更新失败", streamPath)
return
}
target.Status = byte(req.Status)
} else {
target.Status = PullProxyStatusDisabled
}
s.DB.Save(target)
// 检查是否从 disable 状态变为非 disable 状态
wasDisabled := originalStatus == PullProxyStatusDisabled
isNowEnabled := target.Status != PullProxyStatusDisabled
isNowDisabled := target.Status == PullProxyStatusDisabled
wasEnabled := originalStatus != PullProxyStatusDisabled
if device, ok := s.PullProxies.SafeGet(uint(req.ID)); ok {
// 如果现在变为 disable 状态,需要停止并移除代理
if wasEnabled && isNowDisabled {
device.Stop(task.ErrStopByUser)
return
}
conf := device.GetConfig()
if target.URL != conf.URL || conf.Audio != target.Audio || conf.StreamPath != target.StreamPath || conf.Record.FilePath != target.Record.FilePath || conf.Record.Fragment != target.Record.Fragment {
device.Stop(task.ErrStopByUser)
device.WaitStopped()
_, err = s.createPullProxy(target)
device, err = s.createPullProxy(target)
if target.Status == PullProxyStatusPulling {
if pullJob, ok := s.Pulls.SafeGet(device.GetStreamPath()); ok {
pullJob.OnDispose(device.Pull)
pullJob.Stop(task.ErrStopByUser)
pullJob.WaitStopped()
}
device.Pull()
}
} else {
conf.Name = target.Name
@@ -382,6 +431,12 @@ func (s *Server) UpdatePullProxy(ctx context.Context, req *pb.PullProxyInfo) (re
}
}
}
} else if wasDisabled && isNowEnabled {
// 如果原来是 disable 现在不是了,需要创建 PullProxy 并添加到集合中
_, err = s.createPullProxy(target)
if err != nil {
s.Error("create pull proxy failed", "error", err)
}
}
res = &pb.SuccessResponse{}
return

View File

@@ -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
}

View File

@@ -1,6 +1,8 @@
package m7s
import (
"os"
"path/filepath"
"time"
"gorm.io/gorm"
@@ -12,57 +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"`
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:"type:datetime;default:NULL"`
EndTime time.Time `gorm:"type:datetime;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:"-"`
}
)
@@ -74,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
}
@@ -149,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
}

View File

@@ -427,16 +427,23 @@ func (s *Server) Start() (err error) {
}
}
}
s.initDB()
if s.DB != nil {
s.initPullProxies()
s.initPushProxies()
s.initStreamAlias()
} else {
s.initPullProxiesWithoutDB()
s.initPushProxiesWithoutDB()
}
return nil
}, "serverStart")
return
}
func (s *Server) initPullProxies() {
// 1. First read all pull proxies from database
// 1. First read all pull proxies from database, excluding disabled ones
var pullProxies []*PullProxyConfig
s.DB.Find(&pullProxies)
s.DB.Where("status != ?", PullProxyStatusDisabled).Find(&pullProxies)
// Create a map for quick lookup of existing proxies
existingPullProxies := make(map[uint]*PullProxyConfig)
@@ -466,9 +473,11 @@ func (s *Server) initPullProxies() {
}
}
// 3. Finally add all proxies to collections
// 3. Finally add all proxies to collections, excluding disabled ones
for _, proxy := range pullProxies {
s.createPullProxy(proxy)
if proxy.Status != PullProxyStatusDisabled {
s.createPullProxy(proxy)
}
}
}
@@ -532,18 +541,6 @@ func (s *Server) initPushProxiesWithoutDB() {
}
}
func (s *Server) initDB() {
if s.DB != nil {
s.initPullProxies()
s.initPushProxies()
s.initStreamAlias()
} else {
s.initPullProxiesWithoutDB()
s.initPushProxiesWithoutDB()
}
}
func (c *CheckSubWaitTimeout) GetTickInterval() time.Duration {
return c.s.PulseInterval
}
@@ -582,12 +579,9 @@ func (s *Server) Dispose() {
func (s *Server) GetPublisher(streamPath string) (publisher *Publisher, err error) {
var ok bool
s.Streams.Call(func() error {
publisher, ok = s.Streams.Get(streamPath)
return nil
})
publisher, ok = s.Streams.SafeGet(streamPath)
if !ok {
err = fmt.Errorf("src stream not found")
err = pkg.ErrNotFound
return
}
return