Repository: q191201771/lalmax Branch: master Commit: ffdfe24e1a84 Files: 115 Total size: 576.3 KB Directory structure: gitextract_sbsxbvrx/ ├── .github/ │ └── workflows/ │ ├── go.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── conf/ │ ├── cert.pem │ ├── key.pem │ └── lalmax.conf.json ├── config/ │ ├── config.go │ └── config_test.go ├── document/ │ ├── api.md │ ├── api_gateway.md │ ├── config.md │ ├── gb28181.md │ ├── hook_api.md │ ├── hook_plugin_architecture.md │ ├── lal_api.md │ ├── lal_config.md │ ├── rtc.md │ ├── srt.md │ └── stream_url.md ├── fmp4/ │ ├── hls/ │ │ ├── server.go │ │ └── session.go │ ├── http-fmp4/ │ │ ├── server.go │ │ └── session.go │ └── muxer/ │ ├── codec.go │ ├── file_writer.go │ ├── flac_box.go │ ├── init.go │ ├── init_track.go │ ├── mp4_writer.go │ ├── muxer.go │ ├── muxer_part.go │ ├── part.go │ ├── part_sample.go │ ├── part_track.go │ ├── rtmp2fmp4.go │ ├── seekablebuffer.go │ ├── track.go │ └── var.go ├── gb28181/ │ ├── auth.go │ ├── avail_conn_pool.go │ ├── channel.go │ ├── device.go │ ├── http_logic.go │ ├── inviteoption.go │ ├── mediaserver/ │ │ ├── conn.go │ │ ├── mediaserver_t.go │ │ └── server.go │ ├── mpegps/ │ │ ├── bitstream.go │ │ ├── pes_proto.go │ │ ├── ps_demuxer.go │ │ ├── ps_demuxer_test.go │ │ ├── ps_muxer.go │ │ ├── ps_proto.go │ │ └── util.go │ ├── ptz.go │ ├── rtppub/ │ │ ├── manager.go │ │ └── manager_test.go │ ├── rtppush/ │ │ ├── lower_push_session.go │ │ └── lower_push_session_test.go │ ├── server.go │ ├── t_http_api.go │ ├── util.go │ └── xml.go ├── go.mod ├── go.sum ├── logic/ │ ├── gop_cache.go │ ├── group.go │ ├── group_manager.go │ ├── group_test.go │ ├── stat_aggregator.go │ ├── stream_key.go │ └── subscriber_stat.go ├── main.go ├── rtc/ │ ├── jessibucasession.go │ ├── packer.go │ ├── peerConnection.go │ ├── server.go │ ├── subscriber_stat.go │ ├── unpacker.go │ ├── whepsession.go │ └── whipsession.go ├── run.sh ├── server/ │ ├── hook_builtin_http_plugin.go │ ├── hook_filter.go │ ├── hook_plugin.go │ ├── http_notify.go │ ├── middle.go │ ├── router.go │ ├── router_ctrl.go │ ├── router_flv_proxy.go │ ├── router_fmp4.go │ ├── router_helper.go │ ├── router_hook.go │ ├── router_rtc.go │ ├── router_stat.go │ ├── router_test.go │ ├── router_zlm_compat.go │ ├── server.go │ ├── stat_view.go │ ├── zlm_compat_config.go │ ├── zlm_compat_ffmpeg.go │ ├── zlm_compat_test.go │ └── zlm_compat_types.go ├── srt/ │ ├── pub.go │ ├── server.go │ ├── stream_id.go │ └── sub.go ├── utils/ │ └── adjustdts.go └── version/ ├── README.md ├── v0.1.0.md └── v0.2.0.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/go.yml ================================================ # This workflow will build a golang project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go name: Go on: push: branches: [ "master" ] pull_request: branches: [ "master" ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.21' - name: Build for Linux, macOS, and Windows run: | GOOS=linux go build -o lalmax-linux main.go GOOS=darwin go build -o lalmax-macos main.go GOOS=windows go build -o lalmax-windows.exe main.go ================================================ FILE: .github/workflows/release.yml ================================================ # https://github.com/wangyoucao577/go-release-action name: build-go-binary on: release: types: [created] # 表示在创建新的 Release 时触发 permissions: contents: write packages: write jobs: build-go-binary: runs-on: ubuntu-latest strategy: matrix: goos: [linux, windows, darwin] # 需要打包的系统 goarch: [amd64, arm64] # 需要打包的架构 steps: - uses: actions/checkout@v4 - uses: wangyoucao577/go-release-action@v1.49 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} goversion: 1.22 md5sum: false extra_files: ./README.md ./conf ================================================ FILE: .gitignore ================================================ .codex-cache/ server/logs/ ================================================ FILE: Dockerfile ================================================ FROM golang:1.23.0 ENV GOPROXY=https://goproxy.cn,https://goproxy.io,direct LABEL maintainer="Kevin Zang" WORKDIR /code COPY . . RUN /bin/bash ./build.sh EXPOSE 1935 8080 4433 5544 8083 8084 1290 30000-30100/udp 6001/udp 4888/udp CMD export LD_LIBRARY_PATH=/usr/local/lib/ && ./run.sh ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2023 Chef Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # lalmax lalmax是在lal的基础上集成第三方库,可以提供SRT、RTC、mp4、gb28181、onvif等解决方案 # 编译 ./build.sh # 运行 ./run.sh或者./lalmax -c conf/lalmax.conf.json # 配置说明 lalmax.conf.json 配置主要由 2 部分组成 (1) lalmax: lalmax 扩展能力配置,例如 SRT、RTC、HTTP-FMP4、GB28181 等,具体配置说明见[config.md](./document/config.md) (2) lal: lal 原生配置,例如 RTMP、RTSP、HTTP-FLV、HLS-TS、录制、鉴权等,具体配置说明见[lal_config.md](./document/lal_config.md)。对外建议统一使用 lalmax 的 API Gateway、HTTP API 和 Hook API 门面;`lal.http_api` 仅建议在调试 lal 原生行为时临时开启,说明见[api_gateway.md](./document/api_gateway.md)、[lal_api.md](./document/lal_api.md)、[hook_api.md](./document/hook_api.md) 与 [hook_plugin_architecture.md](./document/hook_plugin_architecture.md) 旧版平铺配置和 lal_config_path 仍兼容,但推荐使用 lalmax/lal 两个顶层标签维护单个配置文件。 # docker运行 ``` docker build -t lalmax:init ./ docker run -it -p 1935:1935 -p 8080:8080 -p 4433:4433 -p 5544:5544 -p 8084:8084 -p 30000-30100:30000-30100/udp -p 1290:1290 -p 6001:6001/udp lalmax:init ``` # 架构 ![图片](document/images/init.png) # 支持的协议 ## 推流 (1) RTSP (2) SRT (3) RTMP (4) RTC(WHIP) (5) GB28181 具体的推流 URL 地址见[流地址说明](./document/stream_url.md) ## 拉流 (1) RTSP (2) SRT (3) RTMP (4) HLS(S)-TS (5) HTTP(S)-FLV (6) HTTP(S)-TS (7) RTC(WHEP) (8) HTTP(S)-FMP4 (9) HLS(S)-FMP4/LLHLS 具体的拉流 URL 地址见[流地址说明](./document/stream_url.md) ## [SRT](./document/srt.md) (1)使用gosrt库 (2)暂时不支持SRT加密 (3)支持H264/H265/AAC (4)可以对接OBS/VLC ``` 推流url srt://127.0.0.1:6001?streamid=#!::h=test110,m=publish 拉流url srt://127.0.0.1:6001?streamid=#!::h=test110,m=request ``` ## [WebRTC](./document/rtc.md) (1)支持WHIP推流和WHEP拉流,暂时只支持POST信令 (2)支持H264/G711A/G711U/OPUS (3)可以对接OBS、vue-wish (4)WHEP支持对接Safari HEVC (5)支持datachannel,只支持对接jessibuca播放器 (6)WHIP支持对接OBS 30.2 beta HEVC datachannel播放地址:webrtc://127.0.0.1:1290/webrtc/play/live/test110 ``` WHIP推流url http(s)://127.0.0.1:1290/webrtc/whip?streamid=test110 WHEP拉流url http(s)://127.0.0.1:1290/webrtc/whep?streamid=test110 ``` ## Http-fmp4 (1) 支持H264/H265/AAC/G711A/G711U ``` 拉流url http(s)://127.0.0.1:1290/live/m4s/test110.mp4 ``` ## HLS(fmp4/Low Latency) (1) 支持H264/H265/AAC/OPUS ``` 拉流url http(s)://127.0.0.1:1290/live/hls/test110/index.m3u8 ``` ## [GB28181](./document/gb28181.md) (1) 作为SIP服务器与设备进行SIP交互,使用单端口/多端口收流 (2) 提供相关API获取设备信息、播放某通道等功能 (3) 支持H264/H265/AAC/G711A/G711U (4) 支持TCP/UDP # QQ交流群 11818248 ================================================ FILE: build.sh ================================================ #!/usr/bin/env bash echo "build lalmax" go build -o lalmax main.go ================================================ FILE: conf/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIICpDCCAYwCCQDWutSYrD7joDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls b2NhbGhvc3QwHhcNMjAwOTA3MTAxNzI2WhcNMzAwOTA1MTAxNzI2WjAUMRIwEAYD VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDh dywjAnWKbaQCBtNA9mbvqVsM2S7MStaw9JkcZ45L/MtKxUoP/1egqdADtBPeZxZl XPxx+vcox9uO2nPZ+OyjedzMtgddK8ix0u2QPdOoc8+HW0fYGKO+YOXKUXUpawIg ZUhkUZAgrvlZUIewlZ9T0zMAsN3PUuZtg8ux+V3fY28l2QuulC5Q68i8m5vPVwj8 QRitxtKj66fE7Ut5xIc9XAuFJcvYVFSoZuB3/xNbyVev1e2bAe4kYq/+Jt2CTZf4 y7ESQsJn1Ybj0ippOFp9ZJq53roqEF9L0jUKNxNnJHhNc6niUfYehfvAvqXq/QiU B2qXpZonaq0AICMYFb+nAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAG98/mR8poSA MepIK6CfZsBzbDkl01R4+dw8RY9SwnOgFE5ddTh955UQvK1ORlVrTgBGkpH3djQd 1I4ADvrYIYHekgoRQX+fFNGyviEftzUUV4aq04JTttrrWrilgoF356pkkID0xSsq 9dr7at36wzV/Sbt9DrW8S9iBX6aUk3PPDxHJhi6xl+bYE6lepVlQ27FZjONo6cpg MnoRnsOhi/T+VHDUOaR+Xl4I77hESq6ipnV0GJfAAJ7R0hjfBmRbdIxo88iHN8vf iBHq2THAF+mzApI85ASltPNF+i8ZX0Fn9CvJFwci5eiaoEjjXMbon5X6/n5ovnW/ SaAkF3AobD0= -----END CERTIFICATE----- ================================================ FILE: conf/key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEA4XcsIwJ1im2kAgbTQPZm76lbDNkuzErWsPSZHGeOS/zLSsVK D/9XoKnQA7QT3mcWZVz8cfr3KMfbjtpz2fjso3nczLYHXSvIsdLtkD3TqHPPh1tH 2BijvmDlylF1KWsCIGVIZFGQIK75WVCHsJWfU9MzALDdz1LmbYPLsfld32NvJdkL rpQuUOvIvJubz1cI/EEYrcbSo+unxO1LecSHPVwLhSXL2FRUqGbgd/8TW8lXr9Xt mwHuJGKv/ibdgk2X+MuxEkLCZ9WG49IqaThafWSaud66KhBfS9I1CjcTZyR4TXOp 4lH2HoX7wL6l6v0IlAdql6WaJ2qtACAjGBW/pwIDAQABAoIBADTClml64daK4Z43 yqehAWWD0/Klv/W+bY7rLgkfkoTlmwzcLgCgV/kYw7yaHywkI3GE2O4zNDMu0YoU RJf1UCrREYI19nMvE7/JBB6E2UrKDv41thIzcd3S/vLhLPGMQOsjyFTxYTDEwUTN O3NvD+GlwoGe4cjqNVHbTYdQO09SnN7lJOIhetJ5MQWvH5kiOE+xh9AxEpYOI2K9 RMKKaUOBQjm0Q0ezMSZ1gfwDh7i642uDvVpeQ8uDg+1BZ/ulAJpaL6pE4KTUfCAR ygyQajvj2c93x/876ueacRBRECkvwoliOuQE8SmbjqQ5oDikHD8BNgbIwb/snZbt x3bj6kECgYEA+k5c9ZpqSkTtUK+lCVDV4zfSHOn//6J9pFr/f2o44nTsVUuO7Oqi jxikbx/BjEc6veRnLu2aqqMHuw7wV6EnS7Ikbc0lRNcXsBgN0WYbT26CLmAx0G1t 9DFyPdGT7/LZzNa11YqK/NuL2EFQ06SC7JA23+yfI5EJjOR0V6npU7sCgYEA5pgm 9PjyyfCvOF6wPbDRyGO359aXXtavKGPomOrV1FvTGt282NpbOOoOa7XlD3013A7i pRGiDYZO0FS/2ajYXz/uAaBh0LvKPhTZaXcezJ9GfA2HqeuTbQwhUsj17TLbgC1Y 7xSbwMwt8uRh3KdbLf220U/WHLPJMjXsME9nBwUCgYAnyCyeHFyoUSwmlsP0JxTX eBe84LP/PSQa6xuQdKF13H9zTv74SJJti80WnEV2thtv8s0zeDAMzrx7znQEeWh1 b2q6yNATkNwC8M/BaCkPBtFJ7Z/9MGc5WGJ/0L9ic4aKN9XOiqZsabhgNoFSIeNt Fb6i+EiSroqGCgkzpZ2f4QKBgHEqrLu+zVBjyWpNtgqgk2PX5HJn8yO9EnstBQK/ BS/R3Lmrprl5+BjnbSpZO1Atr9gOihZen/wpNNazMPA+F+ou8rxjnH2XG7r5+nTy 2++qHypUbYbrsQ9sS5JYQ7EkK2stVh8HKyUkT0yL3qcujuX0RNtWZgryBMSaiA5x eWuNAoGAI/87JrId6LzL9RSJFnXtkbYDNw1Zf7OTtjcXytn5U64sG/DEQNt8m6um q/JNM32vH8Fz+JcRVVGJlN2bSsxYxpIzhd7SBS7Cq0a6gHFeRsqcTA5qidxbUPLn itJ84Oz1Zo2U5MG+Zj+2sLm4v6611RkYyOiSEMvvV6ZJ/TGBPDc= -----END RSA PRIVATE KEY----- ================================================ FILE: conf/lalmax.conf.json ================================================ { "lalmax": { "srt_config": { "enable": true, "addr": ":6001" }, "rtc_config": { "enable": true, "ice_host_nat_to_ips": [], "ice_udp_mux_port": 4888, "ice_tcp_mux_port": 4888 }, "http_config": { "http_listen_addr": ":1290", "enable_https": true, "https_listen_addr": ":1233", "https_cert_file": "./conf/cert.pem", "https_key_file": "./conf/key.pem", "ctrl_auth_whitelist": { "ips": [], "secrets": [] } }, "fmp4_config": { "http": { "enable": true }, "hls": { "enable": true, "segment_count": 7, "segment_duration": 1, "part_duration": 200, "low_latency": false } }, "logic_config": { "gop_cache_num": 1, "single_gop_max_frame_num": 0 }, "server_id": "1", "http_notify": { "enable": false, "update_interval_sec": 5, "on_update": "http://127.0.0.1:10101/on_update", "on_group_start": "http://127.0.0.1:10101/on_group_start", "on_group_stop": "http://127.0.0.1:10101/on_group_stop", "on_stream_active": "http://127.0.0.1:10101/on_stream_active", "on_pub_start": "http://127.0.0.1:10101/on_pub_start", "on_pub_stop": "http://127.0.0.1:10101/on_pub_stop", "on_sub_start": "http://127.0.0.1:10101/on_sub_start", "on_sub_stop": "http://127.0.0.1:10101/on_sub_stop", "on_relay_pull_start": "http://127.0.0.1:10101/on_relay_pull_start", "on_relay_pull_stop": "http://127.0.0.1:10101/on_relay_pull_stop", "on_rtmp_connect": "http://127.0.0.1:10101/on_rtmp_connect", "on_server_start": "http://127.0.0.1:10101/on_server_start", "on_hls_make_ts": "http://127.0.0.1:10101/on_hls_make_ts" } }, "lal": { "# doc of config": "./document/lal_config.md", "conf_version": "v0.4.1", "rtmp": { "enable": true, "addr": ":1935", "rtmps_enable": true, "rtmps_addr": ":4935", "rtmps_cert_file": "./conf/cert.pem", "rtmps_key_file": "./conf/key.pem", "gop_num": 1, "single_gop_max_frame_num": 0, "merge_write_size": 0 }, "in_session": { "add_dummy_audio_enable": false, "add_dummy_audio_wait_audio_ms": 150 }, "default_http": { "http_listen_addr": ":8080", "https_listen_addr": ":4433", "https_cert_file": "./conf/cert.pem", "https_key_file": "./conf/key.pem" }, "httpflv": { "enable": true, "enable_https": true, "url_pattern": "/", "gop_num": 0, "single_gop_max_frame_num": 0 }, "hls": { "enable": false, "enable_https": false, "url_pattern": "/hls/", "out_path": "./lal_record/hls/", "fragment_duration_ms": 3000, "fragment_num": 6, "delete_threshold": 6, "cleanup_mode": 1, "use_memory_as_disk_flag": false, "sub_session_timeout_ms": 30000, "sub_session_hash_key": "" }, "httpts": { "enable": false, "enable_https": false, "url_pattern": "/", "gop_num": 0, "single_gop_max_frame_num": 0 }, "rtsp": { "enable": true, "addr": ":5544", "rtsps_enable": true, "rtsps_addr": ":5322", "rtsps_cert_file": "./conf/cert.pem", "rtsps_key_file": "./conf/key.pem", "out_wait_key_frame_flag": true, "auth_enable": false, "auth_method": 1, "username": "q191201771", "password": "pengrl" }, "record": { "enable_flv": false, "flv_out_path": "./lal_record/flv/", "enable_mpegts": false, "mpegts_out_path": "./lal_record/mpegts" }, "relay_push": { "enable": false, "addr_list": [] }, "static_relay_pull": { "enable": false, "addr": "" }, "http_api": { "enable": false, "addr": ":8083" }, "server_id": "1", "simple_auth": { "key": "q191201771", "dangerous_lal_secret": "pengrl", "pub_rtmp_enable": false, "sub_rtmp_enable": false, "sub_httpflv_enable": false, "sub_httpts_enable": false, "pub_rtsp_enable": false, "sub_rtsp_enable": false, "hls_m3u8_enable": false }, "pprof": { "enable": true, "addr": ":8084" }, "log": { "level": 2, "filename": "./logs/lalmax.log", "is_to_stdout": true, "is_rotate_daily": true, "short_file_flag": true, "timestamp_flag": true, "timestamp_with_ms_flag": true, "level_flag": true, "assert_behavior": 1 }, "debug": { "log_group_interval_sec": 30, "log_group_max_group_num": 10, "log_group_max_sub_num_per_group": 10 } } } ================================================ FILE: config/config.go ================================================ package config import ( "encoding/json" "fmt" "io/ioutil" "os" ) var defaultConfig Config type Config struct { SrtConfig SrtConfig `json:"srt_config"` // srt配置 RtcConfig RtcConfig `json:"rtc_config"` // rtc配置 HttpConfig HttpConfig `json:"http_config"` // http/https配置 Fmp4Config Fmp4Config `json:"fmp4_config"` // fmp4配置 GB28181Config GB28181Config `json:"gb28181_config"` // gb28181配置 ServerId string `json:"server_id"` // http 通知唯一标识 HttpNotifyConfig HttpNotifyConfig `json:"http_notify"` // http 通知配置 LalSvrConfigPath string `json:"lal_config_path"` // lal配置文件路径,兼容旧版配置 LogicConfig LogicConfig `json:"logic_config"` // 扩展流组配置 LalRawContent []byte `json:"-"` // lal 原始配置内容 ConfFilePath string `json:"-"` // 配置文件路径,用于持久化 } type SrtConfig struct { Enable bool `json:"enable"` // srt服务使能配置 Addr string `json:"addr"` // srt服务监听地址 } type RtcConfig struct { Enable bool `json:"enable"` // rtc服务使能配置 ICEHostNATToIPs []string `json:"ice_host_nat_to_ips"` // rtc服务公网IP,未设置使用内网 ICEUDPMuxPort int `json:"ice_udp_mux_port"` // rtc udp mux port ICETCPMuxPort int `json:"ice_tcp_mux_port"` // rtc tcp mux port WriteChanSize int `json:"write_chan_size"` } type HttpConfig struct { ListenAddr string `json:"http_listen_addr"` // http服务监听地址 EnableHttps bool `json:"enable_https"` // https使能标志 HttpsListenAddr string `json:"https_listen_addr"` // https监听地址 HttpsCertFile string `json:"https_cert_file"` // https cert 文件 HttpsKeyFile string `json:"https_key_file"` // https key 文件 CtrlAuthWhitelist CtrlAuthWhitelist `json:"ctrl_auth_whitelist"` } // CtrlAuthWhitelist 控制类接口鉴权。 type CtrlAuthWhitelist struct { IPs []string // 允许访问的远程 IP,零值时不生效 Secrets []string // 认证信息,零值时不生效 } type Fmp4Config struct { Http Fmp4HttpConfig `json:"http"` Hls Fmp4HlsConfig `json:"hls"` } type Fmp4HttpConfig struct { Enable bool `json:"enable"` // http-fmp4使能标志 } type Fmp4HlsConfig struct { Enable bool `json:"enable"` // hls使能标志 SegmentCount int `json:"segment_count"` // 分片个数,llhls默认7个 SegmentDuration int `json:"segment_duration"` // hls分片时长,默认1s PartDuration int `json:"part_duration"` // llhls part时长,默认200ms LowLatency bool `json:"low_latency"` // 是否开启llhls } type GB28181Config struct { Enable bool `json:"enable"` // gb28181使能标志 ListenAddr string `json:"listen_addr"` // gb28181监听地址 SipIP string `json:"sip_ip"` // sip 服务器公网IP SipPort uint16 `json:"sip_port"` // sip 服务器端口,默认 5060 Serial string `json:"serial"` // sip 服务器 id, 默认 34020000002000000001 Realm string `json:"realm"` // sip 服务器域,默认 3402000000 Username string `json:"username"` // sip 服务器账号 Password string `json:"password"` // sip 服务器密码 KeepaliveInterval int `json:"keepalive_interval"` // 心跳包时长 QuickLogin bool `json:"quick_login"` // 快速登陆,有keepalive就认为在线 MediaConfig GB28181MediaConfig `json:"media_config"` // 媒体服务器配置 } type GB28181MediaConfig struct { MediaIp string `json:"media_ip"` // 流媒体IP,用于在SDP中指定 ListenPort uint16 `json:"listen_port"` // tcp,udp监听端口 默认启动 MultiPortMaxIncrement uint16 `json:"multi_port_max_increment"` // 多端口范围 ListenPort+1至ListenPort+MultiPortMax } // ZlmCompatHookConfig ZLM 兼容 hook URL 配置 // 为什么独立结构体:隔离 ZLM 适配层,lalmax 原有字段保持不变 type ZlmCompatHookConfig struct { ZlmOnStreamChanged string `json:"zlm_on_stream_changed"` ZlmOnServerKeepalive string `json:"zlm_on_server_keepalive"` ZlmOnStreamNoneReader string `json:"zlm_on_stream_none_reader"` ZlmOnRtpServerTimeout string `json:"zlm_on_rtp_server_timeout"` ZlmOnRecordMp4 string `json:"zlm_on_record_mp4"` ZlmOnPublish string `json:"zlm_on_publish"` ZlmOnPlay string `json:"zlm_on_play"` ZlmOnStreamNotFound string `json:"zlm_on_stream_not_found"` ZlmOnServerStarted string `json:"zlm_on_server_started"` } // HasZlmHooks 任一 ZLM 兼容 hook 字段有值即返回 true // 为什么:ZLM 回调与 lalmax 原有回调二选一,此方法为判断条件 func (c ZlmCompatHookConfig) HasZlmHooks() bool { return c.ZlmOnStreamChanged != "" || c.ZlmOnServerKeepalive != "" || c.ZlmOnStreamNoneReader != "" || c.ZlmOnRtpServerTimeout != "" || c.ZlmOnRecordMp4 != "" || c.ZlmOnPublish != "" || c.ZlmOnPlay != "" || c.ZlmOnStreamNotFound != "" } type HttpNotifyConfig struct { Enable bool `json:"enable"` UpdateIntervalSec int `json:"update_interval_sec"` KeepaliveIntervalSec int `json:"keepalive_interval_sec"` HookTimeoutSec int `json:"hook_timeout_sec"` OnServerStart string `json:"on_server_start"` OnUpdate string `json:"on_update"` OnGroupStart string `json:"on_group_start"` OnGroupStop string `json:"on_group_stop"` OnStreamActive string `json:"on_stream_active"` OnPubStart string `json:"on_pub_start"` OnPubStop string `json:"on_pub_stop"` OnSubStart string `json:"on_sub_start"` OnSubStop string `json:"on_sub_stop"` OnRelayPullStart string `json:"on_relay_pull_start"` OnRelayPullStop string `json:"on_relay_pull_stop"` OnRtmpConnect string `json:"on_rtmp_connect"` OnHlsMakeTs string `json:"on_hls_make_ts"` // --- ZLM 兼容 hook 配置 --- ZlmCompatHookConfig } type LogicConfig struct { GopCacheNum int `json:"gop_cache_num"` SingleGopMaxFrameNum int `json:"single_gop_max_frame_num"` } func Open(filepath string) error { data, err := ioutil.ReadFile(filepath) if err != nil { return err } err = Unmarshal(data) if err != nil { return err } return nil } func Unmarshal(data []byte) error { var file struct { LalMax json.RawMessage `json:"lalmax"` Lal json.RawMessage `json:"lal"` } if err := json.Unmarshal(data, &file); err != nil { return err } var cfg Config if len(file.LalMax) != 0 { if err := unmarshalConfig(file.LalMax, &cfg); err != nil { return err } cfg.LalRawContent = append([]byte(nil), file.Lal...) } else { if err := unmarshalConfig(data, &cfg); err != nil { return err } } defaultConfig = cfg return nil } func unmarshalConfig(data []byte, cfg *Config) error { if err := json.Unmarshal(data, cfg); err != nil { return err } var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } if cfg.LalSvrConfigPath == "" { var legacy struct { LalSvrConfigPath string `json:"lal_config_path:"` } if err := json.Unmarshal(data, &legacy); err != nil { return err } cfg.LalSvrConfigPath = legacy.LalSvrConfigPath } if _, ok := raw["logic_config"]; !ok { var legacy struct { LogicConfig LogicConfig `json:"hook_config"` } if err := json.Unmarshal(data, &legacy); err != nil { return err } cfg.LogicConfig = legacy.LogicConfig } if _, ok := raw["fmp4_config"]; !ok { var legacy struct { Http Fmp4HttpConfig `json:"httpfmp4_config"` Hls Fmp4HlsConfig `json:"hls_config"` } if err := json.Unmarshal(data, &legacy); err != nil { return err } cfg.Fmp4Config.Http = legacy.Http cfg.Fmp4Config.Hls = legacy.Hls } return nil } func GetConfig() *Config { return &defaultConfig } // SaveToFile 将当前配置持久化到配置文件 // 为什么:setServerConfig 动态修改后需落盘,重启后配置仍生效 func (c *Config) SaveToFile() error { if c.ConfFilePath == "" { return nil } data, err := os.ReadFile(c.ConfFilePath) if err != nil { return fmt.Errorf("read config file: %w", err) } var file map[string]json.RawMessage if err := json.Unmarshal(data, &file); err != nil { return fmt.Errorf("parse config file: %w", err) } lalmax, err := json.MarshalIndent(c, "", " ") if err != nil { return fmt.Errorf("marshal lalmax config: %w", err) } file["lalmax"] = lalmax out, err := json.MarshalIndent(file, "", " ") if err != nil { return fmt.Errorf("marshal config file: %w", err) } out = append(out, '\n') return os.WriteFile(c.ConfFilePath, out, 0o644) } ================================================ FILE: config/config_test.go ================================================ package config import ( "strings" "testing" ) func TestUnmarshalStructuredConfig(t *testing.T) { raw := []byte(`{ "lalmax": { "srt_config": { "enable": true, "addr": ":6001" }, "server_id": "lalmax-1" }, "lal": { "rtmp": { "enable": true, "addr": ":1935" } } }`) if err := Unmarshal(raw); err != nil { t.Fatalf("unmarshal structured config: %v", err) } cfg := GetConfig() if !cfg.SrtConfig.Enable || cfg.SrtConfig.Addr != ":6001" { t.Fatalf("unexpected srt config: %+v", cfg.SrtConfig) } if cfg.ServerId != "lalmax-1" { t.Fatalf("unexpected server id: %s", cfg.ServerId) } if !strings.Contains(string(cfg.LalRawContent), `"rtmp"`) { t.Fatalf("lal raw content not preserved: %s", string(cfg.LalRawContent)) } } func TestUnmarshalLegacyConfig(t *testing.T) { raw := []byte(`{ "srt_config": { "enable": true, "addr": ":6001" }, "httpfmp4_config": { "enable": true }, "hls_config": { "enable": true, "segment_count": 3, "segment_duration": 2, "part_duration": 100, "low_latency": true }, "hook_config": { "gop_cache_num": 3, "single_gop_max_frame_num": 120 }, "lal_config_path:": "./conf/lalserver.conf.json" }`) if err := Unmarshal(raw); err != nil { t.Fatalf("unmarshal legacy config: %v", err) } cfg := GetConfig() if !cfg.SrtConfig.Enable || cfg.SrtConfig.Addr != ":6001" { t.Fatalf("unexpected srt config: %+v", cfg.SrtConfig) } if cfg.LalSvrConfigPath != "./conf/lalserver.conf.json" { t.Fatalf("unexpected lal config path: %s", cfg.LalSvrConfigPath) } if cfg.LogicConfig.GopCacheNum != 3 || cfg.LogicConfig.SingleGopMaxFrameNum != 120 { t.Fatalf("unexpected legacy logic config: %+v", cfg.LogicConfig) } if !cfg.Fmp4Config.Http.Enable { t.Fatalf("unexpected legacy fmp4 http config: %+v", cfg.Fmp4Config.Http) } if !cfg.Fmp4Config.Hls.Enable || cfg.Fmp4Config.Hls.SegmentCount != 3 || cfg.Fmp4Config.Hls.SegmentDuration != 2 || cfg.Fmp4Config.Hls.PartDuration != 100 || !cfg.Fmp4Config.Hls.LowLatency { t.Fatalf("unexpected legacy fmp4 hls config: %+v", cfg.Fmp4Config.Hls) } if len(cfg.LalRawContent) != 0 { t.Fatalf("legacy config should not set lal raw content: %s", string(cfg.LalRawContent)) } } func TestUnmarshalStructuredFmp4ConfigKeepsExplicitZero(t *testing.T) { raw := []byte(`{ "lalmax": { "fmp4_config": { "http": { "enable": false }, "hls": { "enable": true, "segment_count": 0, "segment_duration": 0, "part_duration": 0, "low_latency": false } }, "httpfmp4_config": { "enable": true }, "hls_config": { "enable": true, "segment_count": 3, "segment_duration": 2, "part_duration": 100, "low_latency": true } } }`) if err := Unmarshal(raw); err != nil { t.Fatalf("unmarshal structured config: %v", err) } cfg := GetConfig() if cfg.Fmp4Config.Http.Enable { t.Fatalf("explicit fmp4 http config should not be overwritten: %+v", cfg.Fmp4Config.Http) } if !cfg.Fmp4Config.Hls.Enable || cfg.Fmp4Config.Hls.SegmentCount != 0 || cfg.Fmp4Config.Hls.SegmentDuration != 0 || cfg.Fmp4Config.Hls.PartDuration != 0 || cfg.Fmp4Config.Hls.LowLatency { t.Fatalf("explicit fmp4 hls config should not be overwritten: %+v", cfg.Fmp4Config.Hls) } } func TestUnmarshalStructuredLogicConfigKeepsExplicitZero(t *testing.T) { raw := []byte(`{ "lalmax": { "logic_config": { "gop_cache_num": 0, "single_gop_max_frame_num": 0 }, "hook_config": { "gop_cache_num": 3, "single_gop_max_frame_num": 120 } } }`) if err := Unmarshal(raw); err != nil { t.Fatalf("unmarshal structured config: %v", err) } cfg := GetConfig() if cfg.LogicConfig.GopCacheNum != 0 || cfg.LogicConfig.SingleGopMaxFrameNum != 0 { t.Fatalf("explicit logic config should not be overwritten: %+v", cfg.LogicConfig) } } ================================================ FILE: document/api.md ================================================ # HTTP API 总览 `lalmax` 对外建议统一只暴露一个 HTTP 管理入口,也就是 `lalmax.http_config.http_listen_addr`,示例配置通常是 `:1290`。 `lal.http_api` 仍然可以保留给排查 `lal` 原生行为时使用,但默认建议关闭,不要和 `lalmax` 的管理入口混用。 建议配合以下文档一起看: - [api_gateway.md](./api_gateway.md):统一入口和状态聚合说明 - [hook_api.md](./hook_api.md):Hook 查询与订阅接口 - [hook_plugin_architecture.md](./hook_plugin_architecture.md):HookHub 与插件化架构 - [lal_api.md](./lal_api.md):`lal` 原生 HTTP API 的定位和兼容关系 ## 基本约定 - 对外统一入口默认是 `http://127.0.0.1:1290` - `/api/stat/*`、`/api/ctrl/*`、`/api/hook/*` 共用同一套鉴权配置:`lalmax.http_config.ctrl_auth_whitelist` - 如果鉴权失败,HTTP 状态码仍然是 `200`,返回体里的 `error_code` 为 `401` - 除 `GET /api/hook/stream` 之外,其余接口都返回 JSON 统一返回结构如下: ```json { "error_code": 0, "desp": "succ", "data": {} } ``` 常见 `error_code` 如下: | error_code | desp | 说明 | | --- | --- | --- | | 0 | succ | 调用成功 | | 401 | Unauthorized | 鉴权失败 | | 1001 | group not found | 流分组不存在,或者仅传 `stream_name` 时无法唯一定位 | | 1002 | param missing | 必填参数缺失 | | 1003 | session not found | 会话不存在 | | 2001 | 具体错误信息见 `desp` | `start_relay_pull` 执行失败 | | 2002 | 具体错误信息见 `desp` | `start_rtp_pub` 执行失败 | ## 当前接口列表 ### 统计接口 - `GET /api/stat/group` - `GET /api/stat/all_group` - `GET /api/stat/lal_info` ### 控制接口 - `POST /api/ctrl/start_relay_pull` - `GET /api/ctrl/stop_relay_pull` - `POST /api/ctrl/stop_relay_pull` - `POST /api/ctrl/kick_session` - `POST /api/ctrl/start_rtp_pub` - `POST /api/ctrl/stop_rtp_pub` ### Hook 接口 - `GET /api/hook/recent` - `GET /api/hook/stream` ## 统计接口 ### `GET /api/stat/group` 查询单个流分组的当前状态。 请求参数: - `stream_name`:必填,流名 - `app_name`:可选,应用名 说明: - 如果同一个 `stream_name` 只对应一个流分组,可以只传 `stream_name` - 如果同一个 `stream_name` 在多个 `app_name` 下都存在,建议同时传 `app_name + stream_name` - 当前返回结果会在兼容 `lal` 原生字段的基础上,额外补充 `lalmax.ext_subs` 请求示例: ```bash curl "http://127.0.0.1:1290/api/stat/group?stream_name=test110" curl "http://127.0.0.1:1290/api/stat/group?app_name=live&stream_name=test110" ``` 返回示例: ```json { "error_code": 0, "desp": "succ", "data": { "stream_name": "test110", "app_name": "live", "audio_codec": "AAC", "video_codec": "H264", "video_width": 1920, "video_height": 1080, "pub": { "session_id": "RTMPPUB1", "protocol": "RTMP", "base_type": "PUB" }, "subs": [ { "session_id": "RTMPSUB1", "protocol": "RTMP", "base_type": "SUB" }, { "session_id": "whep-123", "protocol": "WHEP", "base_type": "SUB" } ], "pull": { "base_type": "PULL" }, "in_frame_per_sec": [], "lalmax": { "ext_subs": [ { "session_id": "whep-123", "protocol": "WHEP", "base_type": "SUB" } ] } } } ``` 字段语义: - `subs`:统一后的订阅者视图,包含 `lal` 原生订阅者和 `lalmax` 扩展订阅者 - `lalmax.ext_subs`:只包含 `lalmax` 扩展层维护的订阅者,便于业务侧区分来源 ### `GET /api/stat/all_group` 查询当前所有流分组。 请求示例: ```bash curl "http://127.0.0.1:1290/api/stat/all_group" ``` 返回示例: ```json { "error_code": 0, "desp": "succ", "data": { "groups": [ { "stream_name": "test110", "app_name": "live", "lalmax": { "ext_subs": [] } } ] } } ``` 其中 `groups[*]` 的结构和 `/api/stat/group` 的 `data` 完全一致。 ### `GET /api/stat/lal_info` 查询服务基础信息。 请求示例: ```bash curl "http://127.0.0.1:1290/api/stat/lal_info" ``` 该接口直接返回内嵌 `lal` 的运行信息,常见字段包括: - `server_id` - `bin_info` - `lal_version` - `api_version` - `notify_version` - `start_time` ## 控制接口 ### `POST /api/ctrl/start_relay_pull` 让服务主动去远端拉流。 请求体为 JSON,必填字段: - `url` 常用可选字段: - `stream_name` - `pull_timeout_ms` - `pull_retry_num` - `auto_stop_pull_after_no_out_ms` - `rtsp_mode` - `debug_dump_packet` 当前程序里的默认值如下: - `pull_timeout_ms` 默认 `10000` - `pull_retry_num` 默认 `0` - `auto_stop_pull_after_no_out_ms` 默认 `-1` - `rtsp_mode` 默认 `0`,也就是 TCP 请求示例: ```bash curl -H "Content-Type: application/json" \ -X POST \ -d "{\"url\":\"rtmp://127.0.0.1/live/test110\"}" \ "http://127.0.0.1:1290/api/ctrl/start_relay_pull" ``` 说明: - 接口返回成功,只代表命令已经被接受 - 是否真的拉流成功,需要看后续状态接口,或者看 Hook 事件中的 `on_relay_pull_start`、`on_update` ### `GET /api/ctrl/stop_relay_pull` ### `POST /api/ctrl/stop_relay_pull` 关闭指定的 relay pull 会话。 当前实现里,这个接口无论用 `GET` 还是 `POST`,都从查询参数里读取: - `stream_name`:必填 请求示例: ```bash curl "http://127.0.0.1:1290/api/ctrl/stop_relay_pull?stream_name=test110" curl -X POST "http://127.0.0.1:1290/api/ctrl/stop_relay_pull?stream_name=test110" ``` 说明: - 也可以用 `kick_session` 关闭 pull 会话 ### `POST /api/ctrl/kick_session` 强制关闭指定会话。 请求体为 JSON,必填字段: - `stream_name` - `session_id` 请求示例: ```bash curl -H "Content-Type: application/json" \ -X POST \ -d "{\"stream_name\":\"test110\",\"session_id\":\"FLVSUB1\"}" \ "http://127.0.0.1:1290/api/ctrl/kick_session" ``` 适用对象: - 推流会话 - 拉流会话 - relay pull 会话 ### `POST /api/ctrl/start_rtp_pub` 打开一个 GB28181/RTP 接收会话。 请求体为 JSON,必填字段: - `stream_name` 常用可选字段: - `port` - `timeout_ms` - `is_tcp_flag` - `debug_dump_packet` 当前程序里的默认值: - `timeout_ms` 默认 `60000` 请求示例: ```bash curl -H "Content-Type: application/json" \ -X POST \ -d "{\"stream_name\":\"gb28181-test\",\"port\":0}" \ "http://127.0.0.1:1290/api/ctrl/start_rtp_pub" ``` 说明: - `port=0` 表示由服务自动分配端口 - 成功后会返回 `session_id` 和最终监听端口 ### `POST /api/ctrl/stop_rtp_pub` 关闭 GB28181/RTP 接收会话。 当前实现支持两种传参方式: - 查询参数:`stream_name` 或 `session_id` - JSON 请求体:`stream_name` 或 `session_id` 两者至少传一个。 请求示例: ```bash curl -X POST "http://127.0.0.1:1290/api/ctrl/stop_rtp_pub?stream_name=gb28181-test" curl -H "Content-Type: application/json" \ -X POST \ -d "{\"session_id\":\"PSSUB1\"}" \ "http://127.0.0.1:1290/api/ctrl/stop_rtp_pub" ``` 成功后会返回被关闭的 `session_id`。 ## Hook 接口 ### `GET /api/hook/recent` 读取最近的 Hook 事件快照。 常用查询参数: - `limit`:返回条数,默认 `20` - `app_name` - `stream_name` - `session_id` - `event` - `events`:多个事件名,逗号分隔 请求示例: ```bash curl "http://127.0.0.1:1290/api/hook/recent?limit=5" curl "http://127.0.0.1:1290/api/hook/recent?stream_name=test110&events=on_group_start,on_stream_active,on_group_stop,on_update" ``` ### `GET /api/hook/stream` 使用 SSE 持续订阅 Hook 事件。 它和 `/api/hook/recent` 使用同一套过滤参数。连接建立后,会先回放最近一批命中的事件,然后继续推送实时事件。 请求示例: ```bash curl -N "http://127.0.0.1:1290/api/hook/stream" curl -N "http://127.0.0.1:1290/api/hook/stream?stream_name=test110&events=on_update,on_group_stop" ``` 返回格式示例: ```text id: 12 event: on_pub_start data: {"server_id":"1","session_id":"RTMPPUB1","protocol":"RTMP","base_type":"PUB","stream_name":"test110"} ``` 当前 Hook 体系里的事件名称、语义、过滤规则和插件化接入方式,请直接参考 [hook_api.md](./hook_api.md) 和 [hook_plugin_architecture.md](./hook_plugin_architecture.md)。 ================================================ FILE: document/api_gateway.md ================================================ # API Gateway `lalmax` 作为 `lal` 的统一 API 网关,对外建议只暴露一个 HTTP 入口。 Hook 体系的详细设计见 [hook_plugin_architecture.md](./hook_plugin_architecture.md)。 默认入口来自: ```json { "lalmax": { "http_config": { "http_listen_addr": ":1290" } } } ``` ## Exposed Routes ### Stat - `GET /api/stat/group` - `GET /api/stat/all_group` - `GET /api/stat/lal_info` ### Control - `POST /api/ctrl/start_relay_pull` - `GET /api/ctrl/stop_relay_pull` - `POST /api/ctrl/stop_relay_pull` - `POST /api/ctrl/kick_session` - `POST /api/ctrl/start_rtp_pub` - `POST /api/ctrl/stop_rtp_pub` ### Hook - `GET /api/hook/recent` - `GET /api/hook/stream` ## Why Use lalmax Gateway - `lal` 原生流状态仍由 `lal` 负责,避免双事实源 - `lalmax` 在响应中补充扩展协议订阅者统计 - hook 事件统一从 `lalmax` 读取,不必同时维护 HTTP notify 和内部状态 - 控制接口、查询接口、hook 接口共用一套鉴权策略 ## Group Visibility `lalmax` 获取 group 视图的方式是: 1. 通过内嵌 `lal` 的 `StatAllGroup()` 获取原生 group 快照 2. 通过 `lalmax/logic` 获取扩展订阅者状态 3. 聚合成统一视图后再对外返回或分发到 hook hub 这样 `lalmax` 可以知道 `lal group` 中所有流的原生状态,同时保留自己的扩展消费层状态。 ## Stat Response Extension `/api/stat/group` 和 `/api/stat/all_group` 在保持 `lal` 原有字段的同时,会额外返回一个 `lalmax` 扩展块。 兼容原则如下: - 原有 `stream_name`、`app_name`、`pub`、`subs`、`pull`、`in_frame_per_sec` 等字段继续保留 - `subs` 仍然表示统一后的订阅者视图,其中会合并 `lal` 原生订阅者和 `lalmax` 扩展订阅者 - `lalmax.ext_subs` 只列出来自 `lalmax` 扩展层的订阅者,便于业务侧区分来源 示例: ```json { "error_code": 0, "desp": "succ", "data": { "stream_name": "camera01", "app_name": "live", "pub": {}, "subs": [ { "session_id": "RTMPSUB1", "protocol": "RTMP" }, { "session_id": "whep-123", "protocol": "WHEP" } ], "pull": {}, "in_frame_per_sec": [], "lalmax": { "ext_subs": [ { "session_id": "whep-123", "protocol": "WHEP" } ] } } } ``` 如果业务只想拿 `lal` 原生兼容视图,可以继续只读原字段;如果业务需要知道 `lalmax` 在该流上维护了哪些扩展订阅者,则读取 `lalmax.ext_subs`。 ## Control API Scope `/api/ctrl/*` 仍然保持轻量控制接口定位,不会在响应中额外塞入完整流状态、订阅者列表或 `lalmax` 扩展统计。 原因是: - 控制接口的职责是执行动作并返回动作结果 - 流状态属于查询语义,应统一从 `/api/stat/*` 获取 - 避免控制响应膨胀,降低兼容性和调用方解析成本 ================================================ FILE: document/config.md ================================================ # lalmax 配置说明 本文档说明 `conf/lalmax.conf.json` 里 `lalmax` 这一段的配置。 `lal` 原生配置请看 [lal_config.md](./lal_config.md)。 ## 推荐配置结构 当前程序推荐使用一个统一的配置文件,并按顶层标签拆开: ```json { "lalmax": { "server_id": "1", "srt_config": {}, "rtc_config": {}, "http_config": {}, "fmp4_config": {}, "logic_config": {}, "http_notify": {}, "gb28181_config": {} }, "lal": {} } ``` 说明: - `lalmax`:`lalmax` 自己的扩展能力配置 - `lal`:内嵌 `lal` 的原生配置 如果同时提供了顶层 `lal` 标签,程序会优先使用这段内容作为 `lal` 的原生配置,不再读取 `lal_config_path` 指向的文件。 ## srt_config SRT 服务配置。 - `enable`:是否启用 SRT - `addr`:SRT 监听地址,示例 `:6001` 示例: ```json { "enable": true, "addr": ":6001" } ``` ## rtc_config RTC 服务配置。目前主要用于 WHIP、WHEP 和 Jessibuca 播放链路。 - `enable`:是否启用 RTC - `ice_host_nat_to_ips`:对外暴露的 ICE 地址列表;为空时使用本机可用地址 - `ice_udp_mux_port`:ICE UDP 复用端口 - `ice_tcp_mux_port`:ICE TCP 复用端口 - `write_chan_size`:RTC 订阅侧写队列大小;如果填 `0`,程序会自动使用 `1024` 示例: ```json { "enable": true, "ice_host_nat_to_ips": ["192.168.0.1"], "ice_udp_mux_port": 4888, "ice_tcp_mux_port": 4888, "write_chan_size": 1024 } ``` ## http_config `lalmax` 自己的 HTTP/HTTPS 配置。管理接口、RTC 信令、HTTP-FMP4、HLS-FMP4/LLHLS 都依赖这里。 - `http_listen_addr`:HTTP 监听地址,示例 `:1290` - `enable_https`:是否启用 HTTPS - `https_listen_addr`:HTTPS 监听地址 - `https_cert_file`:HTTPS 证书文件 - `https_key_file`:HTTPS 私钥文件 - `ctrl_auth_whitelist`:管理接口鉴权配置 `ctrl_auth_whitelist` 的字段如下: - `secrets`:允许的令牌列表,请求时通过查询参数 `token` 传入 - `ips`:允许访问的客户端 IP 列表 当前鉴权覆盖范围: - `/api/stat/*` - `/api/ctrl/*` - `/api/hook/*` 规则说明: - 如果 `secrets` 和 `ips` 都为空,表示不做鉴权 - 如果两者都配置了,请求必须同时满足两项 - 鉴权失败时,HTTP 状态码仍然是 `200`,返回体里的 `error_code` 是 `401` 示例: ```json { "http_listen_addr": ":1290", "enable_https": true, "https_listen_addr": ":1233", "https_cert_file": "./conf/cert.pem", "https_key_file": "./conf/key.pem", "ctrl_auth_whitelist": { "ips": ["192.168.1.10"], "secrets": ["EC3D1536-5D93-4BD6-9FBD-96A52CB1596D"] } } ``` ## fmp4_config `lalmax` 的 FMP4 相关配置,分成 `http` 和 `hls` 两段。 ### fmp4_config.http HTTP-FMP4 配置。 - `enable`:是否启用 HTTP-FMP4 ### fmp4_config.hls HLS-FMP4 / LLHLS 配置。 - `enable`:是否启用 HLS-FMP4 / LLHLS - `segment_count`:m3u8 保留的切片数量 - `segment_duration`:切片时长,单位秒 - `part_duration`:LLHLS part 时长,单位毫秒 - `low_latency`:是否启用低延迟 HLS 示例: ```json { "http": { "enable": true }, "hls": { "enable": true, "segment_count": 7, "segment_duration": 1, "part_duration": 200, "low_latency": false } } ``` ## logic_config `lalmax` 扩展流分组配置。 - `gop_cache_num`:GOP 缓存数量 - `single_gop_max_frame_num`:单个 GOP 最多缓存多少帧;`0` 表示自动判断 示例: ```json { "gop_cache_num": 1, "single_gop_max_frame_num": 0 } ``` ## server_id 服务实例标识。 这个值会出现在: - Hook 事件的 `server_id` - HTTP 回调的 payload 示例: ```json "server_id": "1" ``` ## http_notify 内置 HTTP 回调插件配置。 先说明两件事: - 这段配置控制的是“是否向外发 HTTP 回调” - 不影响内部 HookHub、本地插件注册、`/api/hook/*` 查询和订阅能力 字段如下: - `enable`:是否启用内置 HTTP 回调插件 - `update_interval_sec`:周期性生成 `on_update` 事件的间隔秒数 - `on_server_start` - `on_update` - `on_group_start` - `on_group_stop` - `on_stream_active` - `on_pub_start` - `on_pub_stop` - `on_sub_start` - `on_sub_stop` - `on_relay_pull_start` - `on_relay_pull_stop` - `on_rtmp_connect` - `on_hls_make_ts` 关于 `update_interval_sec`,当前程序的行为是: - 大于 `0` 时,`lalmax` 会按这个周期向 HookHub 发布 `on_update` - 即使 `enable=false`,这些事件依然会进入 HookHub,也能被 `/api/hook/*` 和进程内插件看到 - 只有在 `enable=true` 且对应回调地址非空时,内置插件才会真正向外发 HTTP 请求 示例: ```json { "enable": true, "update_interval_sec": 5, "on_update": "http://127.0.0.1:10101/on_update", "on_group_start": "http://127.0.0.1:10101/on_group_start", "on_group_stop": "http://127.0.0.1:10101/on_group_stop", "on_stream_active": "http://127.0.0.1:10101/on_stream_active", "on_pub_start": "http://127.0.0.1:10101/on_pub_start", "on_pub_stop": "http://127.0.0.1:10101/on_pub_stop", "on_sub_start": "http://127.0.0.1:10101/on_sub_start", "on_sub_stop": "http://127.0.0.1:10101/on_sub_stop", "on_relay_pull_start": "http://127.0.0.1:10101/on_relay_pull_start", "on_relay_pull_stop": "http://127.0.0.1:10101/on_relay_pull_stop", "on_rtmp_connect": "http://127.0.0.1:10101/on_rtmp_connect", "on_server_start": "http://127.0.0.1:10101/on_server_start", "on_hls_make_ts": "http://127.0.0.1:10101/on_hls_make_ts" } ``` 建议: - 对外统一只配置 `lalmax.http_notify` - `lal` 配置段里的原生 `http_notify` 建议保持关闭 - 如果两边同时往外发,尤其都带 `on_update`,很容易出现重复回调 Hook 事件的具体语义请看 [hook_api.md](./hook_api.md)。 ## gb28181_config GB28181 服务配置。 字段如下: - `enable`:是否启用 GB28181 - `listen_addr`:SIP 服务监听 IP,默认会补成 `0.0.0.0` - `sip_ip`:SIP 对外地址,生成设备交互内容时会用到 - `sip_port`:SIP 端口,默认 `5060` - `serial`:平台 ID,默认 `34020000002000000001` - `realm`:平台域,默认 `3402000000` - `username`:认证用户名 - `password`:认证密码 - `keepalive_interval`:设备心跳周期,默认 `60` - `quick_login`:是否允许设备通过 Keepalive 快速建档 - `media_config`:媒体端口配置 `media_config` 字段如下: - `media_ip`:在 SDP 中对外声明的媒体 IP;默认 `0.0.0.0` - `listen_port`:固定媒体端口起点;默认 `30000` - `multi_port_max_increment`:多端口模式下可分配的附加端口范围;默认 `3000` 示例: ```json { "enable": true, "listen_addr": "0.0.0.0", "sip_ip": "100.100.100.101", "sip_port": 5060, "serial": "34020000002000000001", "realm": "3402000000", "username": "admin", "password": "admin123", "keepalive_interval": 60, "quick_login": false, "media_config": { "media_ip": "100.100.100.101", "listen_port": 30000, "multi_port_max_increment": 3000 } } ``` ## 兼容说明 当前程序还兼容一部分旧配置写法: - 旧版平铺配置仍然可以读 - `lal_config_path` 仍然保留兼容 - 如果没有 `logic_config`,会尝试兼容旧字段 `hook_config` - 如果没有 `fmp4_config`,会尝试兼容旧字段 `httpfmp4_config` 和 `hls_config` 但新项目建议统一使用当前这套结构,也就是: - 顶层使用 `lalmax` 和 `lal` - `lalmax` 内部使用当前代码里的 snake_case 字段名 ## 相关文档 - [lal_config.md](./lal_config.md):`lal` 原生配置 - [api.md](./api.md):统一管理 API 总览 - [hook_api.md](./hook_api.md):Hook 查询与订阅接口 - [hook_plugin_architecture.md](./hook_plugin_architecture.md):HookHub 与插件化架构 ================================================ FILE: document/gb28181.md ================================================ # GB28181 lalmax的gb28181功能为单端口监听(TCP/UDP监听端口可以使用tcp_listen_port和udp_listen_port进行配置),根据INVITE消息中的ssrc来区分具体流名,详细的配置见gb28181_config # GB28181相关HTTP API 目前主要提供的API如下 [/api/gb/device_infos](#apigbdevice_infos) [/api/gb/update_all_notify](#apigbupdate_all_notify) [/api/gb/update_notify](#apigbupdate_notify) [/api/gb/start_play](#apigbstart_play) [/api/gb/ptz_direction](#apigbptz_direction) [/api/gb/ptz_zoom](#apigbptz_zoom) [/api/gb/ptz_fi](#apigbptz_fi) [/api/gb/ptz_preset](#apigbptz_preset) [/api/gb/ptz_stop](#apigbptz_stop) 返回信息格式如下 ``` { "code": , // 状态码 "msg": , // 状态码对应的解释 "data": // 具体返回信息 } 其中code和msg的对应关系如下 1000: success 1001: 请求参数错误 1002: 服务繁忙 1003: 设备暂时未注册 1004: 设备停止播放错误 ``` ## /api/gb/device_infos API含义: 获取注册的设备信息 Method: GET data信息: ``` "data": { "device_items": [ { "device_id": , // 设备ID "channels": [ // 通道信息 { "channel_id": , // 通道ID "name": , // 设备名称 "manufacturer": , // 制造厂商 "owner: , // 设备归属 "civilCode": , // 行政区划编码 "address": , // 地址 "status": , // 设备状态,ON/OFF "longitude": , // 经度 "latitude": // 纬度 } ] } ] } ``` 示例 ``` curl http://127.0.0.1:1290/api/gb/device_infos -X GET { "code":1000, "msg":"success", "data":{ "device_items":[ { "device_id":"34020000001320000001", "channels":[ { "channel_id":"34020000001320000001", "name":"Camera 01", "manufacturer":"Hikvision", "owner":"Owner", "civilCode":"3402000000", "address":"Address", "status":"ON", "longitude":"", "latitude":"" } ] } ] } } ``` ## /api/gb/update_all_notify API含义: 更新全部信息 Method: POST 请求body信息: 无 data信息: 无 示例: ``` curl http://127.0.0.1:1290/api/gb/update_all_notify -X POST { "code":1000, "msg":"success" } ``` ## /api/gb/update_notify API含义: 更新某个设备信息 Method: POST 请求body信息 ``` { "device_id": // 设备ID } ``` data信息: 无 示例: ``` curl "http://127.0.0.1:1290/api/gb/update_notify" -X POST -d '{"device_id": "34020000001320000001"}' { "code":1000, "msg":"success" } ``` ## /api/gb/start_play API含义: 播放某通道 Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID "network": , // 传输协议类型, tcp/udp "stream_name": // 对应的流名,不指定的话就使用channel_id "single_port": // 是否单端口 "dump_file_name": // dump文件路径 } ``` data信息: ``` { "stream_name": // 流名 } ``` 示例: ``` curl "http://127.0.0.1:1290/api/gb/start_play" -X POST -d '{"device_id": "34020000001320000001", "channel_id": "34020000001320000001", "network": "udp", "stream_name": "test001}' { "code":1000, "msg":"success" "data": { "stream_name": "test001" } } ``` ## /api/gb/stop_play API含义: 停止播放某通道 Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID "stream_name": // ssrc对应的流名,不指定的话就使用channel_id } ``` data信息: 无 示例: ``` curl "http://127.0.0.1:1290/api/gb/stop_play" -X POST -d '{"device_id": "34020000001320000001", "channel_id": "34020000001320000001", "stream_name": "test001}' { "code":1000, "msg":"success" } ``` ## /api/gb/ptz_direction API含义: ptz 方向控制 Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID "up": , // 上 "down": // 下 "left": // 左 "right": // 右 "speed": // 步长,1~8 } ``` ## /api/gb/ptz_zoom API含义: 镜头变倍 Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID "zoom_out": , // 缩小 "zoom_in": // 放大 "speed": // 步长,1~8 } ``` ## /api/gb/ptz_fi API含义: 光圈控制和聚焦控制 Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID "iris_in": , // 光圈小 "iris_out": // 光圈大 "focus_near": // 聚焦近 "focus_far": // 聚焦远 "speed": // 步长,1~8 } ``` ## /api/gb/ptz_preset API含义: 预置位操作 Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID "cmd": , // 0:添加,1:删除,2:调用 "point": // 预置点 } ``` ## /api/gb/ptz_stop API含义: 停止ptz Method: POST 请求body信息: ``` { "device_id": , // 设备ID "channel_id": , // 通道ID } ``` # 海康设备接入 ![图片](images/gb-hk.png) ================================================ FILE: document/hook_api.md ================================================ # Hook API `lalmax` 统一托管 `lal` 的 notify 事件,并补充 `lalmax` 自身扩展订阅状态。 如果需要理解完整分层、调用链、插件职责和设计边界,见 [hook_plugin_architecture.md](./hook_plugin_architecture.md)。 默认建议: - 对外状态与控制走 `lalmax` 的 `/api/stat/*` 和 `/api/ctrl/*` - 对外 hook 事件读取也走 `lalmax` - `lal.http_api` 和外部业务直接对接 `lal` 原生 notify 只作为调试手段 ## Event Source `lalmax` 内部将以下事件统一写入 hook hub: - `on_server_start` - `on_update` - `on_group_start` - `on_group_stop` - `on_stream_active` - `on_pub_start` - `on_pub_stop` - `on_sub_start` - `on_sub_stop` - `on_relay_pull_start` - `on_relay_pull_stop` - `on_rtmp_connect` - `on_hls_make_ts` 其中 `on_update` 的 `groups` 数据已经过 `lalmax` 聚合,包含: - `lal` 原生 group 状态 - `lalmax` 扩展订阅者统计 与 `/api/stat/group`、`/api/stat/all_group` 一样,`on_update.groups[*]` 中的 `subs` 也是统一聚合后的订阅列表;如果业务需要显式区分 `lalmax` 扩展订阅者,建议结合 stat API 中的 `lalmax.ext_subs` 使用。 `on_group_start`、`on_stream_active` 和 `on_group_stop` 是 `lalmax` 基于统一输入流生命周期直接生成的事件,payload 结构如下: ```json { "server_id": "1", "app_name": "live", "stream_name": "test110" } ``` 注意:当前上游 `lal` 的 `WithOnHookSession` 只直接提供 `streamName`,因此这类 group 生命周期事件里的 `app_name` 在部分场景下可能为空,不能把它当成始终可靠存在的字段。 三者的语义区别是: - `on_group_start`: 流生命周期进入 `lalmax` - `on_stream_active`: 收到首个音频或视频 RTMP 消息,只触发一次 - `on_group_stop`: 流生命周期结束。业务上要判断“没有流了”,应使用这个事件 其中“没有流了”不单独新增新的 hook,仍统一使用 `on_group_stop`。 ## HTTP API ### `GET /api/hook/recent` 读取最近 hook 事件快照。 请求参数: - `limit`: 可选,返回事件数量,默认 `20` - `app_name`: 可选,只返回指定 app 的事件 - `stream_name`: 可选,只返回指定流的事件 - `session_id`: 可选,只返回指定会话的事件 - `event`: 可选,只返回单个事件类型 - `events`: 可选,逗号分隔的多个事件类型 示例: ```bash curl "http://127.0.0.1:1290/api/hook/recent?limit=5" curl "http://127.0.0.1:1290/api/hook/recent?stream_name=test110&events=on_group_start,on_stream_active,on_group_stop,on_update" ``` 响应示例: ```json { "error_code": 0, "desp": "succ", "data": { "events": [ { "id": 12, "event": "on_pub_start", "timestamp": "2026-04-24T15:20:11.123456789+08:00", "payload": { "server_id": "1", "session_id": "RTMPPUB1", "protocol": "RTMP", "base_type": "PUB", "stream_name": "test110" } } ] } } ``` ### `GET /api/hook/stream` 以 `Server-Sent Events` 持续订阅 hook 事件。 示例: ```bash curl -N http://127.0.0.1:1290/api/hook/stream curl -N "http://127.0.0.1:1290/api/hook/stream?stream_name=test110&events=on_group_start,on_stream_active,on_group_stop,on_update" ``` 返回格式: ```text id: 12 event: on_pub_start data: {"server_id":"1","session_id":"RTMPPUB1","protocol":"RTMP","base_type":"PUB","stream_name":"test110"} ``` 连接建立后会先回放最近一批事件,再进入实时流。 ## In-Process Usage 如果业务代码和 `lalmax` 在同一进程内,可以直接使用: ```go hub := serverInstance.HookHub() _, ch, cancel := hub.Subscribe(64) defer cancel() for event := range ch { // event.Event // event.Payload } ``` ## Plugin Usage 如果具体业务希望由插件处理,而不是把逻辑写进 `lalmax` 主流程,可以注册 hook 插件: ```go type BizPlugin struct{} func (p *BizPlugin) Name() string { return "biz-plugin" } func (p *BizPlugin) OnHookEvent(event server.HookEvent) error { // 业务处理 return nil } cancel, err := serverInstance.RegisterHookPlugin(&BizPlugin{}, server.HookPluginOptions{ Filter: server.NewHookEventFilter("live", "test110", "", []string{ server.HookEventPubStart, server.HookEventPubStop, }), }) if err != nil { panic(err) } defer cancel() ``` 当前默认的 HTTP notify 转发已经作为内置插件存在,外部业务插件只需要关注自己的处理逻辑。 ## Notes - `/api/hook/*` 使用和 `/api/stat/*`、`/api/ctrl/*` 相同的鉴权中间件 - 当前 `lal` 的 `WithOnHookSession` 回调只提供 `streamName`,不提供 `appName` - 因此扩展订阅者与 `app_name` 的精确归属能力仍受上游 hook 入参限制 - 建议只使用 `lalmax.http_notify` 作为对外 webhook 配置;如果 `lal` 配置段也单独开启原生 `http_notify`,尤其是 `update_interval_sec`,可能出现重复的 `on_update` - `on_group_start` / `on_stream_active` / `on_group_stop` 比基于 `on_update` 快照 diff 的方案更实时,也更不容易漏掉短生命周期流 - `on_update` 仍然建议保留给状态快照、巡检和最终一致对账使用 ================================================ FILE: document/hook_plugin_architecture.md ================================================ # Hook Plugin Architecture 本文档详细说明 `lalmax` 当前的 Hook 体系设计,包括: - 为什么需要由 `lalmax` 统一托管 hook - 事件从 `lal` 到业务插件的完整调用链 - `HookHub`、过滤器、插件调度器各自的职责 - 默认 HTTP notify 在新架构中的位置 - 业务插件的推荐接入方式 - 当前设计边界与后续演进方向 ## 1. 设计目标 这套 Hook 架构的目标不是把业务逻辑写进 `lalmax`,而是把 `lalmax` 固定为一个稳定的媒体事件平台层。 核心目标: - `lal` 继续作为原生流状态事实源 - `lalmax` 统一聚合原生状态和扩展订阅状态 - `lalmax` 统一对外暴露 Hook 读取能力 - 具体业务处理通过插件完成,而不是散落在主流程中 - 慢业务不能阻塞媒体主链路 一句话概括: `lalmax` 负责“采集、聚合、过滤、分发”,业务插件负责“消费和处理”。 ## 2. 分层结构 当前 Hook 链路分为 4 层。 ### 2.1 `lal` 原生事件层 `lal` 通过 `INotifyHandler` 向外抛出原生事件,例如: - `OnServerStart` - `OnUpdate` - `OnPubStart` - `OnPubStop` - `OnSubStart` - `OnSubStop` - `OnRelayPullStart` - `OnRelayPullStop` - `OnRtmpConnect` - `OnHlsMakeTs` 这一层只负责产生事件,不负责业务分发。 在 `lalmax` 这一层,还会基于统一输入流生命周期派生额外的 group 生命周期事件: - `on_group_start` - `on_stream_active` - `on_group_stop` ### 2.2 `lalmax` HookHub 层 `lalmax` 使用 [http_notify.go](./../server/http_notify.go) 中的 `HttpNotify` 作为统一 HookHub。 它当前承担 5 类职责: 1. 接住 `lal` 发出的原生 notify 事件 2. 对 `on_update` 的 group 数据做聚合增强 3. 为事件补充过滤所需的元数据 4. 将事件写入历史缓存,并提供 SSE/Recent 读取 5. 将事件异步分发给插件 虽然这个结构体名字仍叫 `HttpNotify`,但职责已经不只是“发 HTTP 回调”,而是整个 Hook 总线。 ### 2.3 过滤层 过滤逻辑在 [hook_filter.go](./../server/hook_filter.go)。 这层负责统一定义事件匹配规则,当前支持: - `app_name` - `stream_name` - `session_id` - `event` - `events` 这一层的意义是“统一语义”,保证: - `/api/hook/recent` - `/api/hook/stream` - 业务插件注册过滤 三者使用同一套过滤规则,而不是每处自己实现一套判断逻辑。 ### 2.4 插件层 插件接口在 [hook_plugin.go](./../server/hook_plugin.go): ```go type HookPlugin interface { Name() string OnHookEvent(event HookEvent) error } ``` 插件层只关心一件事:收到匹配事件后做自己的业务处理。 典型插件可以是: - HTTP webhook 转发 - Kafka 生产者 - Redis Stream 写入器 - 数据库落表 - 业务内存回调 - 审计日志插件 ## 3. 事件调用链 以 `OnPubStart` 为例,完整调用链如下: ```text lal native event -> HttpNotify.NotifyPubStart(info) -> publish(HookEventPubStart, info) -> 填充过滤元数据 -> 写入 history -> 推送给 SSE / recent 订阅者 -> dispatchPlugins(event) -> 匹配到的插件各自异步消费 ``` 以 `OnUpdate` 为例,还会多一步聚合: ```text lal native update -> HttpNotify.NotifyUpdate(info) -> 聚合 lal group + lalmax 扩展订阅者 -> publish(HookEventUpdate, mergedInfo) -> history / SSE / plugin dispatch ``` 而 `on_group_start` / `on_stream_active` / `on_group_stop` 并不是在 `OnUpdate` 流程内 diff 生成的,而是直接跟随输入流生命周期与首个媒体消息触发: ```text group/media lifecycle -> WithOnHookSession create -> Group.OnMsg first real media -> Group.OnStop -> publish(HookEventGroupStart / HookEventStreamActive / HookEventGroupStop, info) ``` 这意味着: - 查询接口拿到的是聚合后的视图 - hook 事件里的 `on_update` 也是聚合后的视图 - HTTP notify 与插件消费看到的是同一份增强数据 ## 4. 为什么默认 HTTP notify 也做成插件 旧模式下,`NotifyPubStart/NotifyUpdate/...` 会直接在主流程里发 HTTP POST。 这样做的问题是: - HTTP 转发是业务出口的一种,不应该写死在主流程 - 后续增加 Kafka、Redis、数据库 sink 时会继续污染主流程 - 不同业务出口的生命周期与重试策略难以统一管理 现在的做法是: - 主流程只负责 `publish` - 默认 HTTP notify 转发实现为内置插件 - 内置插件文件在 [hook_builtin_http_plugin.go](./../server/hook_builtin_http_plugin.go) 这样后续无论新增什么业务出口,都和默认 HTTP notify 处于同一层级。 ## 5. HookEvent 结构说明 对外公开的事件结构是: ```go type HookEvent struct { ID int64 Event string Timestamp string Payload json.RawMessage } ``` 其中: - `ID` 用于事件顺序控制 - `Event` 是事件类型名,例如 `on_pub_start` - `Timestamp` 是事件产生时间 - `Payload` 是具体事件数据 此外,内部还会维护用于过滤的元数据,例如: - `sessionID` - `streamName` - `appName` - `groupKeys` 这些字段不直接暴露给外部 API,但会用于: - 路由层过滤 - 插件过滤 - `on_update` 的 group 命中判断 ## 6. 过滤语义 过滤规则统一由 `HookEventFilter.Match` 决定。 ### 6.1 单会话事件 例如: - `on_group_start` - `on_stream_active` - `on_group_stop` - `on_pub_start` - `on_pub_stop` - `on_sub_start` - `on_sub_stop` - `on_relay_pull_start` - `on_relay_pull_stop` 除 group 级事件外,这类事件会直接携带: - `session_id` - `stream_name` - `app_name` 因此过滤时按单个流或单个会话精准匹配。 其中 `on_group_start` / `on_stream_active` / `on_group_stop` 是 group 级别事件,没有 `session_id`,只携带: - `stream_name` - `app_name` 其中 `app_name` 当前并不保证始终非空,它仍受上游 `WithOnHookSession` 只提供 `streamName` 的限制。 ### 6.2 `on_update` `on_update` 一次可能携带多个 group。 因此内部会把它展开成一组 `groupKeys`,过滤时判断: - 是否有任意一个 group 命中过滤条件 也就是说,一个 `on_update` 事件只要包含目标流,就会被保留。 `on_group_start` / `on_stream_active` / `on_group_stop` 是直接跟随输入流生命周期产生的,因此比基于 `on_update` 快照 diff 的方案更实时,也更不容易漏掉短生命周期流。 其中: - `on_group_start` 表示 group 生命周期开始 - `on_stream_active` 表示首个音频或视频消息真正到达,只触发一次 - `on_group_stop` 表示 group 生命周期结束,也是“没有流了”应使用的事件 但当前仍有一个边界: - 上游 `lal` 的 `WithOnHookSession` 只提供 `streamName` - 因此这类 direct lifecycle hook 的 `app_name` 归属能力仍受上游接口限制 - 如果同时保留 `lal` 原生 `http_notify` 和 `lalmax` 自己的 HookHub 出口,尤其同时配置两个 `update_interval_sec`,`on_update` 可能重复 ### 6.3 当前支持的过滤条件 - `app_name` - `stream_name` - `session_id` - `event` - `events` 建议: - 单流订阅优先同时带 `app_name + stream_name` - 精确追踪某个连接时使用 `session_id` - 降低噪音时优先限制 `event/events` ## 7. 业务插件如何接入 业务代码和 `lalmax` 同进程时,推荐直接注册插件。 ### 7.1 最小插件示例 ```go type BizPlugin struct{} func (p *BizPlugin) Name() string { return "biz-plugin" } func (p *BizPlugin) OnHookEvent(event server.HookEvent) error { // 业务处理 return nil } ``` ### 7.2 注册示例 ```go cancel, err := serverInstance.RegisterHookPlugin(&BizPlugin{}, server.HookPluginOptions{ Filter: server.NewHookEventFilter("live", "test110", "", []string{ server.HookEventPubStart, server.HookEventPubStop, server.HookEventUpdate, }), BufferSize: 64, }) if err != nil { panic(err) } defer cancel() ``` ### 7.3 字段说明 - `Name()` 用作插件唯一标识。重复名称不允许重复注册。 - `Filter` 用于控制这个插件只消费自己关心的事件。 - `BufferSize` 用于控制插件异步队列大小。 ### 7.4 为什么推荐插件而不是直接改主流程 因为主流程的职责应该稳定,而业务处理天然是变化的。 如果把每个业务都写进主流程,会出现: - 发布一个新业务就要改核心代码 - 多业务逻辑互相影响 - 回归成本越来越高 - 业务异常更容易污染核心链路 插件化之后,核心层和业务层边界清晰很多。 ## 8. 插件调度模型 插件分发是异步的,每个插件有自己的缓冲队列。 调度模型: ```text publish(event) -> 遍历已注册插件 -> 根据 Filter 判断是否命中 -> 命中则投递到该插件自己的 queue -> 插件 goroutine 从 queue 中消费 ``` 这个模型的含义是: - 插件之间互不阻塞 - 插件不会反压媒体主链路 - 某个慢插件只影响自己 当前策略下,如果插件队列满了: - 当前事件会被丢弃 - 记录 warn 日志 这是有意选择,优先保证媒体主链路稳定。 ## 9. 当前默认行为 当前系统启动后,默认会注册一个内置插件: - `builtin-http-notify` 它负责把事件按旧配置转发到: - `on_server_start` - `on_update` - `on_group_start` - `on_stream_active` - `on_group_stop` - `on_pub_start` - `on_pub_stop` - `on_sub_start` - `on_sub_stop` - `on_relay_pull_start` - `on_relay_pull_stop` - `on_rtmp_connect` - `on_hls_make_ts` 这意味着旧的 `http_notify` 配置仍然可用,但实现方式已经改成: ```text HookHub -> builtin-http-notify plugin -> HTTP callback ``` 而不再是主流程直接发 HTTP。 ## 10. API、SSE、插件三者关系 三者读的是同一个 HookHub。 ### 10.1 `/api/hook/recent` 适合: - 排查最近事件 - 调试过滤表达式 - 运维观察 ### 10.2 `/api/hook/stream` 适合: - 实时消费 - 调试前端或外部观察程序 - 对接轻量事件订阅方 ### 10.3 插件 适合: - 同进程业务接入 - 需要更复杂处理逻辑 - 需要将事件转发到第三方系统 三者的事件源一致,过滤语义一致,只是使用方式不同。 ## 11. 推荐使用方式 ### 11.1 业务和 `lalmax` 同进程 优先用插件: - 延迟低 - 无需再走 HTTP - 易于封装业务逻辑 ### 11.2 业务和 `lalmax` 不同进程 优先用: - `/api/hook/stream` - 或内置 HTTP notify 插件 ### 11.3 需要统一平台出口 可以继续在插件层增加: - Kafka 插件 - Redis 插件 - 数据库存档插件 ## 12. 当前边界与限制 ### 12.1 `app_name` 边界 当前上游 `lal` 的 `WithOnHookSession` 仍只提供 `streamName`,不提供 `appName`。 这意味着: - `lal` 原生 group 状态本身是可信的 - 但扩展订阅者与 `app_name` 的精确归属能力仍受上游输入限制 因此文档里一直建议: - 需要精确路由时,尽量同时使用 `app_name + stream_name` ### 12.2 插件可靠性策略 当前插件队列满时是丢弃策略,不是阻塞策略,也不是持久化重试策略。 这是为了媒体主链路稳定。 如果未来某类插件需要强可靠投递,建议不要直接在 `lalmax` 内核层强推重试,而是: - 插件内自己做持久化 - 或者把事件转发给外部消息系统 ### 12.3 当前插件装配方式 目前插件仍然通过代码注册。 也就是说: - 你需要拿到 `LalMaxServer` - 调用 `RegisterHookPlugin(...)` 下一步可以继续演进成“配置化装配”,由配置声明启用哪些插件和参数。 ## 13. 后续演进建议 比较合理的后续方向有 3 个。 ### 13.1 插件配置化装配 目标: - 不用业务代码手动注册插件 - 配置文件直接声明插件列表、参数、过滤条件 ### 13.2 标准化插件参数 例如统一定义: - HTTP webhook 插件参数 - Kafka 插件参数 - Redis 插件参数 ### 13.3 更强的可靠性模型 例如: - 插件失败重试 - 死信队列 - 插件级别熔断 - 指标与监控 ## 14. 小结 现在的 Hook 架构已经完成了从“固定 HTTP 回调实现”到“统一 HookHub + 插件化业务处理”的转换。 当前职责边界可以概括为: - `lal`: 原生媒体事件事实源 - `lalmax` HookHub: 聚合、过滤、缓存、分发 - 插件: 具体业务处理 这套结构的核心价值是: - 主流程稳定 - 业务接入灵活 - 多业务可并存 - 后续扩展成本更低 ================================================ FILE: document/lal_api.md ================================================ # lal Native HTTP API `lalmax` 内嵌运行 `lal`。默认情况下,对外建议统一使用 `lalmax` 自己的 API 门面: - `lalmax.http_config.http_listen_addr` 下的 `/api/stat/*` - `lalmax.http_config.http_listen_addr` 下的 `/api/ctrl/*` - `lalmax.http_config.http_listen_addr` 下的 `/api/hook/*` `lal.http_api` 只建议在调试 `lal` 原生行为时临时开启。 默认配置: ```json { "http_api": { "enable": false, "addr": ":8083" } } ``` 启用后可访问: ```text http://127.0.0.1:8083 ``` ## Native Endpoints `lal` 原生 HTTP API 当前主要包含: - `GET /lal.html` - `GET /api/stat/lal_info` - `GET /api/stat/all_group` - `GET /api/stat/group` - `POST /api/ctrl/start_relay_pull` - `GET /api/ctrl/stop_relay_pull` - `POST /api/ctrl/kick_session` - `POST /api/ctrl/start_rtp_pub` ## Recommended Gateway 推荐直接使用 `lalmax` API,因为它会在 `lal` 原生结果基础上补充: - `lalmax` 扩展订阅者统计 - 更完整的统一状态视图 - 统一的 hook 事件读取能力 - 统一鉴权入口 默认地址: ```text http://127.0.0.1:1290/api/stat/group http://127.0.0.1:1290/api/stat/all_group http://127.0.0.1:1290/api/stat/lal_info http://127.0.0.1:1290/api/ctrl/start_relay_pull http://127.0.0.1:1290/api/ctrl/stop_relay_pull http://127.0.0.1:1290/api/ctrl/kick_session http://127.0.0.1:1290/api/ctrl/start_rtp_pub http://127.0.0.1:1290/api/ctrl/stop_rtp_pub http://127.0.0.1:1290/api/hook/recent http://127.0.0.1:1290/api/hook/stream ``` ## Compatibility Notes - `lalmax` 的 `/api/ctrl/*` 请求/响应结构与 `lal` 原生 API 基本保持一致 - `lalmax` 的 `/api/stat/group` 和 `/api/stat/all_group` 会在兼容 `lal` 原有字段的基础上新增 `lalmax` 扩展块 - `stop_relay_pull` 在 `lalmax` 中兼容 `GET` - `stat/group` 在 `lalmax` 中会优先结合 `app_name + stream_name` 做更精确的 group 匹配 - `on_update` 等 hook 事件在 `lalmax` 中已经过聚合增强 ## Stat API Extension `lalmax` 的统计接口会返回两层信息: 1. `lal` 兼容层 也就是原有的 `stream_name`、`app_name`、`pub`、`subs`、`pull`、`in_frame_per_sec` 等字段 2. `lalmax` 扩展层 当前主要是 `lalmax.ext_subs` 其中: - `subs` 是聚合后的统一订阅列表 - `lalmax.ext_subs` 是其中来自 `lalmax` 扩展协议层的子集 这意味着调用方如果完全按照 `lal` 老接口解析,通常仍然可以工作;如果要区分哪些订阅者是 `lalmax` 自己维护的,就再读取 `lalmax.ext_subs`。 控制类接口不附带这些扩展状态。如果执行控制动作后还需要查看最新流状态,应再调用 `/api/stat/group` 或 `/api/stat/all_group`。 ## Debug Usage 只有在以下场景,才建议单独开启 `lal.http_api`: - 排查 `lal` 原生 HTTP API 行为 - 对比 `lal` 原始 group 数据和 `lalmax` 聚合数据 - 调试上游 `lal` 升级后的兼容性 ================================================ FILE: document/lal_config.md ================================================ # lal 原生配置说明 本文档说明 `conf/lalmax.conf.json` 中 `lal` 配置段的常用字段。`lal` 配置段会直接传给 lal 原生服务,用于 RTMP、RTSP、HTTP-FLV、HLS-TS、HTTP-TS、录制、鉴权和原生 HTTP API。 ## rtmp - `enable`: 是否启用 RTMP 服务。 - `addr`: RTMP 监听地址,例如 `:1935`。 - `rtmps_enable`: 是否启用 RTMPS。 - `rtmps_addr`: RTMPS 监听地址,例如 `:4935`。 - `rtmps_cert_file`: RTMPS 证书文件路径。 - `rtmps_key_file`: RTMPS 私钥文件路径。 - `gop_num`: RTMP 拉流 GOP 缓存数量。 - `single_gop_max_frame_num`: 单个 GOP 最大缓存帧数,`0` 表示不限制。 - `merge_write_size`: 合并写大小,`0` 表示关闭合并写。 ## in_session - `add_dummy_audio_enable`: 没有音频时是否补静音音频。 - `add_dummy_audio_wait_audio_ms`: 等待真实音频的时间,超过后才补静音音频。 ## default_http HTTP 类协议的默认监听配置。HTTP-FLV、HTTP-TS、HLS-TS 未单独配置监听地址时,会使用这里的地址。 - `http_listen_addr`: 默认 HTTP 监听地址,例如 `:8080`。 - `https_listen_addr`: 默认 HTTPS 监听地址,例如 `:4433`。 - `https_cert_file`: HTTPS 证书文件路径。 - `https_key_file`: HTTPS 私钥文件路径。 ## httpflv - `enable`: 是否启用 HTTP-FLV。 - `enable_https`: 是否启用 HTTPS HTTP-FLV。 - `url_pattern`: URL 路径匹配前缀。示例配置为 `/`,因此 `/live/test110.flv` 可用。 - `gop_num`: HTTP-FLV GOP 缓存数量。 - `single_gop_max_frame_num`: 单个 GOP 最大缓存帧数。 ## hls 这里是 lal 原生 HLS-TS 配置,不是 lalmax 的 HLS-FMP4/LLHLS 配置。 - `enable`: 是否启用 HLS-TS。 - `enable_https`: 是否启用 HTTPS HLS-TS。 - `url_pattern`: HLS-TS URL 路径前缀,常用 `/hls/`。 - `out_path`: HLS-TS 文件输出目录。 - `fragment_duration_ms`: 单个 TS 分片时长。 - `fragment_num`: m3u8 中保留的分片数量。 - `delete_threshold`: 清理旧分片的阈值。 - `cleanup_mode`: 清理模式。 - `use_memory_as_disk_flag`: 是否使用内存模拟磁盘。 - `sub_session_timeout_ms`: HLS 拉流会话超时时间。 - `sub_session_hash_key`: HLS 会话哈希 key。 ## httpts - `enable`: 是否启用 HTTP-TS。 - `enable_https`: 是否启用 HTTPS HTTP-TS。 - `url_pattern`: URL 路径匹配前缀。示例配置为 `/`,因此 `/live/test110.ts` 可用。 - `gop_num`: HTTP-TS GOP 缓存数量。 - `single_gop_max_frame_num`: 单个 GOP 最大缓存帧数。 ## rtsp - `enable`: 是否启用 RTSP。 - `addr`: RTSP 监听地址,例如 `:5544`。 - `rtsps_enable`: 是否启用 RTSPS。 - `rtsps_addr`: RTSPS 监听地址,例如 `:5322`。 - `rtsps_cert_file`: RTSPS 证书文件路径。 - `rtsps_key_file`: RTSPS 私钥文件路径。 - `out_wait_key_frame_flag`: RTSP 拉流是否等待关键帧后再输出。 - `auth_enable`: 是否启用 RTSP 鉴权。 - `auth_method`: 鉴权方式。 - `username`: RTSP 鉴权用户名。 - `password`: RTSP 鉴权密码。 ## record - `enable_flv`: 是否启用 FLV 录制。 - `flv_out_path`: FLV 录制输出目录。 - `enable_mpegts`: 是否启用 MPEG-TS 录制。 - `mpegts_out_path`: MPEG-TS 录制输出目录。 ## relay_push - `enable`: 是否启用静态转推。 - `addr_list`: 转推目标地址列表。 ## static_relay_pull - `enable`: 是否启用静态回源拉流。 - `addr`: 静态回源地址。 ## http_api - `enable`: 是否启用 lal 原生 HTTP API。 - `addr`: lal 原生 HTTP API 监听地址,例如 `:8083`。 接口说明见 [lal_api.md](./lal_api.md)。 ## simple_auth 简单鉴权配置,鉴权值通常按 `key + streamName` 计算。 - `key`: 鉴权 key。 - `dangerous_lal_secret`: 管理类接口使用的 secret。 - `pub_rtmp_enable`: 是否启用 RTMP 推流鉴权。 - `sub_rtmp_enable`: 是否启用 RTMP 拉流鉴权。 - `sub_httpflv_enable`: 是否启用 HTTP-FLV 拉流鉴权。 - `sub_httpts_enable`: 是否启用 HTTP-TS 拉流鉴权。 - `pub_rtsp_enable`: 是否启用 RTSP 推流鉴权。 - `sub_rtsp_enable`: 是否启用 RTSP 拉流鉴权。 - `hls_m3u8_enable`: 是否启用 HLS m3u8 鉴权。 ## pprof - `enable`: 是否启用 pprof。 - `addr`: pprof 监听地址,例如 `:8084`。 ## log - `level`: 日志级别。 - `filename`: 日志文件路径。 - `is_to_stdout`: 是否输出到标准输出。 - `is_rotate_daily`: 是否按天切分日志。 - `short_file_flag`: 是否打印短文件名。 - `timestamp_flag`: 是否打印时间戳。 - `timestamp_with_ms_flag`: 时间戳是否包含毫秒。 - `level_flag`: 是否打印日志级别。 - `assert_behavior`: 断言行为。 ## debug - `log_group_interval_sec`: group 状态日志输出间隔。 - `log_group_max_group_num`: 单次最多输出的 group 数量。 - `log_group_max_sub_num_per_group`: 单个 group 最多输出的订阅者数量。 ================================================ FILE: document/rtc.md ================================================ # WebRTC(WHIP/WHEP) WebRTC在刚发布的时候仅仅专注于VoIP和点对点用例,它仅限于几个并发的浏览器,并且不能扩展,缺少标准信令交互,故很难用于直播场景。 在此背景下,WHIP和WHEP这2个标准的提出,补齐了信令交互这一个环节,使WebRTC可以运用在直播场景。 ## WHIP(WebRTC-HTTP Ingestion Protocol) 协议链接:https://datatracker.ietf.org/doc/html/draft-murillo-whip-02 WHIP(WebRTC-HTTP Ingestion Protocol)是Milicast的技术团队提出的,在与媒体服务器通信时,WHIP提供了使用标准信令协议的编码软件和硬件,这样就可以实现厂商的WebRTC推流。WHIP在WebRTC上增加了一个简单的信令层,可用于将WebRTC发布者连接到WebRTC媒体服务器,发布者只发送媒体而不接收媒体。 ### 交互流程 ``` +-------------+ +---------------+ +--------------+ +---------------+ | WHIP client | | WHIP endpoint | | Media Server | | WHIP Resource | +--+----------+ +---------+-----+ +------+-------+ +--------|------+ | | | | | | | | |HTTP POST (SDP Offer) | | | +------------------------>+ | | |201 Created (SDP answer) | | | +<------------------------+ | | | ICE REQUEST | | +--------------------------------------->+ | | ICE RESPONSE | | |<---------------------------------------+ | | DTLS SETUP | | |<======================================>| | | RTP/RTCP FLOW | | +<-------------------------------------->+ | | HTTP DELETE | +---------------------------------------------------------->+ | 200 OK | <-----------------------------------------------------------x ``` (1)WHIP client使用HTTP POST请求执行单次SDP Offer/Answer,以便在编码器/媒体生产者(WHIP客户端)和广播接收端点(媒体服务器)之间建立ICE/DTLS会话。 (2)一旦ICE/DTLS会话建立,媒体将从编码器/媒体生成器(WHIP客户端)单向流向广播接收端点(媒体服务器)。为了降低复杂性,不支持SDP重新协商,因此在完成通过HTTP的初始SDP Offer/Answer后,不能添加或删除任何track或stream。 (3)HTTP POST请求的内容类型为“application/sdp”,并包含作为主体的SDP Offer。WHIP端点将生成一个SDP Answer并返回一个“201 Created”响应,内容类型为“application/SDP”。 ## WHEP(WebRTC-HTTP Egress Protocol) 协议链接:https://datatracker.ietf.org/doc/html/draft-murillo-whep-02 WHEP(WebRTC-HTTP Egress Protocol)也是在WebRTC上增加了一个简单的信令层,可用于将WebRTC播放者连接到WebRTC媒体服务器,播放者只接收媒体,不发送媒体。 ``` +-------------+ +---------------+ +--------------+ +---------------+ | WHEP Player | | WHEP endpoint | | Media Server | | WHEP Resource | +--+----------+ +---------+-----+ +------+-------+ +--------|------+ | | | | | | | | |HTTP POST (SDP Offer) | | | +------------------------>+ | | |201 Created (SDP answer) | | | +<------------------------+ | | | ICE REQUEST | | +--------------------------------------->+ | | ICE RESPONSE | | |<---------------------------------------+ | | DTLS SETUP | | |<======================================>| | | RTP/RTCP FLOW | | +<-------------------------------------->+ | | HTTP DELETE | +---------------------------------------------------------->+ | 200 OK | <-----------------------------------------------------------x ``` (1)WHEP Player使用HTTP POST请求执行单次SDP Offer/Answer,以便在WHEP Player和媒体服务器之间建立ICE/DTLS会话。 (2)一旦ICE/DTLS会话建立,媒体将从媒体服务器流向WHEP Player。为了降低复杂性,不支持SDP重新协商,因此在完成通过HTTP的初始SDP Offer/Answer后,不能添加或删除任何track或stream。 (3)HTTP POST请求的内容类型为“application/sdp”,并包含作为主体的SDP Offer。WHEP端点将生成一个SDP Answer并返回一个“201 Created”响应,内容类型为“application/SDP”。 ## lalmax RTC lalmax支持WHIP推流和WHEP拉流 视频:H264 音频:G711A/G711U WHIP可以使用[vue-wish](https://github.com/zllovesuki/vue-wish)、[OBS](https://github.com/obsproject/obs-studio/actions/runs/5227109208?pr=7926)测试 WHEP拉流可以使用[vue-wish](https://github.com/zllovesuki/vue-wish)测试 ### OBS测试效果 使用OBS进行whip推流到lalmax中,并用vue-wish拉流,测试延时可以做到200ms以内 OBS推流配置 ![图片](images/rtc_01.jpeg) vue-wish拉流效果 ![图片](images/rtc_02.png) ================================================ FILE: document/srt.md ================================================ # SRT SRT(Secure Reliable Transport)的简称,主要优化在不可靠网络(非阻塞导致的丢包)环境下实时音视频的传输性能 ## 特点 (1) 基于UDP的用户态协议栈 (2) 抗丢包能力强&低延时 (3) 传输负载无关 (4) 传输加密 ## 应用场景 (1) 上行最后一公里推流加速 (2) CDN内部传输分发加速 (3) 丢包重传率比较高的场景 ## 支持的流媒体服务和工具 (1) OBS (2) VLC (3) FFmpeg,编译需集成libsrt (4) SRS (5) ZLMediaKit (6) LALMax ## 测试 (1) 启动LalMax服务 (2) 使用OBS进行推流,在"直播"中输入srt的推流地址 ![图片](images/srt_0.png) (3) VLC进行播放 在VLC中设置streamid,这部分填streamid后面的所有信息 ![图片](images/srt_1.png) 输入streamid前面的部分进行拉流 ![图片](images/srt_2.png) ![图片](images/srt_3.png) ================================================ FILE: document/stream_url.md ================================================ # 流地址说明 本文档使用 `conf/lalmax.conf.json` 的默认配置举例,默认流名为 `test110`。 ## 基本规则 - `lal` 原生能力使用 `lal` 配置段中的端口,例如 RTMP、RTSP、HTTP-FLV、HLS-TS、HTTP-TS。 - `lalmax` 扩展能力使用 `lalmax` 配置段中的端口,例如 SRT、WHIP/WHEP、HTTP-FMP4、HLS-FMP4/LLHLS。 - 当前 lal 使用简单流管理时主要按 `streamName` 匹配。示例中的 `/live/test110` 里,`test110` 是流名,`live` 可作为常用路径前缀。 - lalmax 扩展拉流接口支持可选 `app_name` 参数,用于未来多 appName 同 streamName 的精确匹配;不传时仍按历史 `streamName` 兼容查找。 - HTTP-FLV、HTTP-TS、HLS-TS 的路径还受 `lal.httpflv.url_pattern`、`lal.httpts.url_pattern`、`lal.hls.url_pattern` 影响。示例配置中 HTTP-FLV 的 `url_pattern` 为 `/`,因此 `/live/test110.flv` 可用。 - HTTPS、RTMPS、RTSPS 依赖配置中的证书文件,浏览器或播放器可能需要信任测试证书。 ## 推流地址 ### RTMP ```text rtmp://127.0.0.1:1935/live/test110 ``` FFmpeg 示例: ```bash ffmpeg -re -i demo.flv -c:a copy -c:v copy -f flv rtmp://127.0.0.1:1935/live/test110 ``` 如果开启 RTMPS: ```text rtmps://127.0.0.1:4935/live/test110 ``` ### RTSP ```text rtsp://127.0.0.1:5544/live/test110 ``` FFmpeg 示例: ```bash ffmpeg -re -i demo.flv -c:a copy -c:v copy -f rtsp rtsp://127.0.0.1:5544/live/test110 ``` 如果开启 RTSPS: ```text rtsps://127.0.0.1:5322/live/test110 ``` ### SRT ```text srt://127.0.0.1:6001?streamid=#!::h=test110,m=publish ``` `h` 表示流名,`m=publish` 表示推流。 ### WebRTC WHIP ```text http://127.0.0.1:1290/webrtc/whip?streamid=test110 https://127.0.0.1:1233/webrtc/whip?streamid=test110 ``` WHIP 使用 HTTP POST 传输 SDP offer,通常由 OBS、WHIP 客户端或 WebRTC 工具调用。 ### GB28181 GB28181 不是普通 URL 推流。设备通过 SIP 注册到 lalmax,平台再通过 API 控制播放。详见 [gb28181.md](./gb28181.md)。 ## 拉流地址 ### RTMP ```text rtmp://127.0.0.1:1935/live/test110 ``` ffplay 示例: ```bash ffplay rtmp://127.0.0.1:1935/live/test110 ``` ### RTSP ```text rtsp://127.0.0.1:5544/live/test110 ``` ffplay 示例: ```bash ffplay rtsp://127.0.0.1:5544/live/test110 ``` 如果开启 RTSPS: ```text rtsps://127.0.0.1:5322/live/test110 ``` ### HTTP-FLV ```text http://127.0.0.1:8080/live/test110.flv https://127.0.0.1:4433/live/test110.flv ``` ffplay 示例: ```bash ffplay http://127.0.0.1:8080/live/test110.flv ``` ### HTTP-TS 需要启用 `lal.httpts.enable`。 ```text http://127.0.0.1:8080/live/test110.ts https://127.0.0.1:4433/live/test110.ts ``` ### HLS-TS 需要启用 `lal.hls.enable`。 ```text http://127.0.0.1:8080/hls/test110/playlist.m3u8 http://127.0.0.1:8080/hls/test110/record.m3u8 http://127.0.0.1:8080/hls/test110.m3u8 ``` ### SRT ```text srt://127.0.0.1:6001?streamid=#!::h=test110,m=request ``` `h` 表示流名,`m=request` 表示拉流。 ### WebRTC WHEP ```text http://127.0.0.1:1290/webrtc/whep?streamid=test110 https://127.0.0.1:1233/webrtc/whep?streamid=test110 ``` 如果需要指定 appName: ```text http://127.0.0.1:1290/webrtc/whep?streamid=test110&app_name=live ``` WHEP 使用 HTTP POST 传输 SDP offer,通常由 WHEP 播放器或 WebRTC 工具调用。 ### Jessibuca DataChannel ```text webrtc://127.0.0.1:1290/webrtc/play/live/test110 ``` 如果需要指定 appName: ```text webrtc://127.0.0.1:1290/webrtc/play/live/test110?app_name=live ``` ### HTTP-FMP4 ```text http://127.0.0.1:1290/live/m4s/test110.mp4 https://127.0.0.1:1233/live/m4s/test110.mp4 ``` 如果需要指定 appName: ```text http://127.0.0.1:1290/live/m4s/test110.mp4?app_name=live ``` ### HLS-FMP4/LLHLS 需要启用 `lalmax.fmp4_config.hls.enable`。 ```text http://127.0.0.1:1290/live/hls/test110/index.m3u8 https://127.0.0.1:1233/live/hls/test110/index.m3u8 ``` 如果需要指定 appName: ```text http://127.0.0.1:1290/live/hls/test110/index.m3u8?app_name=live ``` 如果需要低延迟 HLS,设置 `lalmax.fmp4_config.hls.low_latency` 为 `true`。 ================================================ FILE: fmp4/hls/server.go ================================================ package hls import ( "sync" "time" config "github.com/q191201771/lalmax/config" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/naza/pkg/nazalog" ) type HlsServer struct { sessions sync.Map conf config.Fmp4HlsConfig invalidSessions sync.Map } func NewHlsServer(conf config.Fmp4HlsConfig) *HlsServer { svr := &HlsServer{ conf: conf, } go svr.cleanInvalidSession() return svr } func (s *HlsServer) NewHlsSession(streamName string) { s.NewHlsSessionWithAppName("", streamName) } func (s *HlsServer) NewHlsSessionWithAppName(appName, streamName string) { nazalog.Infof("new hls session, appName:%s, streamName:%s", appName, streamName) session := NewHlsSessionWithAppName(appName, streamName, s.conf) s.sessions.Store(hlsSessionKey(appName, streamName), session) } func (s *HlsServer) OnMsg(streamName string, msg base.RtmpMsg) { s.OnMsgWithAppName("", streamName, msg) } func (s *HlsServer) OnMsgWithAppName(appName, streamName string, msg base.RtmpMsg) { value, ok := s.sessions.Load(hlsSessionKey(appName, streamName)) if ok { session := value.(*HlsSession) session.OnMsg(msg) } } func (s *HlsServer) OnStop(streamName string) { s.OnStopWithAppName("", streamName) } func (s *HlsServer) OnStopWithAppName(appName, streamName string) { key := hlsSessionKey(appName, streamName) value, ok := s.sessions.Load(key) if ok { session := value.(*HlsSession) s.invalidSessions.Store(session.SessionId, session) s.sessions.Delete(key) } } func (s *HlsServer) HandleRequest(ctx *gin.Context) { streamName := ctx.Param("streamid") appName := ctx.Query("app_name") if session, ok := s.getSession(appName, streamName); ok { session.HandleRequest(ctx) } } func (s *HlsServer) getSession(appName, streamName string) (*HlsSession, bool) { value, ok := s.sessions.Load(hlsSessionKey(appName, streamName)) if ok { return value.(*HlsSession), true } if appName != "" { return nil, false } var found *HlsSession matchCount := 0 s.sessions.Range(func(_, value interface{}) bool { session := value.(*HlsSession) if session.streamName != streamName { return true } found = session matchCount++ return matchCount <= 1 }) if matchCount != 1 { return nil, false } return found, true } type sessionKey struct { appName string streamName string } func hlsSessionKey(appName, streamName string) sessionKey { return sessionKey{ appName: appName, streamName: streamName, } } func (s *HlsServer) cleanInvalidSession() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { s.invalidSessions.Range(func(k, v interface{}) bool { session := v.(*HlsSession) nazalog.Info("clean invalid session, streamName:", session.streamName, " sessionId:", k) session.OnStop() s.invalidSessions.Delete(k) return true }) } } ================================================ FILE: fmp4/hls/session.go ================================================ package hls import ( "time" config "github.com/q191201771/lalmax/config" "github.com/bluenviron/gohlslib" "github.com/bluenviron/gohlslib/pkg/codecs" "github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/hevc" "github.com/q191201771/naza/pkg/nazalog" uuid "github.com/satori/go.uuid" ) type HlsSession struct { muxer *gohlslib.Muxer done bool data []Frame audioCodecId int videoCodecId int maxMsgSize int appName string streamName string sps []byte pps []byte vps []byte asc []byte startAudioPts time.Duration startVideoPts time.Duration audioStartPTSFilled bool videoStartPTSFilled bool SessionId string } func NewHlsSession(streamName string, conf config.Fmp4HlsConfig) *HlsSession { return NewHlsSessionWithAppName("", streamName, conf) } func NewHlsSessionWithAppName(appName, streamName string, conf config.Fmp4HlsConfig) *HlsSession { variant := gohlslib.MuxerVariantFMP4 if conf.LowLatency { variant = gohlslib.MuxerVariantLowLatency } u, _ := uuid.NewV4() session := &HlsSession{ muxer: &gohlslib.Muxer{ Variant: variant, }, audioCodecId: -1, videoCodecId: -1, maxMsgSize: 128, data: make([]Frame, 10)[0:0], appName: appName, streamName: streamName, SessionId: u.String(), } uid, _ := uuid.NewV4() session.SessionId = uid.String() if !conf.LowLatency && conf.SegmentCount > 0 { // fmp4模式下可以设置分片个数 session.muxer.SegmentCount = conf.SegmentCount } if conf.LowLatency && conf.PartDuration > 0 { // llhls设置part duration session.muxer.PartDuration = time.Millisecond * time.Duration(conf.PartDuration) } if conf.SegmentDuration > 0 { session.muxer.SegmentDuration = time.Second * time.Duration(conf.SegmentDuration) } return session } func (session *HlsSession) OnMsg(msg base.RtmpMsg) { if session.done { if msg.Header.MsgTypeId == base.RtmpTypeIdVideo { if msg.IsVideoKeySeqHeader() { session.videoCodecId = int(msg.VideoCodecId()) if msg.IsAvcKeySeqHeader() { var err error session.sps, session.pps, err = avc.ParseSpsPpsFromSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseSpsPpsFromSeqHeader err:", err) } } else { session.vps, session.sps, session.pps, _ = hevc.ParseVpsSpsPpsFromSeqHeaderWithoutMalloc(msg.Payload) } } else { nals, err := avc.SplitNaluAvcc(msg.Payload[5:]) if err != nil { nazalog.Error(err) return } var nalus [][]byte if msg.IsAvcKeyNalu() || msg.IsHevcKeyNalu() { if msg.IsAvcKeyNalu() { nalus = append(nalus, session.sps) nalus = append(nalus, session.pps) } else { nalus = append(nalus, session.vps) nalus = append(nalus, session.sps) nalus = append(nalus, session.pps) } } nalus = append(nalus, nals...) pts := time.Millisecond*time.Duration(msg.Pts()) - session.startVideoPts err = session.muxer.WriteH26x(time.Now(), pts, nalus) if err != nil { nazalog.Error("hls-fmp4 WriteH26x failed, err:", err) } } } else { if session.audioCodecId == int(base.RtmpSoundFormatAac) { pts := time.Millisecond*time.Duration(msg.Dts()) - session.startAudioPts err := session.muxer.WriteMPEG4Audio(time.Now(), pts, [][]byte{msg.Payload[2:]}) if err != nil { nazalog.Error("hls-fmp4 WriteMPEG4Audio failed, err:", err) } } else if session.audioCodecId == int(base.RtmpSoundFormatOpus) { pts := time.Millisecond*time.Duration(msg.Dts()) - session.startAudioPts err := session.muxer.WriteOpus(time.Now(), pts, [][]byte{msg.Payload[1:]}) if err != nil { nazalog.Error("hls-fmp4 WriteOpus failed, err:", err) } } } return } switch msg.Header.MsgTypeId { case base.RtmpTypeIdAudio: session.audioCodecId = int(msg.AudioCodecId()) if session.audioCodecId == int(base.RtmpSoundFormatAac) { if msg.IsAacSeqHeader() { session.asc = msg.Payload[2:] } else { if !session.audioStartPTSFilled { session.startAudioPts = time.Millisecond * time.Duration(msg.Dts()) session.audioStartPTSFilled = true } pts := time.Millisecond*time.Duration(msg.Dts()) - session.startAudioPts frame := Frame{ ntp: time.Now(), pts: pts, au: [][]byte{msg.Payload[2:]}, codecType: msg.AudioCodecId(), } session.data = append(session.data, frame) } } else if session.audioCodecId == int(base.RtmpSoundFormatOpus) { if !session.audioStartPTSFilled { session.startAudioPts = time.Millisecond * time.Duration(msg.Dts()) session.audioStartPTSFilled = true } pts := time.Millisecond*time.Duration(msg.Dts()) - session.startAudioPts frame := Frame{ ntp: time.Now(), pts: pts, au: [][]byte{msg.Payload[1:]}, codecType: msg.AudioCodecId(), } session.data = append(session.data, frame) } else { return } case base.RtmpTypeIdVideo: if msg.IsVideoKeySeqHeader() { session.videoCodecId = int(msg.VideoCodecId()) if msg.IsAvcKeySeqHeader() { var err error session.sps, session.pps, err = avc.ParseSpsPpsFromSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseSpsPpsFromSeqHeader err:", err) } } else { session.vps, session.sps, session.pps, _ = hevc.ParseVpsSpsPpsFromSeqHeaderWithoutMalloc(msg.Payload) } } else { nals, err := avc.SplitNaluAvcc(msg.Payload[5:]) if err != nil { return } if !session.videoStartPTSFilled { session.startVideoPts = time.Millisecond * time.Duration(msg.Pts()) session.videoStartPTSFilled = true } var nalus [][]byte if msg.IsAvcKeyNalu() || msg.IsHevcKeyNalu() { if msg.IsAvcKeyNalu() { nalus = append(nalus, session.sps) nalus = append(nalus, session.pps) } else { nalus = append(nalus, session.vps) nalus = append(nalus, session.sps) nalus = append(nalus, session.pps) } } nalus = append(nalus, nals...) pts := time.Millisecond*time.Duration(msg.Pts()) - session.startVideoPts frame := Frame{ ntp: time.Now(), pts: pts, au: nalus, codecType: msg.VideoCodecId(), } session.data = append(session.data, frame) } } if session.videoCodecId != -1 && session.audioCodecId != -1 { session.drain() return } if len(session.data) >= session.maxMsgSize { session.drain() return } } func (session *HlsSession) drain() { if session.videoCodecId != -1 { if session.videoCodecId == int(base.RtmpCodecIdAvc) { session.muxer.VideoTrack = &gohlslib.Track{ Codec: &codecs.H264{ SPS: session.sps, PPS: session.pps, }, } } else if session.videoCodecId == int(base.RtmpCodecIdHevc) { session.muxer.VideoTrack = &gohlslib.Track{ Codec: &codecs.H265{ VPS: session.vps, SPS: session.sps, PPS: session.pps, }, } } } if session.audioCodecId != -1 { if session.audioCodecId == int(base.RtmpSoundFormatAac) { var mpegConf mpeg4audio.Config err := mpegConf.Unmarshal(session.asc) if err != nil { nazalog.Error(err) return } session.muxer.AudioTrack = &gohlslib.Track{ Codec: &codecs.MPEG4Audio{ Config: mpegConf, }, } } else if session.audioCodecId == int(base.RtmpSoundFormatOpus) { session.muxer.AudioTrack = &gohlslib.Track{ Codec: &codecs.Opus{ ChannelCount: 1, }, } } } if err := session.muxer.Start(); err != nil { nazalog.Error(err) return } for _, data := range session.data { if (data.codecType == base.RtmpCodecIdAvc || data.codecType == base.RtmpCodecIdHevc) && session.videoCodecId != -1 { err := session.muxer.WriteH26x(data.ntp, data.pts, data.au) if err != nil { nazalog.Error("hls-fmp4 WriteH26x failed, err:", err) continue } } else if session.audioCodecId != -1 { if data.codecType == base.RtmpSoundFormatAac { err := session.muxer.WriteMPEG4Audio(data.ntp, data.pts, data.au) if err != nil { nazalog.Error("hls-fmp4 WriteMPEG4Audio failed, err:", err) continue } } else if data.codecType == base.RtmpSoundFormatOpus { err := session.muxer.WriteOpus(data.ntp, data.pts, data.au) if err != nil { nazalog.Error("hls-fmp4 WriteMPEG4Audio failed, err:", err) continue } } else { // gohlslib不支持g711 continue } } } session.done = true } func (session *HlsSession) OnStop() { if session.done { session.muxer.Close() } } func (session *HlsSession) HandleRequest(ctx *gin.Context) { nazalog.Info("handle hls request, appName:", session.appName, " streamName:", session.streamName, " path:", ctx.Request.URL.Path) session.muxer.Handle(ctx.Writer, ctx.Request) } type Frame struct { ntp time.Time pts time.Duration au [][]byte codecType uint8 } ================================================ FILE: fmp4/http-fmp4/server.go ================================================ package httpfmp4 import ( "github.com/gin-gonic/gin" ) type HttpFmp4Server struct { } func NewHttpFmp4Server() *HttpFmp4Server { svr := &HttpFmp4Server{} return svr } func (s *HttpFmp4Server) HandleRequest(c *gin.Context) { streamid := c.Param("streamid") appName := c.Query("app_name") session := NewHttpFmp4Session(appName, streamid) session.handleSession(c) } ================================================ FILE: fmp4/http-fmp4/session.go ================================================ package httpfmp4 import ( "errors" "net/http" "strings" "sync" "time" "github.com/q191201771/lalmax/fmp4/muxer" maxlogic "github.com/q191201771/lalmax/logic" "github.com/gofrs/uuid" "github.com/q191201771/naza/pkg/connection" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/naza/pkg/nazalog" ) var ErrWriteChanFull = errors.New("Fmp4 Session write channel full") var ( readBufSize = 4096 // session connection读缓冲的大小 wChanSize = 256 // session 发送数据时,channel 的大小 ) type HttpFmp4Session struct { appName string streamid string group *maxlogic.Group subscriberId string rtmp2Fmp4Remuxer *muxer.Rtmp2Fmp4Remuxer w gin.ResponseWriter conn connection.Connection disposeOnce sync.Once log nazalog.Logger } func NewHttpFmp4Session(appName, streamid string) *HttpFmp4Session { streamid = strings.TrimSuffix(streamid, ".mp4") u, _ := uuid.NewV4() session := &HttpFmp4Session{ appName: appName, streamid: streamid, subscriberId: u.String(), log: nazalog.WithPrefix(u.String()), } session.rtmp2Fmp4Remuxer = muxer.NewRtmp2Fmp4Remuxer(session).WithLog(session.log) session.log.Infof("create http fmp4 session, appName:%s, streamid:%s", appName, streamid) return session } func (session *HttpFmp4Session) OnInitFmp4(init []byte) { session.conn.Write(init) } func (session *HttpFmp4Session) OnFmp4Packets(currentPart *muxer.MuxerPart, lastSampleDuration time.Duration, end bool, isVideo bool) { if currentPart != nil { if err := currentPart.Encode(lastSampleDuration, end); err == nil { session.conn.Write(currentPart.Bytes()) } } } func (session *HttpFmp4Session) Dispose() error { return session.dispose() } func (session *HttpFmp4Session) dispose() error { var retErr error session.disposeOnce.Do(func() { session.OnStop() if session.conn == nil { retErr = base.ErrSessionNotStarted return } retErr = session.conn.Close() }) return retErr } func (session *HttpFmp4Session) handleSession(c *gin.Context) { ok, group := maxlogic.GetGroupManagerInstance().GetGroup(maxlogic.NewStreamKey(session.appName, session.streamid)) if !ok { nazalog.Errorf("stream is not found, appName:%s, streamid:%s", session.appName, session.streamid) c.Status(http.StatusNotFound) return } session.group = group session.w = c.Writer c.Header("Content-Type", "video/mp4") c.Header("Connection", "close") c.Header("Expires", "-1") h, ok := session.w.(http.Hijacker) if !ok { nazalog.Error("gin response does not implement http.Hijacker") return } conn, bio, err := h.Hijack() if err != nil { nazalog.Errorf("hijack failed. err=%+v", err) return } if bio.Reader.Buffered() != 0 || bio.Writer.Buffered() != 0 { nazalog.Errorf("hijack but buffer not empty. rb=%d, wb=%d", bio.Reader.Buffered(), bio.Writer.Buffered()) } session.conn = connection.New(conn, func(option *connection.Option) { option.ReadBufSize = readBufSize option.WriteChanSize = wChanSize }) if err = session.writeHttpHeader(session.w.Header()); err != nil { nazalog.Errorf("session writeHttpHeader. err=%+v", err) return } session.group.AddSubscriber(maxlogic.SubscriberInfo{ SubscriberID: session.subscriberId, Protocol: maxlogic.SubscriberProtocolHTTPFMP4, }, session) go func() { readBuf := make([]byte, 1024) _, err = session.conn.Read(readBuf) session.dispose() }() } func (session *HttpFmp4Session) writeHttpHeader(header http.Header) error { p := make([]byte, 0, 1024) p = append(p, []byte("HTTP/1.1 200 OK\r\n")...) for k, vs := range header { for _, v := range vs { p = append(p, k...) p = append(p, ": "...) for i := 0; i < len(v); i++ { b := v[i] if b <= 31 { // prevent response splitting. b = ' ' } p = append(p, b) } p = append(p, "\r\n"...) } } p = append(p, "\r\n"...) return session.write(p) } func (session *HttpFmp4Session) write(buf []byte) (err error) { if session.conn != nil { _, err = session.conn.Write(buf) } return err } func (session *HttpFmp4Session) OnMsg(msg base.RtmpMsg) { if session.rtmp2Fmp4Remuxer != nil { session.rtmp2Fmp4Remuxer.FeedRtmpMessage(msg) } } func (session *HttpFmp4Session) OnStop() { if session.group != nil { session.group.RemoveSubscriber(session.subscriberId) } } func (session *HttpFmp4Session) GetSubscriberStat() maxlogic.SubscriberStat { if session == nil || session.conn == nil { return maxlogic.SubscriberStat{} } connStat := session.conn.GetStat() stat := maxlogic.SubscriberStat{ ReadBytesSum: connStat.ReadBytesSum, WroteBytesSum: connStat.WroteBytesSum, } if remoteAddr := session.conn.RemoteAddr(); remoteAddr != nil { stat.RemoteAddr = remoteAddr.String() } return stat } ================================================ FILE: fmp4/muxer/codec.go ================================================ package muxer import ( "bytes" "github.com/q191201771/lal/pkg/aac" ) type Codec interface { IsVideo() bool Equal(other Codec) bool String() string } type CodecH264 struct { SPS []byte PPS []byte } func (c *CodecH264) IsVideo() bool { return true } func (c *CodecH264) Equal(other Codec) bool { if other2, ok := other.(*CodecH264); ok { return bytes.Equal(c.SPS, other2.SPS) && bytes.Equal(c.PPS, other2.PPS) } return false } func (c *CodecH264) String() string { return "H264" } type CodecH265 struct { SPS []byte PPS []byte VPS []byte } func (c *CodecH265) IsVideo() bool { return true } func (c *CodecH265) Equal(other Codec) bool { if other2, ok := other.(*CodecH265); ok { return bytes.Equal(c.SPS, other2.SPS) && bytes.Equal(c.PPS, other2.PPS) && bytes.Equal(c.VPS, other2.VPS) } return false } func (c *CodecH265) String() string { return "H265" } type CodecAAC struct { Ctx *aac.AscContext AscData []byte } func (c *CodecAAC) IsVideo() bool { return false } func (c *CodecAAC) Equal(other Codec) bool { if other2, ok := other.(*CodecAAC); ok { return bytes.Equal(c.AscData, other2.AscData) } return false } func (c *CodecAAC) String() string { return "AAC" } type CodecOpus struct { ChannelCount int } func (c *CodecOpus) IsVideo() bool { return false } func (c *CodecOpus) Equal(other Codec) bool { return false } func (c *CodecOpus) String() string { return "OPUS" } ================================================ FILE: fmp4/muxer/file_writer.go ================================================ package muxer import ( "fmt" "os" "path/filepath" "time" "github.com/q191201771/lal/pkg/base" ) type Fmp4Record struct { curfw *FileWriter initMp4 []byte recordInterval int enableRecordByInterval bool streamName string recordPath string curDuration float64 hasWriteInit bool currentPart *MuxerPart currentFileNextPartId uint64 currentFileLastPartVideoStartDts time.Duration currentFIleLastPartAudioStartDts time.Duration } func NewFmp4Record(recordInterval int, enableRecordByInterval bool, streamName, recordPath string) *Fmp4Record { r := &Fmp4Record{ recordInterval: recordInterval, enableRecordByInterval: enableRecordByInterval, streamName: streamName, recordPath: recordPath, } if !r.enableRecordByInterval { err := r.createFile() if err != nil { return nil } } return r } func (r *Fmp4Record) createFile() (err error) { r.curfw = &FileWriter{} filename := fmt.Sprintf("%s-%d.mp4", r.streamName, time.Now().Unix()) filenameWithPath := filepath.Join(r.recordPath, filename) if err := r.curfw.Create(filenameWithPath); err != nil { Log.Errorf("[%s] record fmp4 open file failed. filename=%s, err=%+v", filenameWithPath, err) r.curfw = nil return err } return } func (r *Fmp4Record) WriteInitFmp4(init []byte) { r.initMp4 = init } func (r *Fmp4Record) WriteFmp4Segment(part *MuxerPart, lastSampleDuration time.Duration, end bool) (err error) { if r.enableRecordByInterval { r.WriteMultiFile(part, lastSampleDuration, end) } else { r.writeSingleFile(part, lastSampleDuration, end) } return } func (r *Fmp4Record) WriteMultiFile(part *MuxerPart, lastSampleDuration time.Duration, end bool) { if part == nil { return } if r.curfw == nil { err := r.createFile() if err != nil { Log.Error("create file failed, err:", err) return } r.curDuration = 0 r.currentFileLastPartVideoStartDts = 0 r.currentFIleLastPartAudioStartDts = 0 r.currentFileNextPartId = 0 r.curfw.Write(r.initMp4) } if r.currentFileLastPartVideoStartDts == 0 { r.currentFileLastPartVideoStartDts = part.StartVideoDts() } baseDecodeVideoTime := part.StartVideoDts() - r.currentFileLastPartVideoStartDts if r.currentFIleLastPartAudioStartDts == 0 { r.currentFIleLastPartAudioStartDts = part.StartAudioDts() } baseDecodeAudioTime := part.StartAudioDts() - r.currentFIleLastPartAudioStartDts if end { part.SetVideoStartDts(baseDecodeVideoTime) part.SetAudioStartDts(baseDecodeAudioTime) part.SetPartId(r.currentFileNextPartId) part.Encode(lastSampleDuration, end) r.curfw.Write(part.Bytes()) // 结束写文件 r.curfw.Dispose() r.curfw = nil } else { curpartduration := part.CalcDuration(lastSampleDuration, end) part.SetVideoStartDts(baseDecodeVideoTime) part.SetAudioStartDts(baseDecodeAudioTime) part.SetPartId(r.currentFileNextPartId) part.Encode(lastSampleDuration, end) r.curfw.Write(part.Bytes()) r.currentFileNextPartId++ r.curDuration += curpartduration.Seconds() if r.curDuration >= float64(r.recordInterval) { // 结束写文件 r.curfw.Dispose() r.curfw = nil } } } func (r *Fmp4Record) writeSingleFile(part *MuxerPart, lastSampleDuration time.Duration, end bool) { if r.hasWriteInit { err := part.Encode(lastSampleDuration, false) if err != nil { Log.Errorf("encode muxer part failed: %v", err) return } r.curfw.Write(part.Bytes()) } else { r.curfw.Write(r.initMp4) err := part.Encode(lastSampleDuration, false) if err != nil { Log.Errorf("encode muxer part failed: %v", err) return } r.curfw.Write(part.Bytes()) r.hasWriteInit = true } } func (r *Fmp4Record) Dispose() error { if r.curfw != nil { return r.curfw.Dispose() } return nil } type FileWriter struct { fp *os.File } func (fw *FileWriter) Create(filename string) (err error) { fw.fp, err = os.Create(filename) return } func (fw *FileWriter) Write(b []byte) (err error) { if fw.fp == nil { return base.ErrFileNotExist } _, err = fw.fp.Write(b) return } func (fw *FileWriter) Dispose() error { if fw.fp == nil { return base.ErrFileNotExist } return fw.fp.Close() } func (fw *FileWriter) Name() string { if fw.fp == nil { return "" } return fw.fp.Name() } ================================================ FILE: fmp4/muxer/flac_box.go ================================================ package muxer import ( "github.com/abema/go-mp4" ) func BoxTypeFlac() mp4.BoxType { return mp4.StrToBoxType("fLaC") } func init() { mp4.AddAnyTypeBoxDef(&mp4.AudioSampleEntry{}, BoxTypeFlac()) } /* func BoxTypeFlac() mp4.BoxType { return mp4.StrToBoxType("fLaC") } func init() { mp4.AddBoxDef(&FlacBox{}) } type FlacBox struct { mp4.FullBox `mp4:"0,extend"` } func (f *FlacBox) GetType() mp4.BoxType { return BoxTypeFlac() } */ func BoxTypeDfla() mp4.BoxType { return mp4.StrToBoxType("dfLa") } func init() { mp4.AddBoxDef(&DflaBox{}) } type DflaBox struct { mp4.BaseCustomFieldObject Data []byte `mp4:"0,size=8,dynamic"` } func (d *DflaBox) GetType() mp4.BoxType { return BoxTypeDfla() } /* func (d *DflaBox) GetFieldLength(name string, ctx mp4.Context) uint { switch name { case "NALUnit": return uint(d.Length) } return 0 } */ // AddFlag adds the flag func (d *DflaBox) AddFlag(uint32) {} func (d *DflaBox) CheckFlag(uint32) bool { return false } func (d *DflaBox) GetFlags() uint32 { return 0 } func (d *DflaBox) GetVersion() uint8 { return 0 } func (d *DflaBox) RemoveFlag(uint32) { } func (d *DflaBox) SetFlags(uint32) { } func (d *DflaBox) SetVersion(uint8) { } ================================================ FILE: fmp4/muxer/init.go ================================================ package muxer import ( "fmt" "io" "github.com/abema/go-mp4" "github.com/q191201771/lal/pkg/aac" "github.com/q191201771/lal/pkg/hevc" ) // Specification: ISO 14496-1, Table 5 const ( objectTypeIndicationVisualISO14496part2 = 0x20 objectTypeIndicationAudioISO14496part3 = 0x40 objectTypeIndicationVisualISO1318part2Main = 0x61 objectTypeIndicationAudioISO11172part3 = 0x6B objectTypeIndicationVisualISO10918part1 = 0x6C ) // Specification: ISO 14496-1, Table 6 const ( streamTypeVisualStream = 0x04 streamTypeAudioStream = 0x05 ) func h265FindParams(params []mp4.HEVCNaluArray) ([]byte, []byte, []byte, error) { var vps []byte var sps []byte var pps []byte for _, arr := range params { switch hevc.ParseNaluType(arr.NaluType) { case hevc.NaluTypeVps, hevc.NaluTypeSps, hevc.NaluTypePps: if arr.NumNalus != 1 { return nil, nil, nil, fmt.Errorf("multiple VPS/SPS/PPS are not supported") } } switch hevc.ParseNaluType(arr.NaluType) { case hevc.NaluTypeVps: vps = arr.Nalus[0].NALUnit case hevc.NaluTypeSps: sps = arr.Nalus[0].NALUnit case hevc.NaluTypePps: pps = arr.Nalus[0].NALUnit } } if vps == nil { return nil, nil, nil, fmt.Errorf("VPS not provided") } if sps == nil { return nil, nil, nil, fmt.Errorf("SPS not provided") } if pps == nil { return nil, nil, nil, fmt.Errorf("PPS not provided") } return vps, sps, pps, nil } func h264FindParams(avcc *mp4.AVCDecoderConfiguration) ([]byte, []byte, error) { if len(avcc.SequenceParameterSets) > 1 { return nil, nil, fmt.Errorf("multiple SPS are not supported") } var sps []byte if len(avcc.SequenceParameterSets) == 1 { sps = avcc.SequenceParameterSets[0].NALUnit } if len(avcc.PictureParameterSets) > 1 { return nil, nil, fmt.Errorf("multiple PPS are not supported") } var pps []byte if len(avcc.PictureParameterSets) == 1 { pps = avcc.PictureParameterSets[0].NALUnit } return sps, pps, nil } func esdsFindDecoderConf(descriptors []mp4.Descriptor) *mp4.DecoderConfigDescriptor { for _, desc := range descriptors { if desc.Tag == mp4.DecoderConfigDescrTag { return desc.DecoderConfigDescriptor } } return nil } func esdsFindDecoderSpecificInfo(descriptors []mp4.Descriptor) []byte { for _, desc := range descriptors { if desc.Tag == mp4.DecSpecificInfoTag { return desc.Data } } return nil } // Init is a fMP4 initialization block. type Init struct { Tracks []*InitTrack } // Marshal encodes a fMP4 initialization file. func (i *Init) Marshal(w io.WriteSeeker) error { /* |ftyp| |moov| | |mvhd| | |trak| | |trak| | |....| | |mvex| | | |trex| | | |trex| | | |....| */ mw := newMP4Writer(w) _, err := mw.writeBox(&mp4.Ftyp{ // MajorBrand: [4]byte{'m', 'p', '4', '2'}, MinorVersion: 1, CompatibleBrands: []mp4.CompatibleBrandElem{ {CompatibleBrand: [4]byte{'m', 'p', '4', '1'}}, {CompatibleBrand: [4]byte{'m', 'p', '4', '2'}}, {CompatibleBrand: [4]byte{'i', 's', 'o', 'm'}}, {CompatibleBrand: [4]byte{'h', 'l', 's', 'f'}}, }, }) if err != nil { return err } _, err = mw.writeBoxStart(&mp4.Moov{}) // if err != nil { return err } _, err = mw.writeBox(&mp4.Mvhd{ // Timescale: 1000, Rate: 65536, Volume: 256, Matrix: [9]int32{0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000}, NextTrackID: 4294967295, }) if err != nil { return err } for _, track := range i.Tracks { err = track.marshal(mw) if err != nil { return err } } _, err = mw.writeBoxStart(&mp4.Mvex{}) // if err != nil { return err } for _, track := range i.Tracks { _, err = mw.writeBox(&mp4.Trex{ // TrackID: uint32(track.ID), DefaultSampleDescriptionIndex: 1, }) if err != nil { return err } } err = mw.writeBoxEnd() // if err != nil { return err } err = mw.writeBoxEnd() // if err != nil { return err } return nil } // Unmarshal decodes a fMP4 initialization block. func (i *Init) Unmarshal(r io.ReadSeeker) error { type readState int const ( waitingTrak readState = iota waitingTkhd waitingMdhd waitingCodec waitingAv1C waitingVpcC waitingHvcC waitingAvcC waitingVideoEsds waitingAudioEsds waitingDOps waitingDac3 waitingPcmC ) state := waitingTrak var curTrack *InitTrack /* var width int var height int var sampleRate int var channelCount int */ _, err := mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) { if !h.BoxInfo.IsSupportedType() { if state != waitingTrak { i.Tracks = i.Tracks[:len(i.Tracks)-1] state = waitingTrak } } else { switch h.BoxInfo.Type.String() { case "moov": return h.Expand() case "trak": if state != waitingTrak { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } curTrack = &InitTrack{} i.Tracks = append(i.Tracks, curTrack) state = waitingTkhd return h.Expand() case "tkhd": if state != waitingTkhd { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } tkhd := box.(*mp4.Tkhd) curTrack.ID = int(tkhd.TrackID) state = waitingMdhd case "mdia": return h.Expand() case "mdhd": if state != waitingMdhd { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } mdhd := box.(*mp4.Mdhd) curTrack.TimeScale = mdhd.Timescale state = waitingCodec case "minf", "stbl", "stsd": return h.Expand() case "avc1": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } state = waitingAvcC return h.Expand() case "avcC": if state != waitingAvcC { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } avcc := box.(*mp4.AVCDecoderConfiguration) sps, pps, err := h264FindParams(avcc) if err != nil { return nil, err } curTrack.Codec = &CodecH264{ SPS: sps, PPS: pps, } state = waitingTrak /* case "vp09": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } vp09 := box.(*mp4.VisualSampleEntry) width = int(vp09.Width) height = int(vp09.Height) state = waitingVpcC return h.Expand() */ /* case "vpcC": if state != waitingVpcC { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } vpcc := box.(*mp4.VpcC) curTrack.Codec = &CodecVP9{ Width: width, Height: height, Profile: vpcc.Profile, BitDepth: vpcc.BitDepth, ChromaSubsampling: vpcc.ChromaSubsampling, ColorRange: vpcc.VideoFullRangeFlag != 0, } state = waitingTrak */ case "vp08": // VP8, not supported yet return nil, nil case "hev1", "hvc1": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } state = waitingHvcC return h.Expand() case "hvcC": if state != waitingHvcC { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } hvcc := box.(*mp4.HvcC) vps, sps, pps, err := h265FindParams(hvcc.NaluArrays) if err != nil { return nil, err } curTrack.Codec = &CodecH265{ VPS: vps, SPS: sps, PPS: pps, } state = waitingTrak case "av01": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } state = waitingAv1C return h.Expand() case "Opus": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } state = waitingDOps return h.Expand() case "dOps": if state != waitingDOps { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } dops := box.(*mp4.DOps) curTrack.Codec = &CodecOpus{ ChannelCount: int(dops.OutputChannelCount), } state = waitingTrak case "mp4v": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } mp4v := box.(*mp4.VisualSampleEntry) width := int(mp4v.Width) height := int(mp4v.Height) Log.Info("width:", width, " height:", height) state = waitingVideoEsds return h.Expand() case "mp4a": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } mp4a := box.(*mp4.AudioSampleEntry) sampleRate := int(mp4a.SampleRate / 65536) channelCount := int(mp4a.ChannelCount) Log.Info("sampleRate:", sampleRate, " channelCount:", channelCount) state = waitingAudioEsds return h.Expand() case "esds": box, _, err := h.ReadPayload() if err != nil { return nil, err } esds := box.(*mp4.Esds) conf := esdsFindDecoderConf(esds.Descriptors) if conf == nil { return nil, fmt.Errorf("unable to find decoder config") } switch state { case waitingVideoEsds: switch conf.ObjectTypeIndication { case objectTypeIndicationVisualISO14496part2: spec := esdsFindDecoderSpecificInfo(esds.Descriptors) if spec == nil { return nil, fmt.Errorf("unable to find decoder specific info") } /* curTrack.Codec = &CodecMPEG4Video{ Config: spec, } */ case objectTypeIndicationVisualISO1318part2Main: spec := esdsFindDecoderSpecificInfo(esds.Descriptors) if spec == nil { return nil, fmt.Errorf("unable to find decoder specific info") } /* curTrack.Codec = &CodecMPEG1Video{ Config: spec, } */ case objectTypeIndicationVisualISO10918part1: /* curTrack.Codec = &CodecMJPEG{ Width: width, Height: height, } */ default: return nil, fmt.Errorf("unsupported object type indication: 0x%.2x", conf.ObjectTypeIndication) } state = waitingTrak case waitingAudioEsds: switch conf.ObjectTypeIndication { case objectTypeIndicationAudioISO14496part3: spec := esdsFindDecoderSpecificInfo(esds.Descriptors) if spec == nil { return nil, fmt.Errorf("unable to find decoder specific info") } ascCtx, err := aac.NewAscContext(spec) if err != nil { return nil, fmt.Errorf("NewAscContext failed, err: %w", err) } curTrack.Codec = &CodecAAC{ Ctx: ascCtx, AscData: spec, } case objectTypeIndicationAudioISO11172part3: /* curTrack.Codec = &CodecMPEG1Audio{ SampleRate: sampleRate, ChannelCount: channelCount, } */ default: return nil, fmt.Errorf("unsupported object type indication: 0x%.2x", conf.ObjectTypeIndication) } default: return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } state = waitingTrak case "ac-3": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } ac3 := box.(*mp4.AudioSampleEntry) sampleRate := int(ac3.SampleRate / 65536) channelCount := int(ac3.ChannelCount) Log.Info("sampleRate:", sampleRate, " channelCount:", channelCount) state = waitingDac3 return h.Expand() case "dac3": if state != waitingDac3 { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } /* box, _, err := h.ReadPayload() if err != nil { return nil, err } dac3 := box.(*mp4.Dac3) curTrack.Codec = &C{ SampleRate: sampleRate, ChannelCount: channelCount, Fscod: dac3.Fscod, Bsid: dac3.Bsid, Bsmod: dac3.Bsmod, Acmod: dac3.Acmod, LfeOn: dac3.LfeOn != 0, BitRateCode: dac3.BitRateCode, } */ state = waitingTrak case "ipcm": if state != waitingCodec { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } ac3 := box.(*mp4.AudioSampleEntry) sampleRate := int(ac3.SampleRate / 65536) channelCount := int(ac3.ChannelCount) Log.Info("sampleRate:", sampleRate, " channelCount:", channelCount) state = waitingPcmC return h.Expand() /* case "pcmC": if state != waitingPcmC { return nil, fmt.Errorf("unexpected box '%v'", h.BoxInfo.Type) } box, _, err := h.ReadPayload() if err != nil { return nil, err } pcmc := box.(*mp4.PcmC) curTrack.Codec = &CodecLPCM{ LittleEndian: (pcmc.FormatFlags & 0x01) != 0, BitDepth: int(pcmc.PCMSampleSize), SampleRate: sampleRate, ChannelCount: channelCount, } state = waitingTrak */ } } return nil, nil }) if err != nil { return err } if state != waitingTrak { return fmt.Errorf("parse error") } if len(i.Tracks) == 0 { return fmt.Errorf("no tracks found") } return nil } ================================================ FILE: fmp4/muxer/init_track.go ================================================ package muxer import ( "fmt" "github.com/abema/go-mp4" "github.com/bluenviron/mediacommon/pkg/codecs/h265" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/hevc" ) func boolToUint8(v bool) uint8 { if v { return 1 } return 0 } // InitTrack is a track of Init. type InitTrack struct { // ID, starts from 1. ID int // time scale. TimeScale uint32 // maximum bitrate. // it defaults to 1MB for video tracks, 128k for audio tracks. MaxBitrate uint32 // average bitrate. // it defaults to 1MB for video tracks, 128k for audio tracks. AvgBitrate uint32 // codec. Codec Codec } func (it *InitTrack) marshal(w *mp4Writer) error { /* |trak| | |tkhd| | |mdia| | | |mdhd| | | |hdlr| | | |minf| | | | |vmhd| (video) | | | |smhd| (audio) | | | |dinf| | | | | |dref| | | | | | |url| | | | |stbl| | | | | |stsd| | | | | | |av01| (AV1) | | | | | | |av1C| | | | | | | |btrt| | | | | | |vp09| (VP9) | | | | | | |vpcC| | | | | | | |btrt| | | | | | |hev1| (H265) | | | | | | |hvcC| | | | | | | |btrt| | | | | | |avc1| (H264) | | | | | | |avcC| | | | | | | |btrt| | | | | | |mp4v| (MPEG-4/2/1 video, MJPEG) | | | | | | |esds| | | | | | | |btrt| | | | | | |Opus| (Opus) | | | | | | |dOps| | | | | | | |btrt| | | | | | |mp4a| (MPEG-4/1 audio) | | | | | | |esds| | | | | | | |btrt| | | | | | |ac-3| (AC-3) | | | | | | |dac3| | | | | | | |btrt| | | | | | |ipcm| (LPCM) | | | | | | |pcmC| | | | | | | |btrt| | | | | | |fLaC| (FLAC) | | | | |stts| | | | | |stsc| | | | | |stsz| | | | | |stco| */ var width int var height int _, err := w.writeBoxStart(&mp4.Trak{}) // if err != nil { return err } if it.Codec == nil { return fmt.Errorf("codec is not for track") } switch codec := it.Codec.(type) { case *CodecH264: if len(codec.SPS) == 0 || len(codec.PPS) == 0 { return fmt.Errorf("H264 parameters not provided") } var ctx avc.Context err = avc.ParseSps(codec.SPS, &ctx) if err != nil { return fmt.Errorf("h264 parse sps failed") } width = int(ctx.Width) height = int(ctx.Height) case *CodecH265: if len(codec.SPS) == 0 || len(codec.PPS) == 0 || len(codec.VPS) == 0 { return fmt.Errorf("H265 parameters not provided") } var ctx hevc.Context err = hevc.ParseSps(codec.SPS, &ctx) if err != nil { return fmt.Errorf("hevc parse sps failed") } width = int(ctx.PicWidthInLumaSamples) height = int(ctx.PicHeightInLumaSamples) } if it.Codec == nil { return nil } if it.Codec.IsVideo() { _, err = w.writeBox(&mp4.Tkhd{ // FullBox: mp4.FullBox{ Flags: [3]byte{0, 0, 3}, }, TrackID: uint32(it.ID), Width: uint32(width * 65536), Height: uint32(height * 65536), Matrix: [9]int32{0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000}, }) if err != nil { return err } } else { _, err = w.writeBox(&mp4.Tkhd{ // FullBox: mp4.FullBox{ Flags: [3]byte{0, 0, 3}, }, TrackID: uint32(it.ID), AlternateGroup: 1, Volume: 256, Matrix: [9]int32{0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000}, }) if err != nil { return err } } _, err = w.writeBoxStart(&mp4.Mdia{}) // if err != nil { return err } _, err = w.writeBox(&mp4.Mdhd{ // Timescale: it.TimeScale, Language: [3]byte{'u', 'n', 'd'}, }) if err != nil { return err } if it.Codec.IsVideo() { _, err = w.writeBox(&mp4.Hdlr{ // HandlerType: [4]byte{'v', 'i', 'd', 'e'}, Name: "VideoHandler", }) if err != nil { return err } } else { _, err = w.writeBox(&mp4.Hdlr{ // HandlerType: [4]byte{'s', 'o', 'u', 'n'}, Name: "SoundHandler", }) if err != nil { return err } } _, err = w.writeBoxStart(&mp4.Minf{}) // if err != nil { return err } if it.Codec.IsVideo() { _, err = w.writeBox(&mp4.Vmhd{ // FullBox: mp4.FullBox{ Flags: [3]byte{0, 0, 1}, }, }) if err != nil { return err } } else { _, err = w.writeBox(&mp4.Smhd{}) // if err != nil { return err } } _, err = w.writeBoxStart(&mp4.Dinf{}) // if err != nil { return err } _, err = w.writeBoxStart(&mp4.Dref{ // EntryCount: 1, }) if err != nil { return err } _, err = w.writeBox(&mp4.Url{ // FullBox: mp4.FullBox{ Flags: [3]byte{0, 0, 1}, }, }) if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } _, err = w.writeBoxStart(&mp4.Stbl{}) // if err != nil { return err } _, err = w.writeBoxStart(&mp4.Stsd{ // EntryCount: 1, }) if err != nil { return err } maxBitrate := it.MaxBitrate if maxBitrate == 0 { if it.Codec.IsVideo() { maxBitrate = 1000000 } else { maxBitrate = 128825 } } avgBitrate := it.AvgBitrate if avgBitrate == 0 { if it.Codec.IsVideo() { avgBitrate = 1000000 } else { avgBitrate = 128825 } } switch codec := it.Codec.(type) { case *CodecH264: _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ // SampleEntry: mp4.SampleEntry{ AnyTypeBox: mp4.AnyTypeBox{ Type: mp4.BoxTypeAvc1(), }, DataReferenceIndex: 1, }, Width: uint16(width), Height: uint16(height), Horizresolution: 4718592, Vertresolution: 4718592, FrameCount: 1, Depth: 24, PreDefined3: -1, }) if err != nil { return err } var ctx avc.Context err = avc.ParseSps(codec.SPS, &ctx) if err != nil { return fmt.Errorf("h264 parse sps failed") } _, err = w.writeBox(&mp4.AVCDecoderConfiguration{ // AnyTypeBox: mp4.AnyTypeBox{ Type: mp4.BoxTypeAvcC(), }, ConfigurationVersion: 1, Profile: ctx.Profile, ProfileCompatibility: codec.SPS[2], Level: ctx.Level, LengthSizeMinusOne: 3, NumOfSequenceParameterSets: 1, SequenceParameterSets: []mp4.AVCParameterSet{ { Length: uint16(len(codec.SPS)), NALUnit: codec.SPS, }, }, NumOfPictureParameterSets: 1, PictureParameterSets: []mp4.AVCParameterSet{ { Length: uint16(len(codec.PPS)), NALUnit: codec.PPS, }, }, }) if err != nil { return err } case *CodecH265: _, err = w.writeBoxStart(&mp4.VisualSampleEntry{ // SampleEntry: mp4.SampleEntry{ AnyTypeBox: mp4.AnyTypeBox{ Type: mp4.BoxTypeHev1(), }, DataReferenceIndex: 1, }, Width: uint16(width), Height: uint16(height), Horizresolution: 4718592, Vertresolution: 4718592, FrameCount: 1, Depth: 24, PreDefined3: -1, }) if err != nil { return err } var ctx hevc.Context err = hevc.ParseSps(codec.SPS, &ctx) if err != nil { return fmt.Errorf("hevc parse sps failed") } _, err = w.writeBox(&mp4.HvcC{ // ConfigurationVersion: 1, GeneralProfileIdc: ctx.GeneralProfileIdc, GeneralProfileCompatibility: Uint32ToBoolSlice(ctx.GeneralProfileCompatibilityFlags), GeneralConstraintIndicator: [6]uint8{ codec.SPS[7], codec.SPS[8], codec.SPS[9], codec.SPS[10], codec.SPS[11], codec.SPS[12], }, GeneralLevelIdc: ctx.GeneralLevelIdc, // MinSpatialSegmentationIdc // ParallelismType ChromaFormatIdc: uint8(ctx.ChromaFormat), BitDepthLumaMinus8: uint8(ctx.BitDepthLumaMinus8), BitDepthChromaMinus8: uint8(ctx.BitDepthChromaMinus8), // AvgFrameRate // ConstantFrameRate NumTemporalLayers: 1, // TemporalIdNested LengthSizeMinusOne: 3, NumOfNaluArrays: 3, NaluArrays: []mp4.HEVCNaluArray{ { NaluType: byte(h265.NALUType_VPS_NUT), NumNalus: 1, Nalus: []mp4.HEVCNalu{{ Length: uint16(len(codec.VPS)), NALUnit: codec.VPS, }}, }, { NaluType: byte(h265.NALUType_SPS_NUT), NumNalus: 1, Nalus: []mp4.HEVCNalu{{ Length: uint16(len(codec.SPS)), NALUnit: codec.SPS, }}, }, { NaluType: byte(h265.NALUType_PPS_NUT), NumNalus: 1, Nalus: []mp4.HEVCNalu{{ Length: uint16(len(codec.PPS)), NALUnit: codec.PPS, }}, }, }, }) if err != nil { return err } case *CodecAAC: sampleRate, _ := codec.Ctx.GetSamplingFrequency() _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ // SampleEntry: mp4.SampleEntry{ AnyTypeBox: mp4.AnyTypeBox{ Type: mp4.BoxTypeMp4a(), }, DataReferenceIndex: 1, }, ChannelCount: uint16(codec.Ctx.ChannelConfiguration), SampleSize: 16, SampleRate: uint32(sampleRate * 65536), }) if err != nil { return err } _, err = w.writeBox(&mp4.Esds{ // Descriptors: []mp4.Descriptor{ { Tag: mp4.ESDescrTag, Size: 32 + uint32(len(codec.AscData)), ESDescriptor: &mp4.ESDescriptor{ ESID: uint16(it.ID), }, }, { Tag: mp4.DecoderConfigDescrTag, Size: 18 + uint32(len(codec.Ctx.Pack())), DecoderConfigDescriptor: &mp4.DecoderConfigDescriptor{ ObjectTypeIndication: objectTypeIndicationAudioISO14496part3, StreamType: streamTypeAudioStream, Reserved: true, MaxBitrate: maxBitrate, AvgBitrate: avgBitrate, }, }, { Tag: mp4.DecSpecificInfoTag, Size: uint32(len(codec.AscData)), Data: codec.AscData, }, { Tag: mp4.SLConfigDescrTag, Size: 1, Data: []byte{0x02}, }, }, }) if err != nil { return err } case *CodecOpus: _, err = w.writeBoxStart(&mp4.AudioSampleEntry{ // SampleEntry: mp4.SampleEntry{ AnyTypeBox: mp4.AnyTypeBox{ Type: mp4.BoxTypeOpus(), }, DataReferenceIndex: 1, }, ChannelCount: uint16(codec.ChannelCount), SampleSize: 16, SampleRate: 48000 * 65536, }) if err != nil { return err } _, err = w.writeBox(&mp4.DOps{ // OutputChannelCount: uint8(codec.ChannelCount), PreSkip: 312, InputSampleRate: 48000, }) if err != nil { return err } } _, err = w.writeBox(&mp4.Btrt{ // MaxBitrate: maxBitrate, AvgBitrate: avgBitrate, }) if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } _, err = w.writeBox(&mp4.Stts{ // }) if err != nil { return err } _, err = w.writeBox(&mp4.Stsc{ // }) if err != nil { return err } _, err = w.writeBox(&mp4.Stsz{ // }) if err != nil { return err } _, err = w.writeBox(&mp4.Stco{ // }) if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } err = w.writeBoxEnd() // if err != nil { return err } return nil } func Uint32ToBoolSlice(num uint32) [32]bool { var boolSlice [32]bool for i := 0; i < 32; i++ { boolSlice[i] = num&(1< if err != nil { return err } _, err = mw.writeBox(&mp4.Mfhd{ // SequenceNumber: p.SequenceNumber, }) if err != nil { return err } trackLen := len(p.Tracks) truns := make([]*mp4.Trun, trackLen) trunOffsets := make([]int, trackLen) dataOffsets := make([]int, trackLen) dataSize := 0 for i, track := range p.Tracks { var trun *mp4.Trun var trunOffset int trun, trunOffset, err = track.marshal(mw) if err != nil { return err } dataOffsets[i] = dataSize for _, sample := range track.Samples { dataSize += len(sample.Payload) } truns[i] = trun trunOffsets[i] = trunOffset } err = mw.writeBoxEnd() // if err != nil { return err } mdat := &mp4.Mdat{} // mdat.Data = make([]byte, dataSize) pos := 0 for _, track := range p.Tracks { for _, sample := range track.Samples { pos += copy(mdat.Data[pos:], sample.Payload) } } mdatOffset, err := mw.writeBox(mdat) if err != nil { return err } for i := range p.Tracks { truns[i].DataOffset = int32(dataOffsets[i] + mdatOffset - moofOffset + 8) err = mw.rewriteBox(trunOffsets[i], truns[i]) if err != nil { return err } } return nil } ================================================ FILE: fmp4/muxer/part_sample.go ================================================ package muxer import ( "time" ) // PartSample is a sample of a PartTrack. type PartSample struct { Dts time.Duration Duration uint32 PTSOffset int32 IsNonSyncSample bool Payload []byte } func avccMarshalSize(au [][]byte) int { n := 0 for _, nalu := range au { n += 4 + len(nalu) } return n } // AVCCMarshal encodes an access unit into the AVCC stream format. // Specification: ISO 14496-15, section 5.3.4.2.1 func AVCCMarshal(au [][]byte) ([]byte, error) { buf := make([]byte, avccMarshalSize(au)) pos := 0 for _, nalu := range au { naluLen := len(nalu) buf[pos] = byte(naluLen >> 24) buf[pos+1] = byte(naluLen >> 16) buf[pos+2] = byte(naluLen >> 8) buf[pos+3] = byte(naluLen) pos += 4 pos += copy(buf[pos:], nalu) } return buf, nil } // NewPartSampleH26x creates a sample with H26x data. func NewPartSampleH26x(ptsOffset int32, randomAccessPresent bool, au [][]byte, duration uint32, dts time.Duration) *PartSample { avcc, err := AVCCMarshal(au) if err != nil { return nil } return &PartSample{ Dts: dts, PTSOffset: ptsOffset, IsNonSyncSample: !randomAccessPresent, Payload: avcc, Duration: duration, } } ================================================ FILE: fmp4/muxer/part_track.go ================================================ package muxer import "github.com/abema/go-mp4" // PartTrack is a track of Part. type PartTrack struct { ID int BaseTime uint64 Samples []*PartSample } func (pt *PartTrack) marshal(w *mp4Writer) (*mp4.Trun, int, error) { /* |traf| | |tfhd| | |tfdt| | |trun| */ _, err := w.writeBoxStart(&mp4.Traf{}) // if err != nil { return nil, 0, err } flags := 0 _, err = w.writeBox(&mp4.Tfhd{ // FullBox: mp4.FullBox{ Flags: [3]byte{2, byte(flags >> 8), byte(flags)}, }, TrackID: uint32(pt.ID), }) if err != nil { return nil, 0, err } _, err = w.writeBox(&mp4.Tfdt{ // FullBox: mp4.FullBox{ Version: 1, }, // sum of decode durations of all earlier samples BaseMediaDecodeTimeV1: pt.BaseTime, }) if err != nil { return nil, 0, err } flags = trunFlagDataOffsetPreset | trunFlagSampleDurationPresent | trunFlagSampleSizePresent for _, sample := range pt.Samples { if sample.IsNonSyncSample { flags |= trunFlagSampleFlagsPresent } if sample.PTSOffset != 0 { flags |= trunFlagSampleCompositionTimeOffsetPresentOrV1 } } trun := &mp4.Trun{ // FullBox: mp4.FullBox{ Version: 1, Flags: [3]byte{0, byte(flags >> 8), byte(flags)}, }, SampleCount: uint32(len(pt.Samples)), } for _, sample := range pt.Samples { var flags uint32 if sample.IsNonSyncSample { flags |= sampleFlagIsNonSyncSample } trun.Entries = append(trun.Entries, mp4.TrunEntry{ SampleDuration: sample.Duration, SampleSize: uint32(len(sample.Payload)), SampleFlags: flags, SampleCompositionTimeOffsetV1: sample.PTSOffset, }) } trunOffset, err := w.writeBox(trun) if err != nil { return nil, 0, err } err = w.writeBoxEnd() // if err != nil { return nil, 0, err } return trun, trunOffset, nil } ================================================ FILE: fmp4/muxer/rtmp2fmp4.go ================================================ package muxer import ( "bytes" "time" "github.com/q191201771/lal/pkg/aac" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/hevc" "github.com/q191201771/naza/pkg/nazalog" ) type IRtmp2Fmp4muxerObserver interface { OnInitFmp4(init []byte) OnFmp4Packets(currentPart *MuxerPart, lastSampleDuration time.Duration, end bool, isVideo bool) } var waitHeaderQueueSize = 16 type Rtmp2Fmp4Remuxer struct { data []base.RtmpMsg done bool maxMsgSize int vCodec Codec aCodec Codec mux *Muxer observer IRtmp2Fmp4muxerObserver log nazalog.Logger nextPartId uint64 currentPart *MuxerPart } func NewRtmp2Fmp4Remuxer(observer IRtmp2Fmp4muxerObserver) *Rtmp2Fmp4Remuxer { m := &Rtmp2Fmp4Remuxer{ maxMsgSize: waitHeaderQueueSize, data: make([]base.RtmpMsg, waitHeaderQueueSize)[0:0], done: false, observer: observer, log: Log, } m.mux = NewMuxer() m.mux.WithLog(m.log) return m } func (m *Rtmp2Fmp4Remuxer) WithLog(log nazalog.Logger) *Rtmp2Fmp4Remuxer { m.log = log m.mux.WithLog(m.log) return m } func (m *Rtmp2Fmp4Remuxer) FeedRtmpMessage(msg base.RtmpMsg) { m.Push(msg) } func (m *Rtmp2Fmp4Remuxer) Push(msg base.RtmpMsg) { if msg.Header.MsgTypeId == base.RtmpTypeIdMetadata { return } if m.done { m.pack(msg) return } if msg.IsVideoKeySeqHeader() { switch msg.VideoCodecId() { case base.RtmpCodecIdAvc: if sps, pps, err := avc.ParseSpsPpsFromSeqHeader(msg.Payload); err != nil { m.log.Errorf("parse sps pps from seq header failed: %v", err) return } else { m.vCodec = &CodecH264{ SPS: sps, PPS: pps, } } case base.RtmpCodecIdHevc: var vps, sps, pps []byte var err error if msg.IsEnhanced() { vps, sps, pps, err = hevc.ParseVpsSpsPpsFromEnhancedSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseVpsSpsPpsFromEnhancedSeqHeader failed, err:", err) break } } else { vps, sps, pps, err = hevc.ParseVpsSpsPpsFromSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseVpsSpsPpsFromSeqHeader failed, err:", err) break } } m.vCodec = &CodecH265{ VPS: vps, SPS: sps, PPS: pps, } default: m.log.Errorf("unknown video codec id: %d", msg.VideoCodecId()) return } } if msg.Header.MsgTypeId == base.RtmpTypeIdAudio { switch msg.AudioCodecId() { case base.RtmpSoundFormatAac: if msg.IsAacSeqHeader() { if ascCtx, err := aac.NewAscContext(msg.Payload[2:]); err != nil { m.log.Errorf("new asc context failed: %v", err) return } else { m.aCodec = &CodecAAC{ Ctx: ascCtx, AscData: msg.Payload[2:], } } } default: return } } m.data = append(m.data, msg.Clone()) if m.vCodec != nil && m.aCodec != nil { m.drain() return } if len(m.data) >= m.maxMsgSize { m.drain() return } } func (m *Rtmp2Fmp4Remuxer) drain() { if m.vCodec != nil { m.mux.AddVideoTrack(m.vCodec) } if m.aCodec != nil { m.mux.AddAudioTrack(m.aCodec) } init := m.mux.GetInitMp4() if m.observer != nil { m.observer.OnInitFmp4(init) } for i := range m.data { m.pack(m.data[i]) } m.data = nil m.done = true } func (m *Rtmp2Fmp4Remuxer) FlushLastSegment() { if m.currentPart != nil { if err := m.currentPart.Encode(0, true); err == nil && m.observer != nil { m.observer.OnFmp4Packets(m.currentPart, 0, true, false) } } } func (m *Rtmp2Fmp4Remuxer) Dispose() { } func (m *Rtmp2Fmp4Remuxer) pack(msg base.RtmpMsg) { paramsChanged := false if m.done { if msg.IsVideoKeySeqHeader() { switch msg.VideoCodecId() { case base.RtmpCodecIdAvc: if sps, pps, err := avc.ParseSpsPpsFromSeqHeader(msg.Payload); err != nil { m.log.Errorf("parse sps pps from seq header failed: %v", err) return } else { codec, ok := m.vCodec.(*CodecH264) if !ok || !bytes.Equal(codec.SPS, sps) || !bytes.Equal(codec.PPS, pps) { old := m.vCodec m.vCodec = &CodecH264{ SPS: sps, PPS: pps, } paramsChanged = true if old != nil && m.vCodec != nil { m.log.Infof("video codec changed, old:%s, new:%s", old.String(), m.vCodec.String()) } } } case base.RtmpCodecIdHevc: var vps, sps, pps []byte var err error if msg.IsEnhanced() { vps, sps, pps, err = hevc.ParseVpsSpsPpsFromEnhancedSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseVpsSpsPpsFromEnhancedSeqHeader failed, err:", err) break } } else { vps, sps, pps, err = hevc.ParseVpsSpsPpsFromSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseVpsSpsPpsFromSeqHeader failed, err:", err) break } } codec, ok := m.vCodec.(*CodecH265) if !ok || !bytes.Equal(codec.VPS, vps) || !bytes.Equal(codec.SPS, sps) || !bytes.Equal(codec.PPS, pps) { old := m.vCodec m.vCodec = &CodecH265{ VPS: vps, SPS: sps, PPS: pps, } paramsChanged = true if old != nil && m.vCodec != nil { m.log.Infof("video codec changed, old:%s, new:%s", old.String(), m.vCodec.String()) } } } } else if msg.Header.MsgTypeId == base.RtmpTypeIdAudio { if msg.IsAacSeqHeader() { if ascCtx, err := aac.NewAscContext(msg.Payload[2:]); err != nil { m.log.Errorf("new asc context failed: %v", err) return } else { codec, ok := m.aCodec.(*CodecAAC) if !ok || !bytes.Equal(codec.AscData, msg.Payload[2:]) { old := m.aCodec m.aCodec = &CodecAAC{ Ctx: ascCtx, AscData: msg.Payload[2:], } paramsChanged = true if old != nil && m.aCodec != nil { m.log.Infof("audio codec changed, old:%s, new:%s", old.String(), m.aCodec.String()) } } } } } if paramsChanged { // 编码格式发生变化,需要更新init和强制生成当前这个文件 if m.currentPart != nil { if err := m.currentPart.Encode(0, true); err == nil && m.observer != nil { m.observer.OnFmp4Packets(m.currentPart, 0, true, false) } } m.mux = NewMuxer() m.mux.WithLog(m.log) if m.vCodec != nil { m.mux.AddVideoTrack(m.vCodec) } if m.aCodec != nil { m.mux.AddAudioTrack(m.aCodec) } init := m.mux.GetInitMp4() if m.observer != nil { m.observer.OnInitFmp4(init) } m.currentPart = nil } } sample, err := m.mux.Pack(msg) if err == nil { if m.vCodec != nil { // 视频存在的话,I帧作为分割点 if msg.IsVideoKeyNalu() { if m.currentPart == nil { m.currentPart = NewMuxerPart(m.partId(), m.mux.AudioTimeScale()) } else { if len(m.currentPart.VideoSamples) >= 15 { if m.observer != nil { m.observer.OnFmp4Packets(m.currentPart, sample.Dts, false, true) } else { return } m.currentPart = NewMuxerPart(m.partId(), m.mux.AudioTimeScale()) } } } if msg.Header.MsgTypeId == base.RtmpTypeIdVideo { m.currentPart.WriteVideo(sample) } else { // 防止起始是音频 if m.currentPart == nil { m.currentPart = NewMuxerPart(m.partId(), m.mux.AudioTimeScale()) } m.currentPart.WriteAudio(sample) } } else { if m.currentPart == nil { m.currentPart = NewMuxerPart(m.partId(), m.mux.AudioTimeScale()) } else { // 只有音频的话,2s分割 if m.currentPart.Duration() >= 2*time.Second { if m.observer != nil { m.observer.OnFmp4Packets(m.currentPart, sample.Dts, false, false) } m.currentPart = NewMuxerPart(m.partId(), m.mux.AudioTimeScale()) } } m.currentPart.WriteAudio(sample) } } } func (m *Rtmp2Fmp4Remuxer) partId() uint64 { id := m.nextPartId m.nextPartId++ return id } ================================================ FILE: fmp4/muxer/seekablebuffer.go ================================================ package muxer import ( "bytes" "fmt" "io" ) // Buffer is a bytes.Buffer with an additional Seek() method. type Buffer struct { bytes.Buffer pos int64 } // Write implements io.Writer. func (b *Buffer) Write(p []byte) (int, error) { n := 0 if b.pos < int64(b.Len()) { n = copy(b.Bytes()[b.pos:], p) p = p[n:] } if len(p) > 0 { // Buffer.Write can't return an error. nn, _ := b.Buffer.Write(p) //nolint:errcheck n += nn } b.pos += int64(n) return n, nil } // Read implements io.Reader. func (b *Buffer) Read(_ []byte) (int, error) { return 0, fmt.Errorf("unimplemented") } // Seek implements io.Seeker. func (b *Buffer) Seek(offset int64, whence int) (int64, error) { pos2 := int64(0) switch whence { case io.SeekStart: pos2 = offset case io.SeekCurrent: pos2 = b.pos + offset case io.SeekEnd: pos2 = int64(b.Len()) + offset } if pos2 < 0 { return 0, fmt.Errorf("negative position") } b.pos = pos2 diff := b.pos - int64(b.Len()) if diff > 0 { // Buffer.Write can't return an error. b.Buffer.Write(make([]byte, diff)) //nolint:errcheck } return pos2, nil } // Reset resets the buffer state. func (b *Buffer) Reset() { b.Buffer.Reset() b.pos = 0 } ================================================ FILE: fmp4/muxer/track.go ================================================ package muxer type Track struct { Codec TrackId uint32 timeScale uint32 firstDTS int64 lastDTS int64 samples []PartSample } func NewTrack(codec Codec, trackId, timeSacle uint32) *Track { return &Track{ Codec: codec, TrackId: trackId, timeScale: timeSacle, firstDTS: -1, } } ================================================ FILE: fmp4/muxer/var.go ================================================ package muxer import "github.com/q191201771/naza/pkg/nazalog" var Log = nazalog.GetGlobalLogger() ================================================ FILE: gb28181/auth.go ================================================ package gb28181 import ( "crypto/md5" "fmt" "github.com/ghettovoice/gosip/sip" "github.com/q191201771/naza/pkg/nazalog" ) type Authorization struct { *sip.Authorization } func (a *Authorization) Verify(username, passwd, realm, nonce string) bool { //1、将 username,realm,password 依次组合获取 1 个字符串,并用算法加密的到密文 r1 s1 := fmt.Sprintf("%s:%s:%s", username, realm, passwd) r1 := a.getDigest(s1) //2、将 method,即REGISTER ,uri 依次组合获取 1 个字符串,并对这个字符串使用算法 加密得到密文 r2 s2 := fmt.Sprintf("REGISTER:%s", a.Uri()) r2 := a.getDigest(s2) if r1 == "" || r2 == "" { nazalog.Error("Authorization algorithm wrong") return false } //3、将密文 1,nonce 和密文 2 依次组合获取 1 个字符串,并对这个字符串使用算法加密,获得密文 r3,即Response s3 := fmt.Sprintf("%s:%s:%s", r1, nonce, r2) r3 := a.getDigest(s3) //4、计算服务端和客户端上报的是否相等 return r3 == a.Response() } func (a *Authorization) getDigest(raw string) string { switch a.Algorithm() { case "MD5": return fmt.Sprintf("%x", md5.Sum([]byte(raw))) default: //如果没有算法,默认使用MD5 return fmt.Sprintf("%x", md5.Sum([]byte(raw))) } } ================================================ FILE: gb28181/avail_conn_pool.go ================================================ // Copyright 2020, Chef. All rights reserved. // https://github.com/q191201771/naza // // Use of this source code is governed by a MIT-style license // that can be found in the License file. // // Author: Chef (191201771@qq.com) //根据naza修改,新增tcp package gb28181 import ( "errors" "net" "sync" ) var ErrNazaNet = errors.New("gb28181: fxxk") type OnListenWithPort func(port uint16) (net.Listener, error) // 从指定的端口范围内,寻找可绑定监听的端口,绑定监听并返回 type AvailConnPool struct { minPort uint16 maxPort uint16 m sync.Mutex lastPort uint16 onListenWithPort OnListenWithPort } func NewAvailConnPool(minPort uint16, maxPort uint16) *AvailConnPool { return &AvailConnPool{ minPort: minPort, maxPort: maxPort, lastPort: minPort, } } func (a *AvailConnPool) WithListenWithPort(listenWithPort OnListenWithPort) { a.onListenWithPort = listenWithPort } func (a *AvailConnPool) Acquire() (net.Listener, uint16, error) { a.m.Lock() defer a.m.Unlock() loopFirstFlag := true p := a.lastPort for { // 找了一轮也没有可用的,返回错误 if !loopFirstFlag && p == a.lastPort { return nil, 0, ErrNazaNet } loopFirstFlag = false if a.onListenWithPort == nil { return nil, 0, ErrNazaNet } listener, err := a.onListenWithPort(p) // 绑定失败,尝试下一个端口 if err != nil { p = a.nextPort(p) continue } // 绑定成功,更新last,返回结果 a.lastPort = a.nextPort(p) return listener, p, nil } } // 通过Acquire获取到可用net.UDPConn对象后,将对象关闭,只返回可用的端口 func (a *AvailConnPool) Peek() (uint16, error) { conn, port, err := a.Acquire() if err == nil { err = conn.Close() } return port, err } func (a *AvailConnPool) ListenWithPort(port uint16) (net.Listener, error) { if a.onListenWithPort == nil { return nil, ErrNazaNet } return a.onListenWithPort(port) } func (a *AvailConnPool) nextPort(p uint16) uint16 { if p == a.maxPort { return a.minPort } return p + 1 } ================================================ FILE: gb28181/channel.go ================================================ package gb28181 import ( "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/q191201771/naza/pkg/nazaatomic" config "github.com/q191201771/lalmax/config" "github.com/q191201771/lalmax/gb28181/mediaserver" "github.com/ghettovoice/gosip/sip" "github.com/q191201771/naza/pkg/nazalog" ) type Channel struct { device *Device // 所属设备 //status atomic.Int32 // 通道状态,0:空闲,1:正在invite,2:正在播放 GpsTime time.Time // gps时间 number uint16 ackReq sip.Request observer IMediaOpObserver playInfo *PlayInfo ChannelInfo conf config.GB28181Config } // Channel 通道 type ChannelInfo struct { ChannelId string `xml:"DeviceID"` // 设备id ParentId string `xml:"ParentID"` //父目录Id Name string `xml:"Name"` //设备名称 Manufacturer string `xml:"Manufacturer"` //制造厂商 Model string `xml:"Model"` //型号 Owner string `xml:"Owner"` //设备归属 CivilCode string `xml:"CivilCode"` //行政区划编码 Address string `xml:"Address"` //地址 Port int `xml:"Port"` //端口 Parental int `xml:"Parental"` //存在子设备,这里表明有子目录存在 1代表有子目录,0表示没有 SafetyWay int `xml:"SafetyWay"` //信令安全模式(可选)缺省为 0;0:不采用;2:S/MIME 签名方式;3:S/MIME 加密签名同时采用方式;4:数字摘要方式 RegisterWay int `xml:"RegisterWay"` //标准的认证注册模式 Secrecy int `xml:"Secrecy"` //0 表示不涉密 Status ChannelStatus `xml:"Status"` // 状态 on 在线 off离线 Longitude string `xml:"Longitude"` // 经度 Latitude string `xml:"Latitude"` // 纬度 StreamName string `xml:"-"` serial string mediaserver.MediaInfo sn nazaatomic.Uint32 } type ChannelStatus string const ( ChannelOnStatus = "ON" ChannelOffStatus = "OFF" ) func (channel *Channel) WithMediaServer(observer IMediaOpObserver) { channel.observer = observer } func (channel *Channel) TryAutoInvite(opt *InviteOptions, streamName string, playInfo *PlayInfo) { if channel.CanInvite(streamName) { go channel.Invite(opt, streamName, playInfo) } } func (channel *Channel) CanInvite(streamName string) bool { if len(channel.ChannelId) != 20 || channel.Status == ChannelOffStatus { nazalog.Info("return false, channel.DeviceID:", len(channel.ChannelId), " channel.Status:", channel.Status) return false } if channel.Parental != 0 { return false } if channel.MediaInfo.IsInvite { return false } // 11~13位是设备类型编码 typeID := channel.ChannelId[10:13] if typeID == "132" || typeID == "131" { return true } nazalog.Info("return false") return false } // Invite 发送Invite报文 invites a channel to play // 注意里面的锁保证不同时发送invite报文,该锁由channel持有 /*** f字段: f = v/编码格式/分辨率/帧率/码率类型/码率大小a/编码格式/码率大小/采样率 各项具体含义: v:后续参数为视频的参数;各参数间以 “/”分割; 编码格式:十进制整数字符串表示 1 –MPEG-4 2 –H.264 3 – SVAC 4 –3GP 分辨率:十进制整数字符串表示 1 – QCIF 2 – CIF 3 – 4CIF 4 – D1 5 –720P 6 –1080P/I 帧率:十进制整数字符串表示 0~99 码率类型:十进制整数字符串表示 1 – 固定码率(CBR) 2 – 可变码率(VBR) 码率大小:十进制整数字符串表示 0~100000(如 1表示1kbps) a:后续参数为音频的参数;各参数间以 “/”分割; 编码格式:十进制整数字符串表示 1 – G.711 2 – G.723.1 3 – G.729 4 – G.722.1 码率大小:十进制整数字符串 音频编码码率: 1 — 5.3 kbps (注:G.723.1中使用) 2 — 6.3 kbps (注:G.723.1中使用) 3 — 8 kbps (注:G.729中使用) 4 — 16 kbps (注:G.722.1中使用) 5 — 24 kbps (注:G.722.1中使用) 6 — 32 kbps (注:G.722.1中使用) 7 — 48 kbps (注:G.722.1中使用) 8 — 64 kbps(注:G.711中使用) 采样率:十进制整数字符串表示 1 — 8 kHz(注:G.711/ G.723.1/ G.729中使用) 2—14 kHz(注:G.722.1中使用) 3—16 kHz(注:G.722.1中使用) 4—32 kHz(注:G.722.1中使用) 注1:字符串说明 本节中使用的“十进制整数字符串”的含义为“0”~“4294967296” 之间的十进制数字字符串。 注2:参数分割标识 各参数间以“/”分割,参数间的分割符“/”不能省略; 若两个分割符 “/”间的某参数为空时(即两个分割符 “/”直接将相连时)表示无该参数值; 注3:f字段说明 使用f字段时,应保证视频和音频参数的结构完整性,即在任何时候,f字段的结构都应是完整的结构: f = v/编码格式/分辨率/帧率/码率类型/码率大小a/编码格式/码率大小/采样率 若只有视频时,音频中的各参数项可以不填写,但应保持 “a///”的结构: f = v/编码格式/分辨率/帧率/码率类型/码率大小a/// 若只有音频时也类似处理,视频中的各参数项可以不填写,但应保持 “v/”的结构: f = v/a/编码格式/码率大小/采样率 f字段中视、音频参数段之间不需空格分割。 可使用f字段中的分辨率参数标识同一设备不同分辨率的码流。 */ func (channel *Channel) Invite(opt *InviteOptions, streamName string, playInfo *PlayInfo) (code int, err error) { d := channel.device s := "Play" //然后按顺序生成,一个channel最大999 方便排查问题,也能保证唯一性 channel.number++ if channel.number > 999 { channel.number = 1 } if len(channel.serial) == 0 { channel.serial = RandNumString(6) } opt.CreateSSRC(channel.serial, channel.number) var mediaserver *mediaserver.GB28181MediaServer if channel.observer != nil { mediaserver = channel.observer.OnStartMediaServer(playInfo.NetWork, playInfo.SinglePort, channel.device.ID, channel.ChannelId) } if mediaserver == nil { return http.StatusNotFound, err } protocol := "" if playInfo.NetWork == "tcp" { opt.MediaPort = mediaserver.GetListenerPort() protocol = "TCP/" } else { opt.MediaPort = mediaserver.GetListenerPort() } sdpInfo := []string{ "v=0", fmt.Sprintf("o=%s 0 0 IN IP4 %s", channel.ChannelId, d.mediaIP), "s=" + s, "c=IN IP4 " + d.mediaIP, opt.String(), fmt.Sprintf("m=video %d %sRTP/AVP 96", opt.MediaPort, protocol), "a=recvonly", "a=rtpmap:96 PS/90000", "y=" + opt.ssrc, } if playInfo.NetWork == "tcp" { sdpInfo = append(sdpInfo, "a=setup:passive", "a=connection:new") } invite := channel.CreateRequst(sip.INVITE, channel.conf) contentType := sip.ContentType("application/sdp") invite.AppendHeader(&contentType) contentLength := sip.ContentLength(len(sdpInfo)) invite.AppendHeader(&contentLength) invite.SetBody(strings.Join(sdpInfo, "\r\n")+"\r\n", true) subject := sip.GenericHeader{ HeaderName: "Subject", Contents: fmt.Sprintf("%s:%s,%s:0", channel.ChannelId, opt.ssrc, channel.conf.Serial), } invite.AppendHeader(&subject) inviteRes, err := d.SipRequestForResponse(invite) if err != nil { nazalog.Error("invite failed, err:", err, " invite msg:", invite.String()) //jay 在media端口监听成功后,但是sip发送失败时 if channel.observer != nil { if err = channel.observer.OnStopMediaServer(playInfo.NetWork, playInfo.SinglePort, channel.device.ID, channel.ChannelId, ""); err != nil { nazalog.Errorf("gb28181 MediaServer stop err:%s", err.Error()) } } return http.StatusInternalServerError, err } code = int(inviteRes.StatusCode()) if code == http.StatusOK { ds := strings.Split(inviteRes.Body(), "\r\n") for _, l := range ds { if ls := strings.Split(l, "="); len(ls) > 1 { if ls[0] == "y" && len(ls[1]) > 0 { if _ssrc, err := strconv.ParseInt(ls[1], 10, 0); err == nil { opt.SSRC = uint32(_ssrc) } else { nazalog.Error("parse invite response y failed, err:", err) } } if ls[0] == "m" && len(ls[1]) > 0 { netinfo := strings.Split(ls[1], " ") if strings.ToUpper(netinfo[2]) == "TCP/RTP/AVP" { nazalog.Info("Device support tcp") } else { nazalog.Info("Device not support tcp") } } } } channel.MediaInfo.IsInvite = true channel.MediaInfo.Ssrc = opt.SSRC channel.MediaInfo.StreamName = streamName channel.MediaInfo.MediaKey = fmt.Sprintf("%s%d", playInfo.NetWork, mediaserver.GetListenerPort()) ackReq := sip.NewAckRequest("", invite, inviteRes, "", nil) //保存一下播放信息 channel.ackReq = ackReq channel.playInfo = playInfo err = channel.device.sipSvr.Send(ackReq) } else { if channel.observer != nil { if err = channel.observer.OnStopMediaServer(playInfo.NetWork, playInfo.SinglePort, channel.device.ID, channel.ChannelId, ""); err != nil { nazalog.Errorf("gb28181 MediaServer stop err:%s", err.Error()) } } } return } func (channel *Channel) GetCallId() string { if channel.ackReq != nil { if callId, ok := channel.ackReq.CallID(); ok { return callId.Value() } } return "" } func (channel *Channel) stopMediaServer() (err error) { if channel.playInfo != nil { if channel.observer != nil { if err = channel.observer.OnStopMediaServer(channel.playInfo.NetWork, channel.playInfo.SinglePort, channel.device.ID, channel.ChannelId, channel.playInfo.StreamName); err != nil { nazalog.Errorf("gb28181 MediaServer stop err:%s", err.Error()) } } } return } func (channel *Channel) byeClear() (err error) { err = channel.stopMediaServer() channel.ackReq = nil channel.MediaInfo.Clear() return } func (channel *Channel) Bye(streamName string) (err error) { if channel.ackReq != nil { byeReq := channel.ackReq channel.ackReq = nil byeReq.SetMethod(sip.BYE) seq, _ := byeReq.CSeq() seq.SeqNo += 1 channel.device.sipSvr.Send(byeReq) } else { err = errors.New("channel has been closed") } channel.stopMediaServer() return err } func (channel *Channel) CreateRequst(Method sip.RequestMethod, conf config.GB28181Config) (req sip.Request) { d := channel.device d.sn++ callId := sip.CallID(RandNumString(10)) userAgent := sip.UserAgentHeader("LALMax") maxForwards := sip.MaxForwards(70) //增加max-forwards为默认值 70 cseq := sip.CSeq{ SeqNo: uint32(d.sn), MethodName: Method, } port := sip.Port(conf.SipPort) serverAddr := sip.Address{ Uri: &sip.SipUri{ FUser: sip.String{Str: conf.Serial}, FHost: d.sipIP, FPort: &port, }, Params: sip.NewParams().Add("tag", sip.String{Str: RandNumString(9)}), } //非同一域的目标地址需要使用@host host := conf.Realm if channel.ChannelId[0:9] != host { if channel.Port != 0 { deviceIp := d.NetAddr deviceIp = deviceIp[0:strings.LastIndex(deviceIp, ":")] host = fmt.Sprintf("%s:%d", deviceIp, channel.Port) } else { host = d.NetAddr } } channelAddr := sip.Address{ Uri: &sip.SipUri{FUser: sip.String{Str: channel.ChannelId}, FHost: host}, } req = sip.NewRequest( "", Method, channelAddr.Uri, "SIP/2.0", []sip.Header{ serverAddr.AsFromHeader(), channelAddr.AsToHeader(), &callId, &userAgent, &cseq, &maxForwards, serverAddr.AsContactHeader(), }, "", nil, ) req.SetTransport(channel.device.network) req.SetDestination(d.NetAddr) return req } func (channel *Channel) PtzDirection(direction *PtzDirection) error { ptz := Ptz{ ZoomOut: false, ZoomIn: false, Up: direction.Up, Down: direction.Down, Left: direction.Left, Right: direction.Right, Speed: direction.Speed, } msgPtz := &MessagePtz{ CmdType: DeviceControl, DeviceID: direction.ChannelId, SN: int(channel.sn.Add(1)), PTZCmd: ptz.Pack(), } xml, err := XmlEncode(msgPtz) if err != nil { return err } return channel.sipMessage(xml) } func (channel *Channel) PtzZoom(zoom *PtzZoom) error { ptz := Ptz{ ZoomOut: zoom.ZoomOut, ZoomIn: zoom.ZoomIn, Speed: zoom.Speed, } msgPtz := &MessagePtz{ CmdType: DeviceControl, DeviceID: zoom.ChannelId, SN: int(channel.sn.Add(1)), PTZCmd: ptz.Pack(), } xml, err := XmlEncode(msgPtz) if err != nil { return err } return channel.sipMessage(xml) } func (channel *Channel) PtzFi(fi *PtzFi) error { ptzFi := Fi{ IrisIn: fi.IrisIn, IrisOut: fi.IrisOut, FocusNear: fi.FocusNear, FocusFar: fi.FocusFar, Speed: fi.Speed, } msgPtz := &MessagePtz{ CmdType: DeviceControl, DeviceID: fi.ChannelId, SN: int(channel.sn.Add(1)), PTZCmd: ptzFi.Pack(), } xml, err := XmlEncode(msgPtz) if err != nil { return err } return channel.sipMessage(xml) } func (channel *Channel) PtzPreset(ptzPreset *PtzPreset) error { cmd := byte(PresetSet) switch ptzPreset.Cmd { case PresetEditPoint: cmd = PresetSet case PresetDelPoint: cmd = PresetDel case PresetCallPoint: cmd = PresetCall default: return errors.New(fmt.Sprintf("ptz preset cmd error:%d", ptzPreset.Cmd)) } preset := Preset{ CMD: cmd, Point: ptzPreset.Point, } msgPtz := &MessagePtz{ CmdType: DeviceControl, DeviceID: ptzPreset.ChannelId, SN: int(channel.sn.Add(1)), PTZCmd: preset.Pack(), } xml, err := XmlEncode(msgPtz) if err != nil { return err } return channel.sipMessage(xml) } func (channel *Channel) PtzStop(stop *PtzStop) error { ptz := Ptz{} msgPtz := &MessagePtz{ CmdType: DeviceControl, DeviceID: stop.ChannelId, SN: int(channel.sn.Add(1)), PTZCmd: ptz.Pack(), } xml, err := XmlEncode(msgPtz) if err != nil { return err } return channel.sipMessage(xml) } func (channel *Channel) sipMessage(xml string) error { d := channel.device msg := channel.CreateRequst(sip.MESSAGE, channel.conf) msg.AppendHeader(&sip.GenericHeader{HeaderName: "Content-Type", Contents: "Application/MANSCDP+xml"}) msg.SetBody(xml, true) msgRes, err := d.SipRequestForResponse(msg) if err != nil { return err } code := int(msgRes.StatusCode()) if code == http.StatusOK { return nil } else { return errors.New(fmt.Sprintf("sip message ptz fail,code:%d", code)) } } ================================================ FILE: gb28181/device.go ================================================ package gb28181 import ( "context" "github.com/ghettovoice/gosip" "net/http" "strings" "sync" "time" config "github.com/q191201771/lalmax/config" "github.com/ghettovoice/gosip/sip" "github.com/q191201771/naza/pkg/nazalog" ) const TIME_LAYOUT = "2006-01-02T15:04:05" var ( Devices sync.Map DeviceNonce sync.Map //保存nonce防止设备伪造 DeviceRegisterCount sync.Map //设备注册次数 ) type DeviceStatus string const ( DeviceRegisterStatus = "REGISTER" DeviceRecoverStatus = "RECOVER" DeviceOnlineStatus = "ONLINE" DeviceOfflineStatus = "OFFLINE" DeviceAlarmedStatus = "ALARMED" ) type Device struct { ID string Name string Manufacturer string Model string Owner string RegisterTime time.Time UpdateTime time.Time LastKeepaliveAt time.Time Status DeviceStatus sn int addr sip.Address sipIP string //设备对应网卡的服务器ip mediaIP string //设备对应网卡的服务器ip NetAddr string channelMap sync.Map subscriber struct { CallID string Timeout time.Time } lastSyncTime time.Time GpsTime time.Time //gps时间 Longitude string //经度 Latitude string //纬度 observer IMediaOpObserver conf config.GB28181Config network string sipSvr gosip.Server } func (d *Device) WithMediaServer(observer IMediaOpObserver) { d.observer = observer } func (d *Device) WithSipSvr(sipSvr gosip.Server) *Device { d.sipSvr = sipSvr return d } func (d *Device) syncChannels() { if time.Since(d.lastSyncTime) > 2*time.Second { d.lastSyncTime = time.Now() d.Catalog(d.conf) //d.Subscribe(conf) //d.QueryDeviceInfo(conf) } } func (d *Device) UpdateChannels(list ...ChannelInfo) { for _, c := range list { //当父设备非空且存在时、父设备节点增加通道 if c.ParentId != "" { path := strings.Split(c.ParentId, "/") parentId := path[len(path)-1] //如果父ID并非本身所属设备,一般情况下这是因为下级设备上传了目录信息,该信息通常不需要处理。 // 暂时不考虑级联目录的实现 if d.ID != parentId { if v, ok := Devices.Load(parentId); ok { parent := v.(*Device) parent.addOrUpdateChannel(c) continue } } } c.ParentId = d.ID //本设备增加通道 d.addOrUpdateChannel(c) //channel.TryAutoInvite(&InviteOptions{}, conf) } } func (d *Device) addOrUpdateChannel(info ChannelInfo) (c *Channel) { if old, ok := d.channelMap.Load(info.ChannelId); ok { c = old.(*Channel) c.ChannelInfo = info } else { c = &Channel{ device: d, ChannelInfo: info, conf: d.conf, } c.WithMediaServer(d.observer) d.channelMap.Store(info.ChannelId, c) } return } func (d *Device) Catalog(conf config.GB28181Config) int { request := d.CreateRequest(sip.MESSAGE, conf) expires := sip.Expires(3600) d.subscriber.Timeout = time.Now().Add(time.Second * time.Duration(expires)) contentType := sip.ContentType("Application/MANSCDP+xml") request.AppendHeader(&contentType) request.AppendHeader(&expires) request.SetBody(BuildCatalogXML(d.sn, d.ID), true) // 输出Sip请求设备通道信息信令 nazalog.Info("SIP->Catalog request:", request.String()) resp, err := d.SipRequestForResponse(request) if err == nil && resp != nil { nazalog.Info("SIP->Catalog Response:", resp.String()) return int(resp.StatusCode()) } else if err != nil { nazalog.Error("SIP<-Catalog error:", err) } return http.StatusRequestTimeout } func (d *Device) CreateRequest(Method sip.RequestMethod, conf config.GB28181Config) (req sip.Request) { d.sn++ callId := sip.CallID(RandNumString(10)) userAgent := sip.UserAgentHeader("LALMax") maxForwards := sip.MaxForwards(70) //增加max-forwards为默认值 70 cseq := sip.CSeq{ SeqNo: uint32(d.sn), MethodName: Method, } port := sip.Port(conf.SipPort) serverAddr := sip.Address{ Uri: &sip.SipUri{ FUser: sip.String{Str: conf.Serial}, FHost: d.sipIP, FPort: &port, }, Params: sip.NewParams().Add("tag", sip.String{Str: RandNumString(9)}), } req = sip.NewRequest( "", Method, d.addr.Uri, "SIP/2.0", []sip.Header{ serverAddr.AsFromHeader(), d.addr.AsToHeader(), &callId, &userAgent, &cseq, &maxForwards, serverAddr.AsContactHeader(), }, "", nil, ) req.SetTransport(d.network) req.SetDestination(d.NetAddr) return } func (d *Device) Subscribe(conf config.GB28181Config) int { request := d.CreateRequest(sip.SUBSCRIBE, conf) if d.subscriber.CallID != "" { callId := sip.CallID(RandNumString(10)) request.AppendHeader(&callId) } expires := sip.Expires(3600) d.subscriber.Timeout = time.Now().Add(time.Second * time.Duration(expires)) contentType := sip.ContentType("Application/MANSCDP+xml") request.AppendHeader(&contentType) request.AppendHeader(&expires) request.SetBody(BuildCatalogXML(d.sn, d.ID), true) response, err := d.SipRequestForResponse(request) if err == nil && response != nil { if response.StatusCode() == http.StatusOK { callId, _ := request.CallID() d.subscriber.CallID = string(*callId) } else { d.subscriber.CallID = "" } return int(response.StatusCode()) } return http.StatusRequestTimeout } func (d *Device) QueryDeviceInfo(conf config.GB28181Config) { for i := time.Duration(5); i < 100; i++ { time.Sleep(time.Second * i) request := d.CreateRequest(sip.MESSAGE, conf) contentType := sip.ContentType("Application/MANSCDP+xml") request.AppendHeader(&contentType) request.SetBody(BuildDeviceInfoXML(d.sn, d.ID), true) response, _ := d.SipRequestForResponse(request) if response != nil { if response.StatusCode() == http.StatusOK { break } } } } // UpdateChannelStatus 目录订阅消息处理:新增/移除/更新通道或者更改通道状态 func (d *Device) UpdateChannelStatus(deviceList []*notifyMessage, conf config.GB28181Config) { for _, v := range deviceList { switch v.Event { case "ON": nazalog.Debug("receive channel online notify") d.channelOnline(v.ChannelId) case "OFF": nazalog.Debug("receive channel offline notify") d.channelOffline(v.ChannelId) case "VLOST": nazalog.Debug("receive channel video lost notify") d.channelOffline(v.ChannelId) case "DEFECT": nazalog.Debug("receive channel video defect notify") d.channelOffline(v.ChannelId) case "ADD": nazalog.Debug("receive channel add notify") channel := ChannelInfo{ ChannelId: v.ChannelId, ParentId: v.ParentId, Name: v.Name, Manufacturer: v.Manufacturer, Model: v.Model, Owner: v.Owner, CivilCode: v.CivilCode, Address: v.Address, Port: v.Port, Parental: v.Parental, SafetyWay: v.SafetyWay, RegisterWay: v.RegisterWay, Secrecy: v.Secrecy, Status: v.Status, Longitude: v.Longitude, Latitude: v.Latitude, } d.addOrUpdateChannel(channel) case "DEL": //删除 nazalog.Debug("receive channel delete notify") d.deleteChannel(v.ChannelId) case "UPDATE": nazalog.Debug("receive channel update notify") // 更新通道 channel := ChannelInfo{ ChannelId: v.ChannelId, ParentId: v.ParentId, Name: v.Name, Manufacturer: v.Manufacturer, Model: v.Model, Owner: v.Owner, CivilCode: v.CivilCode, Address: v.Address, Port: v.Port, Parental: v.Parental, SafetyWay: v.SafetyWay, RegisterWay: v.RegisterWay, Secrecy: v.Secrecy, Status: v.Status, Longitude: v.Longitude, Latitude: v.Latitude, } d.UpdateChannels(channel) } } } func (d *Device) channelOnline(channelId string) { if v, ok := d.channelMap.Load(channelId); ok { c := v.(*Channel) c.Status = ChannelOnStatus nazalog.Debug("channel online, channelId: ", channelId) } else { nazalog.Debug("update channel status failed, not found, channelId: ", channelId) } } func (d *Device) channelOffline(channelId string) { if v, ok := d.channelMap.Load(channelId); ok { c := v.(*Channel) c.Status = ChannelOffStatus nazalog.Debug("channel offline, channelId: ", channelId) } else { nazalog.Debug("update channel status failed, not found, channelId: ", channelId) } } func (d *Device) deleteChannel(channelId string) { d.channelMap.Delete(channelId) } // UpdateChannelPosition 更新通道GPS坐标 func (d *Device) UpdateChannelPosition(channelId string, gpsTime string, lng string, lat string) { if v, ok := d.channelMap.Load(channelId); ok { c := v.(*Channel) c.GpsTime = time.Now() //时间取系统收到的时间,避免设备时间和格式问题 c.Longitude = lng c.Latitude = lat nazalog.Debug("update channel position success") } else { //如果未找到通道,则更新到设备上 d.GpsTime = time.Now() //时间取系统收到的时间,避免设备时间和格式问题 d.Longitude = lng d.Latitude = lat nazalog.Debug("update device position success, channelId:", channelId) } } func (d *Device) SipRequestForResponse(request sip.Request) (sip.Response, error) { return d.sipSvr.RequestWithContext(context.Background(), request) } ================================================ FILE: gb28181/http_logic.go ================================================ package gb28181 import ( "sync" "github.com/gin-gonic/gin" ) type GbLogic struct { s *GB28181Server } var gbLogic *GbLogic var once sync.Once func NewGbLogic(s *GB28181Server) *GbLogic { once.Do(func() { gbLogic = &GbLogic{ s: s, } }) return gbLogic } func (g *GbLogic) GetDeviceInfos(c *gin.Context) { deviceInfos := g.s.getDeviceInfos() ResponseSuccess(c, deviceInfos) } func (g *GbLogic) StartPlay(c *gin.Context) { var reqPlay ReqPlay if err := c.ShouldBindJSON(&reqPlay); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { ch := g.s.FindChannel(reqPlay.DeviceId, reqPlay.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { streamName := reqPlay.StreamName if len(streamName) == 0 { streamName = reqPlay.ChannelId } if len(reqPlay.NetWork) == 0 || !(reqPlay.NetWork == "udp" || reqPlay.NetWork == "tcp") { reqPlay.NetWork = "udp" } ch.TryAutoInvite(&InviteOptions{}, streamName, &reqPlay.PlayInfo) respPlay := &RespPlay{ StreamName: streamName, } ResponseSuccess(c, respPlay) } } } func (g *GbLogic) StopPlay(c *gin.Context) { var reqStop ReqStop if err := c.ShouldBindJSON(&reqStop); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { ch := g.s.FindChannel(reqStop.DeviceId, reqStop.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { streamName := reqStop.StreamName if len(streamName) == 0 { streamName = reqStop.ChannelId } if err = ch.Bye(streamName); err != nil { ResponseErrorWithMsg(c, CodeDeviceStopError, err.Error()) } else { ResponseSuccess(c, nil) } } } } func (g *GbLogic) PtzDirection(c *gin.Context) { var reqDirection PtzDirection if err := c.ShouldBindJSON(&reqDirection); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { if !(reqDirection.Speed > 0 && reqDirection.Speed <= 8) { ResponseErrorWithMsg(c, CodeInvalidParam, SpeedParamError) } ch := g.s.FindChannel(reqDirection.DeviceId, reqDirection.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { reqDirection.Speed = reqDirection.Speed * 25 if err = ch.PtzDirection(&reqDirection); err != nil { ResponseErrorWithMsg(c, CodeDeviceStopError, err.Error()) } else { ResponseSuccess(c, nil) } } } } func (g *GbLogic) PtzZoom(c *gin.Context) { var reqZoom PtzZoom if err := c.ShouldBindJSON(&reqZoom); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { if !(reqZoom.Speed > 0 && reqZoom.Speed <= 8) { ResponseErrorWithMsg(c, CodeInvalidParam, SpeedParamError) } ch := g.s.FindChannel(reqZoom.DeviceId, reqZoom.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { reqZoom.Speed = reqZoom.Speed * 25 if err = ch.PtzZoom(&reqZoom); err != nil { ResponseErrorWithMsg(c, CodeDeviceStopError, err.Error()) } else { ResponseSuccess(c, nil) } } } } func (g *GbLogic) PtzFi(c *gin.Context) { var reqFi PtzFi if err := c.ShouldBindJSON(&reqFi); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { if !(reqFi.Speed > 0 && reqFi.Speed <= 8) { ResponseErrorWithMsg(c, CodeInvalidParam, SpeedParamError) } ch := g.s.FindChannel(reqFi.DeviceId, reqFi.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { reqFi.Speed = reqFi.Speed * 25 if err = ch.PtzFi(&reqFi); err != nil { ResponseErrorWithMsg(c, CodeDeviceStopError, err.Error()) } else { ResponseSuccess(c, nil) } } } } func (g *GbLogic) PtzPreset(c *gin.Context) { var reqPreset PtzPreset if err := c.ShouldBindJSON(&reqPreset); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { if !(reqPreset.Point > 0 && reqPreset.Point <= 50) { ResponseErrorWithMsg(c, CodeInvalidParam, PointParamError) } ch := g.s.FindChannel(reqPreset.DeviceId, reqPreset.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { if err = ch.PtzPreset(&reqPreset); err != nil { ResponseErrorWithMsg(c, CodeDeviceStopError, err.Error()) } else { ResponseSuccess(c, nil) } } } } func (g *GbLogic) PtzStop(c *gin.Context) { var reqStop PtzStop if err := c.ShouldBindJSON(&reqStop); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { ch := g.s.FindChannel(reqStop.DeviceId, reqStop.ChannelId) if ch == nil { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } else { if err = ch.PtzStop(&reqStop); err != nil { ResponseErrorWithMsg(c, CodeDeviceStopError, err.Error()) } else { ResponseSuccess(c, nil) } } } } func (g *GbLogic) UpdateAllNotify(c *gin.Context) { g.s.GetAllSyncChannels() ResponseSuccess(c, nil) } func (g *GbLogic) UpdateNotify(c *gin.Context) { var reqUpdateNotify ReqUpdateNotify if err := c.ShouldBindJSON(&reqUpdateNotify); err != nil { ResponseErrorWithMsg(c, CodeInvalidParam, CodeInvalidParam.Msg()) } else { if g.s.GetSyncChannels(reqUpdateNotify.DeviceId) { ResponseSuccess(c, nil) } else { ResponseErrorWithMsg(c, CodeDeviceNotRegister, CodeDeviceNotRegister.Msg()) } } } ================================================ FILE: gb28181/inviteoption.go ================================================ package gb28181 import ( "fmt" "strconv" ) type InviteOptions struct { Start int End int ssrc string SSRC uint32 MediaPort uint16 } func (o InviteOptions) IsLive() bool { return o.Start == 0 || o.End == 0 } func (o InviteOptions) String() string { return fmt.Sprintf("t=%d %d", o.Start, o.End) } func (o *InviteOptions) CreateSSRC(serial string, number uint16) { //不按gb生成标准,取ID最后六位,然后按顺序生成,一个channel最大999 o.ssrc = fmt.Sprintf("%d%s%03d", 0, serial, number) _ssrc, _ := strconv.ParseInt(o.ssrc, 10, 0) o.SSRC = uint32(_ssrc) } ================================================ FILE: gb28181/mediaserver/conn.go ================================================ package mediaserver import ( "bytes" "encoding/binary" "errors" "fmt" "io" "net" "sync" "time" "github.com/q191201771/lalmax/gb28181/mpegps" "github.com/pion/rtp" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) var ( ErrInvalidPsData = errors.New("invalid mpegps data") ) type Frame struct { buffer *bytes.Buffer pts uint64 dts uint64 initPts uint64 initDts uint64 } type Conn struct { conn net.Conn r io.Reader check bool demuxer *mpegps.PsDemuxer streamName string lalServer logic.ILalServer lalSession logic.ICustomizePubSessionContext videoFrame Frame audioFrame Frame observer IGbObserver rtpPts uint64 psPtsZeroTimes int64 psDumpFile *base.DumpFile buffer *bytes.Buffer key string mediaServer *GB28181MediaServer preferMediaKeyLookup bool readTimeout time.Duration one sync.Once oneSaveConn sync.Once } func NewConn(conn net.Conn, observer IGbObserver, lal logic.ILalServer) *Conn { c := &Conn{ conn: conn, r: conn, demuxer: mpegps.NewPsDemuxer(), observer: observer, lalServer: lal, buffer: bytes.NewBuffer(nil), } c.demuxer.OnFrame = c.OnFrame return c } func (c *Conn) SetMediaServer(mediaServer *GB28181MediaServer) { c.mediaServer = mediaServer } func (c *Conn) SetKey(key string) { c.key = key } func (c *Conn) SetPreferMediaKeyLookup(prefer bool) { c.preferMediaKeyLookup = prefer } func (c *Conn) SetReadTimeout(timeout time.Duration) { c.readTimeout = timeout } func (c *Conn) Serve() (err error) { defer func() { nazalog.Info("conn close, err:", err) c.Close() if c.observer != nil && c.streamName != "" { c.observer.NotifyClose(c.streamName) } if c.psDumpFile != nil { c.psDumpFile.Close() } if c.lalSession != nil { c.lalServer.DelCustomizePubSession(c.lalSession) } }() nazalog.Info("gb28181 conn, remoteaddr:", c.conn.RemoteAddr().String(), " localaddr:", c.conn.LocalAddr().String()) for { if c.readTimeout > 0 { c.conn.SetReadDeadline(time.Now().Add(c.readTimeout)) } pkt := &rtp.Packet{} if c.conn.RemoteAddr().Network() == "udp" { buf := make([]byte, 1472*4) n, err := c.conn.Read(buf) if err != nil { nazalog.Error("conn read failed, err:", err) return err } err = pkt.Unmarshal(buf[:n]) if err != nil { return err } } else { len := make([]byte, 2) _, err := io.ReadFull(c.r, len) if err != nil { return err } size := binary.BigEndian.Uint16(len) buf := make([]byte, size) _, err = io.ReadFull(c.r, buf) if err != nil { return err } err = pkt.Unmarshal(buf) if err != nil { return err } } if !c.check && c.observer != nil { var mediaInfo *MediaInfo var ok bool if c.preferMediaKeyLookup { mediaInfo, ok = c.observer.GetMediaInfoByKey(c.key) if !ok { nazalog.Error("get mediaInfo :", c.key) return fmt.Errorf("get mediaInfo:%s", c.key) } } else if pkt.SSRC != 0 { mediaInfo, ok = c.observer.CheckSsrc(pkt.SSRC) if !ok { nazalog.Error("invalid ssrc:", pkt.SSRC) return fmt.Errorf("invalid ssrc:%d", pkt.SSRC) } } else { mediaInfo, ok = c.observer.GetMediaInfoByKey(c.key) if !ok { nazalog.Error("get mediaInfo :", c.key) return fmt.Errorf("get mediaInfo:%s", c.key) } } c.check = true c.streamName = mediaInfo.StreamName c.oneSaveConn.Do(func() { if c.mediaServer != nil { c.mediaServer.conns.Store(c.streamName, c) } }) if len(mediaInfo.DumpFileName) > 0 { c.psDumpFile = base.NewDumpFile() if err = c.psDumpFile.OpenToWrite(mediaInfo.DumpFileName); err != nil { nazalog.Errorf("gb con dump file:%s", err.Error()) } } nazalog.Info("gb28181 ssrc check success, streamName:", c.streamName) if c.observer != nil { c.observer.OnRtpPacket(c.streamName, c.key) } session, err := c.lalServer.AddCustomizePubSession(mediaInfo.StreamName) if err != nil { nazalog.Error("lal server AddCustomizePubSession failed, err:", err) return err } session.WithOption(func(option *base.AvPacketStreamOption) { option.VideoFormat = base.AvPacketStreamVideoFormatAnnexb option.AudioFormat = base.AvPacketStreamAudioFormatAdtsAac }) c.lalSession = session } c.rtpPts = uint64(pkt.Header.Timestamp) if c.observer != nil && c.streamName != "" { c.observer.OnRtpPacket(c.streamName, c.key) } if c.demuxer != nil { if c.psDumpFile != nil { c.psDumpFile.WriteWithType(pkt.Payload, base.DumpTypePsRtpData) } c.demuxer.Input(pkt.Payload) } } return } func (c *Conn) Demuxer(data []byte) error { c.buffer.Write(data) buf := c.buffer.Bytes() if len(buf) < 4 { return nil } if buf[0] != 0x00 && buf[1] != 0x00 && buf[2] != 0x01 && buf[3] != 0xBA { return ErrInvalidPsData } packets := splitPsPackets(buf) if len(packets) <= 1 { return nil } for i, packet := range packets { if i == len(packets)-1 { c.buffer = bytes.NewBuffer(packet) return nil } if c.demuxer != nil { c.demuxer.Input(packet) } } return nil } func (c *Conn) OnFrame(frame []byte, cid mpegps.PsStreamType, pts uint64, dts uint64) { payloadType := getPayloadType(cid) if payloadType == base.AvPacketPtUnknown { return } //当ps流解析出pts为0时,计数超过10则用rtp的时间戳 if pts == 0 { if c.psPtsZeroTimes >= 0 { c.psPtsZeroTimes++ } if c.psPtsZeroTimes > 10 { pts = c.rtpPts dts = c.rtpPts } } else { c.psPtsZeroTimes = -1 } if payloadType == base.AvPacketPtAac || payloadType == base.AvPacketPtG711A || payloadType == base.AvPacketPtG711U { if c.audioFrame.initDts == 0 { c.audioFrame.initDts = dts } if c.audioFrame.initPts == 0 { c.audioFrame.initPts = pts } var pkt base.AvPacket pkt.PayloadType = payloadType pkt.Timestamp = int64(dts - c.audioFrame.initDts) pkt.Pts = int64(pts - c.audioFrame.initPts) pkt.Payload = append(pkt.Payload, frame...) c.lalSession.FeedAvPacket(pkt) } else { if c.videoFrame.initPts == 0 { c.videoFrame.initPts = pts } if c.videoFrame.initDts == 0 { c.videoFrame.initDts = dts } // 塞入lal中 c.videoFrame.pts = pts - c.videoFrame.initPts c.videoFrame.dts = dts - c.videoFrame.initDts var pkt base.AvPacket pkt.PayloadType = payloadType pkt.Timestamp = int64(c.videoFrame.dts) pkt.Pts = int64(c.videoFrame.pts) pkt.Payload = frame c.lalSession.FeedAvPacket(pkt) } } func (c *Conn) Close() { c.one.Do(func() { c.conn.Close() }) } func getPayloadType(cid mpegps.PsStreamType) base.AvPacketPt { switch cid { case mpegps.PsStreamAac: return base.AvPacketPtAac case mpegps.PsStreamG711A: return base.AvPacketPtG711A case mpegps.PsStreamG711U: return base.AvPacketPtG711U case mpegps.PsStreamH264: return base.AvPacketPtAvc case mpegps.PsStreamH265: return base.AvPacketPtHevc } return base.AvPacketPtUnknown } func splitPsPackets(data []byte) [][]byte { startCode := []byte{0x00, 0x00, 0x01, 0xBA} start := 0 var packets [][]byte for i := 0; i < len(data); i++ { if i+len(startCode) <= len(data) && bytes.Equal(data[i:i+len(startCode)], startCode) { if i == 0 { continue } packets = append(packets, data[start:i]) start = i } } packets = append(packets, data[start:]) return packets } ================================================ FILE: gb28181/mediaserver/mediaserver_t.go ================================================ package mediaserver type MediaInfo struct { IsInvite bool Ssrc uint32 StreamName string SinglePort bool DumpFileName string MediaKey string } func (m *MediaInfo) Clear() (err error) { m.IsInvite = false m.Ssrc = 0 m.StreamName = "" m.SinglePort = false m.DumpFileName = "" return } ================================================ FILE: gb28181/mediaserver/server.go ================================================ package mediaserver import ( "errors" "net" "sync" "sync/atomic" "time" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) const defaultReadTimeout = 10 * time.Second type IGbObserver interface { CheckSsrc(ssrc uint32) (*MediaInfo, bool) GetMediaInfoByKey(key string) (*MediaInfo, bool) NotifyClose(streamName string) OnRtpPacket(streamName string, mediaKey string) } type GB28181MediaServer struct { listenPort int lalServer logic.ILalServer listener net.Listener disposeOnce sync.Once disposed atomic.Bool observer IGbObserver mediaKey string preferMediaKeyLookup bool readTimeout time.Duration conns sync.Map //增加链接对象,目前只适用于多端口 } func NewGB28181MediaServer(listenPort int, mediaKey string, observer IGbObserver, lal logic.ILalServer) *GB28181MediaServer { return &GB28181MediaServer{ listenPort: listenPort, lalServer: lal, observer: observer, mediaKey: mediaKey, readTimeout: defaultReadTimeout, } } func (s *GB28181MediaServer) WithPreferMediaKeyLookup(prefer bool) *GB28181MediaServer { s.preferMediaKeyLookup = prefer return s } func (s *GB28181MediaServer) WithReadTimeout(timeout time.Duration) *GB28181MediaServer { s.readTimeout = timeout return s } func (s *GB28181MediaServer) GetListenerPort() uint16 { return uint16(s.listenPort) } func (s *GB28181MediaServer) Start(listener net.Listener) (err error) { s.listener = listener if listener != nil { go func(listener net.Listener) { for { if s.disposed.Load() { return } conn, err := listener.Accept() if err != nil { var ne net.Error if ok := errors.As(err, &ne); ok && ne.Timeout() { nazalog.Error("Accept failed: timeout error, retrying...") time.Sleep(time.Second / 20) continue } else { break } } if conn == nil { continue } if s.disposed.Load() { conn.Close() return } c := NewConn(conn, s.observer, s.lalServer) c.SetKey(s.mediaKey) c.SetMediaServer(s) c.SetPreferMediaKeyLookup(s.preferMediaKeyLookup) c.SetReadTimeout(s.readTimeout) s.conns.Store(c, c) go func() { c.Serve() s.conns.Delete(c) s.conns.Delete(c.streamName) }() } }(listener) } return } func (s *GB28181MediaServer) CloseConn(streamName string) { if v, ok := s.conns.Load(streamName); ok { conn := v.(*Conn) conn.Close() } } func (s *GB28181MediaServer) Dispose() { s.disposeOnce.Do(func() { s.disposed.Store(true) s.conns.Range(func(_, value any) bool { conn := value.(*Conn) conn.Close() return true }) if s.listener != nil { s.listener.Close() s.listener = nil } }) } ================================================ FILE: gb28181/mpegps/bitstream.go ================================================ package mpegps import ( "encoding/binary" ) var BitMask [8]byte = [8]byte{0x01, 0x03, 0x07, 0x0F, 0x1F, 0x3F, 0x7F, 0xFF} type BitStream struct { bits []byte bytesOffset int bitsOffset int bitsmark int bytemark int } func NewBitStream(buf []byte) *BitStream { return &BitStream{ bits: buf, bytesOffset: 0, bitsOffset: 0, bitsmark: 0, bytemark: 0, } } func (bs *BitStream) Uint8(n int) uint8 { return uint8(bs.GetBits(n)) } func (bs *BitStream) Uint16(n int) uint16 { return uint16(bs.GetBits(n)) } func (bs *BitStream) Uint32(n int) uint32 { return uint32(bs.GetBits(n)) } func (bs *BitStream) GetBytes(n int) []byte { if bs.bytesOffset+n > len(bs.bits) { panic("OUT OF RANGE") } if bs.bitsOffset != 0 { panic("invaild operation") } data := make([]byte, n) copy(data, bs.bits[bs.bytesOffset:bs.bytesOffset+n]) bs.bytesOffset += n return data } // n <= 64 func (bs *BitStream) GetBits(n int) uint64 { if bs.bytesOffset >= len(bs.bits) { panic("OUT OF RANGE") } var ret uint64 = 0 if 8-bs.bitsOffset >= n { ret = uint64((bs.bits[bs.bytesOffset] >> (8 - bs.bitsOffset - n)) & BitMask[n-1]) bs.bitsOffset += n if bs.bitsOffset == 8 { bs.bytesOffset++ bs.bitsOffset = 0 } } else { ret = uint64(bs.bits[bs.bytesOffset] & BitMask[8-bs.bitsOffset-1]) bs.bytesOffset++ n -= 8 - bs.bitsOffset bs.bitsOffset = 0 for n > 0 { if bs.bytesOffset >= len(bs.bits) { panic("OUT OF RANGE") } if n >= 8 { ret = ret<<8 | uint64(bs.bits[bs.bytesOffset]) bs.bytesOffset++ n -= 8 } else { ret = (ret << n) | uint64((bs.bits[bs.bytesOffset]>>(8-n))&BitMask[n-1]) bs.bitsOffset = n break } } } return ret } func (bs *BitStream) GetBit() uint8 { if bs.bytesOffset >= len(bs.bits) { panic("OUT OF RANGE") } ret := bs.bits[bs.bytesOffset] >> (7 - bs.bitsOffset) & 0x01 bs.bitsOffset++ if bs.bitsOffset >= 8 { bs.bytesOffset++ bs.bitsOffset = 0 } return ret } func (bs *BitStream) SkipBits(n int) { bytecount := n / 8 bitscount := n % 8 bs.bytesOffset += bytecount if bs.bitsOffset+bitscount < 8 { bs.bitsOffset += bitscount } else { bs.bytesOffset += 1 bs.bitsOffset += bitscount - 8 } } func (bs *BitStream) Markdot() { bs.bitsmark = bs.bitsOffset bs.bytemark = bs.bytesOffset } func (bs *BitStream) DistanceFromMarkDot() int { bytecount := bs.bytesOffset - bs.bytemark - 1 bitscount := bs.bitsOffset + (8 - bs.bitsmark) return bytecount*8 + bitscount } func (bs *BitStream) RemainBytes() int { if bs.bitsOffset > 0 { return len(bs.bits) - bs.bytesOffset - 1 } else { return len(bs.bits) - bs.bytesOffset } } func (bs *BitStream) RemainBits() int { if bs.bitsOffset > 0 { return bs.RemainBytes()*8 + 8 - bs.bitsOffset } else { return bs.RemainBytes() * 8 } } func (bs *BitStream) Bits() []byte { return bs.bits } func (bs *BitStream) RemainData() []byte { return bs.bits[bs.bytesOffset:] } // 无符号哥伦布熵编码 func (bs *BitStream) ReadUE() uint64 { leadingZeroBits := 0 for bs.GetBit() == 0 { leadingZeroBits++ } if leadingZeroBits == 0 { return 0 } info := bs.GetBits(leadingZeroBits) return uint64(1)<= 8 { bs.bytesOffset-- least -= 8 } if least > 0 { bs.bytesOffset-- bs.bitsOffset = 8 - least } } } func (bs *BitStream) NextBits(n int) uint64 { r := bs.GetBits(n) bs.UnRead(n) return r } func (bs *BitStream) EOS() bool { return bs.bytesOffset == len(bs.bits) && bs.bitsOffset == 0 } type BitStreamWriter struct { bits []byte byteoffset int bitsoffset int bitsmark int bytemark int } func NewBitStreamWriter(n int) *BitStreamWriter { return &BitStreamWriter{ bits: make([]byte, n), byteoffset: 0, bitsoffset: 0, bitsmark: 0, bytemark: 0, } } func (bsw *BitStreamWriter) expandSpace(n int) { if (len(bsw.bits)-bsw.byteoffset-1)*8+8-bsw.bitsoffset < n { newlen := 0 if len(bsw.bits)*8 < n { newlen = len(bsw.bits) + n/8 + 1 } else { newlen = len(bsw.bits) * 2 } tmp := make([]byte, newlen) copy(tmp, bsw.bits) bsw.bits = tmp } } func (bsw *BitStreamWriter) ByteOffset() int { return bsw.byteoffset } func (bsw *BitStreamWriter) BitOffset() int { return bsw.bitsoffset } func (bsw *BitStreamWriter) Markdot() { bsw.bitsmark = bsw.bitsoffset bsw.bytemark = bsw.byteoffset } func (bsw *BitStreamWriter) DistanceFromMarkDot() int { bytecount := bsw.byteoffset - bsw.bytemark - 1 bitscount := bsw.bitsoffset + (8 - bsw.bitsmark) return bytecount*8 + bitscount } func (bsw *BitStreamWriter) PutByte(v byte) { bsw.expandSpace(8) if bsw.bitsoffset == 0 { bsw.bits[bsw.byteoffset] = v bsw.byteoffset++ } else { bsw.bits[bsw.byteoffset] |= v >> byte(bsw.bitsoffset) bsw.byteoffset++ bsw.bits[bsw.byteoffset] = v & BitMask[bsw.bitsoffset-1] } } func (bsw *BitStreamWriter) PutBytes(v []byte) { if bsw.bitsoffset != 0 { panic("bsw.bitsoffset > 0") } bsw.expandSpace(8 * len(v)) copy(bsw.bits[bsw.byteoffset:], v) bsw.byteoffset += len(v) } func (bsw *BitStreamWriter) PutRepetValue(v byte, n int) { if bsw.bitsoffset != 0 { panic("bsw.bitsoffset > 0") } bsw.expandSpace(8 * n) for i := 0; i < n; i++ { bsw.bits[bsw.byteoffset] = v bsw.byteoffset++ } } func (bsw *BitStreamWriter) PutUint8(v uint8, n int) { bsw.PutUint64(uint64(v), n) } func (bsw *BitStreamWriter) PutUint16(v uint16, n int) { bsw.PutUint64(uint64(v), n) } func (bsw *BitStreamWriter) PutUint32(v uint32, n int) { bsw.PutUint64(uint64(v), n) } func (bsw *BitStreamWriter) PutUint64(v uint64, n int) { bsw.expandSpace(n) if 8-bsw.bitsoffset >= n { bsw.bits[bsw.byteoffset] |= uint8(v) & BitMask[n-1] << (8 - bsw.bitsoffset - n) bsw.bitsoffset += n if bsw.bitsoffset == 8 { bsw.bitsoffset = 0 bsw.byteoffset++ } } else { bsw.bits[bsw.byteoffset] |= uint8(v>>(n-int(8-bsw.bitsoffset))) & BitMask[8-bsw.bitsoffset-1] bsw.byteoffset++ n -= 8 - bsw.bitsoffset for n-8 >= 0 { bsw.bits[bsw.byteoffset] = uint8(v>>(n-8)) & 0xFF bsw.byteoffset++ n -= 8 } bsw.bitsoffset = n if n > 0 { bsw.bits[bsw.byteoffset] |= (uint8(v) & BitMask[n-1]) << (8 - n) } } } func (bsw *BitStreamWriter) SetByte(v byte, where int) { bsw.bits[where] = v } func (bsw *BitStreamWriter) SetUint16(v uint16, where int) { binary.BigEndian.PutUint16(bsw.bits[where:where+2], v) } func (bsw *BitStreamWriter) Bits() []byte { if bsw.byteoffset == len(bsw.bits) { return bsw.bits } if bsw.bitsoffset > 0 { return bsw.bits[0 : bsw.byteoffset+1] } else { return bsw.bits[0:bsw.byteoffset] } } // 用v 填充剩余字节 func (bsw *BitStreamWriter) FillRemainData(v byte) { for i := bsw.byteoffset; i < len(bsw.bits); i++ { bsw.bits[i] = v } bsw.byteoffset = len(bsw.bits) bsw.bitsoffset = 0 } func (bsw *BitStreamWriter) Reset() { for i := 0; i < len(bsw.bits); i++ { bsw.bits[i] = 0 } bsw.bitsmark = 0 bsw.bytemark = 0 bsw.bitsoffset = 0 bsw.byteoffset = 0 } ================================================ FILE: gb28181/mpegps/pes_proto.go ================================================ package mpegps import ( "fmt" "os" ) type TsStreamType int const ( TsStreamAudioMpeg1 TsStreamType = 0x03 TsStreamAudioMpeg2 TsStreamType = 0x04 TsStreamAac TsStreamType = 0x0F TsStreamH264 TsStreamType = 0x1B TsStreamH265 TsStreamType = 0x24 ) var H264AudNalu []byte = []byte{0x00, 0x00, 0x00, 0x01, 0x09, 0xF0} //ffmpeg mpegtsenc.c mpegts_write_packet_internal var H265AudNalu []byte = []byte{0x00, 0x00, 0x00, 0x01, 0x46, 0x01, 0x50} type PesStreamId int const ( PesStreamEnd PesStreamId = 0xB9 PesStreamStart PesStreamId = 0xBA PesStreamSystemHead PesStreamId = 0xBB PesStreamMap PesStreamId = 0xBC PesStreamPrivate PesStreamId = 0xBD PesStreamAudio PesStreamId = 0xC0 PesStreamVideo PesStreamId = 0xE0 ) type Display interface { PrettyPrint(file *os.File) } func findPesIdByStreamType(cid TsStreamType) PesStreamId { switch cid { case TsStreamAac, TsStreamAudioMpeg1, TsStreamAudioMpeg2: return PesStreamAudio case TsStreamH264, TsStreamH265: return PesStreamVideo default: return PesStreamPrivate } } type PesPacket struct { StreamId uint8 PesPacketLength uint16 PesScramblingControl uint8 PesPriority uint8 DataAlignmentIndicator uint8 Copyright uint8 OriginalOrCopy uint8 PtsDtsFlags uint8 EscrFlag uint8 EsRateFlag uint8 DsmTrickModeFlag uint8 AdditionalCopyInfoFlag uint8 PesCrcFlag uint8 PesExtensionFlag uint8 PesHeaderDataLength uint8 Pts uint64 Dts uint64 EscrBase uint64 EscrExtension uint16 EsRate uint32 TrickModeControl uint8 TrickValue uint8 AdditionalCopyInfo uint8 PreviousPesPacketCrc uint16 PesPayload []byte //TODO //if ( PesExtensionFlag == '1') // PesPrivateDataFlag uint8 // PackHeaderFieldFlag uint8 // ProgramPacketSequenceCounterFlag uint8 // PStdBufferFlag uint8 // PesExtensionFlag2 uint8 // PesPrivateData [16]byte } func NewPesPacket() *PesPacket { return new(PesPacket) } func (pkg *PesPacket) PrettyPrint(file *os.File) { file.WriteString(fmt.Sprintf("stream id:%d\n", pkg.StreamId)) file.WriteString(fmt.Sprintf("pes packet length:%d\n", pkg.PesPacketLength)) file.WriteString(fmt.Sprintf("pes scrambling control:%d\n", pkg.PesScramblingControl)) file.WriteString(fmt.Sprintf("pes priority:%d\n", pkg.PesPriority)) file.WriteString(fmt.Sprintf("data alignment indicator:%d\n", pkg.DataAlignmentIndicator)) file.WriteString(fmt.Sprintf("copyright:%d\n", pkg.Copyright)) file.WriteString(fmt.Sprintf("original or copy:%d\n", pkg.OriginalOrCopy)) file.WriteString(fmt.Sprintf("pts dts flags:%d\n", pkg.PtsDtsFlags)) file.WriteString(fmt.Sprintf("escr flag:%d\n", pkg.EscrFlag)) file.WriteString(fmt.Sprintf("es rate flag:%d\n", pkg.EsRateFlag)) file.WriteString(fmt.Sprintf("dsm trick mode flag:%d\n", pkg.DsmTrickModeFlag)) file.WriteString(fmt.Sprintf("additional copy info flag:%d\n", pkg.AdditionalCopyInfoFlag)) file.WriteString(fmt.Sprintf("pes crc flag:%d\n", pkg.PesCrcFlag)) file.WriteString(fmt.Sprintf("pes extension flag:%d\n", pkg.PesExtensionFlag)) file.WriteString(fmt.Sprintf("pes header data length:%d\n", pkg.PesHeaderDataLength)) if pkg.PtsDtsFlags&0x02 == 0x02 { file.WriteString(fmt.Sprintf("PTS:%d\n", pkg.Pts)) } if pkg.PtsDtsFlags&0x03 == 0x03 { file.WriteString(fmt.Sprintf("DTS:%d\n", pkg.Dts)) } if pkg.EscrFlag == 1 { file.WriteString(fmt.Sprintf("escr base:%d\n", pkg.EscrBase)) file.WriteString(fmt.Sprintf("escr extension:%d\n", pkg.EscrExtension)) } if pkg.EsRateFlag == 1 { file.WriteString(fmt.Sprintf("es rate:%d\n", pkg.EsRate)) } if pkg.DsmTrickModeFlag == 1 { file.WriteString(fmt.Sprintf("trick mode control:%d\n", pkg.TrickModeControl)) } if pkg.AdditionalCopyInfoFlag == 1 { file.WriteString(fmt.Sprintf("additional copy info:%d\n", pkg.AdditionalCopyInfo)) } if pkg.PesCrcFlag == 1 { file.WriteString(fmt.Sprintf("previous pes packet crc:%d\n", pkg.PreviousPesPacketCrc)) } file.WriteString("pes packet data byte:\n") file.WriteString(fmt.Sprintf(" size: %d\n", len(pkg.PesPayload))) file.WriteString(" data:") for i := 0; i < 12 && i < len(pkg.PesPayload); i++ { if i%4 == 0 { file.WriteString("\n") file.WriteString(" ") } file.WriteString(fmt.Sprintf("0x%02x ", pkg.PesPayload[i])) } file.WriteString("\n") } func (pkg *PesPacket) Decode(bs *BitStream) error { if bs.RemainBytes() < 9 { return errNeedMore } bs.SkipBits(24) //packet_start_code_prefix pkg.StreamId = bs.Uint8(8) //stream_id pkg.PesPacketLength = bs.Uint16(16) bs.SkipBits(2) //'10' pkg.PesScramblingControl = bs.Uint8(2) pkg.PesPriority = bs.Uint8(1) pkg.DataAlignmentIndicator = bs.Uint8(1) pkg.Copyright = bs.Uint8(1) pkg.OriginalOrCopy = bs.Uint8(1) pkg.PtsDtsFlags = bs.Uint8(2) pkg.EscrFlag = bs.Uint8(1) pkg.EsRateFlag = bs.Uint8(1) pkg.DsmTrickModeFlag = bs.Uint8(1) pkg.AdditionalCopyInfoFlag = bs.Uint8(1) pkg.PesCrcFlag = bs.Uint8(1) pkg.PesExtensionFlag = bs.Uint8(1) pkg.PesHeaderDataLength = bs.Uint8(8) if bs.RemainBytes() < int(pkg.PesHeaderDataLength) { bs.UnRead(9 * 8) return errNeedMore } bs.Markdot() if pkg.PtsDtsFlags&0x02 == 0x02 { bs.SkipBits(4) pkg.Pts = bs.GetBits(3) bs.SkipBits(1) pkg.Pts = (pkg.Pts << 15) | bs.GetBits(15) bs.SkipBits(1) pkg.Pts = (pkg.Pts << 15) | bs.GetBits(15) bs.SkipBits(1) } if pkg.PtsDtsFlags&0x03 == 0x03 { bs.SkipBits(4) pkg.Dts = bs.GetBits(3) bs.SkipBits(1) pkg.Dts = (pkg.Dts << 15) | bs.GetBits(15) bs.SkipBits(1) pkg.Dts = (pkg.Dts << 15) | bs.GetBits(15) bs.SkipBits(1) } else { pkg.Dts = pkg.Pts } if pkg.EscrFlag == 1 { bs.SkipBits(2) pkg.EscrBase = bs.GetBits(3) bs.SkipBits(1) pkg.EscrBase = (pkg.Pts << 15) | bs.GetBits(15) bs.SkipBits(1) pkg.EscrBase = (pkg.Pts << 15) | bs.GetBits(15) bs.SkipBits(1) pkg.EscrExtension = bs.Uint16(9) bs.SkipBits(1) } if pkg.EsRateFlag == 1 { bs.SkipBits(1) pkg.EsRate = bs.Uint32(22) bs.SkipBits(1) } if pkg.DsmTrickModeFlag == 1 { pkg.TrickModeControl = bs.Uint8(3) pkg.TrickValue = bs.Uint8(5) } if pkg.AdditionalCopyInfoFlag == 1 { pkg.AdditionalCopyInfo = bs.Uint8(7) } if pkg.PesCrcFlag == 1 { pkg.PreviousPesPacketCrc = bs.Uint16(16) } loc := bs.DistanceFromMarkDot() bs.SkipBits(int(pkg.PesHeaderDataLength)*8 - loc) // skip remaining header // the -3 bytes are the combined lengths // of all fields between PesHeaderDataLength and PesHeaderDataLength (2 bytes) // and the PesHeaderDataLength itself (1 byte) dataLen := int(pkg.PesPacketLength - 3 - uint16(pkg.PesHeaderDataLength)) if bs.RemainBytes() < dataLen { pkg.PesPayload = bs.RemainData() bs.UnRead((9 + int(pkg.PesHeaderDataLength)) * 8) return errNeedMore } if pkg.PesPacketLength == 0 || bs.RemainBytes() <= dataLen { pkg.PesPayload = bs.RemainData() bs.SkipBits(bs.RemainBits()) } else { pkg.PesPayload = bs.RemainData()[:dataLen] bs.SkipBits(dataLen * 8) } return nil } func (pkg *PesPacket) DecodeMpeg1(bs *BitStream) error { if bs.RemainBytes() < 6 { return errNeedMore } bs.SkipBits(24) //packet_start_code_prefix pkg.StreamId = bs.Uint8(8) //stream_id pkg.PesPacketLength = bs.Uint16(16) if pkg.PesPacketLength != 0 && bs.RemainBytes() < int(pkg.PesPacketLength) { bs.UnRead(6 * 8) return errNeedMore } bs.Markdot() for bs.NextBits(8) == 0xFF { bs.SkipBits(8) } if bs.NextBits(2) == 0x01 { bs.SkipBits(16) } if bs.NextBits(4) == 0x02 { bs.SkipBits(4) pkg.Pts = bs.GetBits(3) bs.SkipBits(1) pkg.Pts = pkg.Pts<<15 | bs.GetBits(15) bs.SkipBits(1) pkg.Pts = pkg.Pts<<15 | bs.GetBits(15) bs.SkipBits(1) } else if bs.NextBits(4) == 0x03 { bs.SkipBits(4) pkg.Pts = bs.GetBits(3) bs.SkipBits(1) pkg.Pts = pkg.Pts<<15 | bs.GetBits(15) bs.SkipBits(1) pkg.Pts = pkg.Pts<<15 | bs.GetBits(15) bs.SkipBits(1) pkg.Dts = bs.GetBits(3) bs.SkipBits(1) pkg.Dts = pkg.Pts<<15 | bs.GetBits(15) bs.SkipBits(1) pkg.Dts = pkg.Pts<<15 | bs.GetBits(15) bs.SkipBits(1) } else if bs.NextBits(8) == 0x0F { bs.SkipBits(8) } else { return errParser } loc := bs.DistanceFromMarkDot() / 8 if pkg.PesPacketLength < uint16(loc) { return errParser } if pkg.PesPacketLength == 0 || bs.RemainBits() <= int(pkg.PesPacketLength-uint16(loc))*8 { pkg.PesPayload = bs.RemainData() bs.SkipBits(bs.RemainBits()) } else { pkg.PesPayload = bs.RemainData()[:pkg.PesPacketLength-uint16(loc)] bs.SkipBits(int(pkg.PesPacketLength-uint16(loc)) * 8) } return nil } func (pkg *PesPacket) Encode(bsw *BitStreamWriter) { bsw.PutBytes([]byte{0x00, 0x00, 0x01}) bsw.PutByte(pkg.StreamId) bsw.PutUint16(pkg.PesPacketLength, 16) bsw.PutUint8(0x02, 2) bsw.PutUint8(pkg.PesScramblingControl, 2) bsw.PutUint8(pkg.PesPriority, 1) bsw.PutUint8(pkg.DataAlignmentIndicator, 1) bsw.PutUint8(pkg.Copyright, 1) bsw.PutUint8(pkg.OriginalOrCopy, 1) bsw.PutUint8(pkg.PtsDtsFlags, 2) bsw.PutUint8(pkg.EscrFlag, 1) bsw.PutUint8(pkg.EsRateFlag, 1) bsw.PutUint8(pkg.DsmTrickModeFlag, 1) bsw.PutUint8(pkg.AdditionalCopyInfoFlag, 1) bsw.PutUint8(pkg.PesCrcFlag, 1) bsw.PutUint8(pkg.PesExtensionFlag, 1) bsw.PutByte(pkg.PesHeaderDataLength) if pkg.PtsDtsFlags == 0x02 { bsw.PutUint8(0x02, 4) bsw.PutUint64(pkg.Pts>>30, 3) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.Pts>>15, 15) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.Pts, 15) bsw.PutUint8(0x01, 1) } if pkg.PtsDtsFlags == 0x03 { bsw.PutUint8(0x03, 4) bsw.PutUint64(pkg.Pts>>30, 3) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.Pts>>15, 15) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.Pts, 15) bsw.PutUint8(0x01, 1) bsw.PutUint8(0x01, 4) bsw.PutUint64(pkg.Dts>>30, 3) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.Dts>>15, 15) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.Dts, 15) bsw.PutUint8(0x01, 1) } if pkg.EscrFlag == 1 { bsw.PutUint8(0x03, 2) bsw.PutUint64(pkg.EscrBase>>30, 3) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.EscrBase>>15, 15) bsw.PutUint8(0x01, 1) bsw.PutUint64(pkg.EscrBase, 15) bsw.PutUint8(0x01, 1) } bsw.PutBytes(pkg.PesPayload) } ================================================ FILE: gb28181/mpegps/ps_demuxer.go ================================================ package mpegps //单元来源于https://github.com/yapingcat/gomedia import ( "errors" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/hevc" "github.com/q191201771/naza/pkg/nazalog" ) type psStream struct { sid uint8 cid PsStreamType pts uint64 dts uint64 streamBuf []byte } func newPsStream(sid uint8, cid PsStreamType) *psStream { return &psStream{ sid: sid, cid: cid, streamBuf: make([]byte, 0, 4096), } } func (p *psStream) setCid(cid PsStreamType) { p.cid = cid } func (p *psStream) clearBuf() { p.streamBuf = p.streamBuf[:0] } type PsDemuxer struct { streamMap map[uint8]*psStream pkg *PsPacket mpeg1 bool cache []byte OnFrame func(frame []byte, cid PsStreamType, pts uint64, dts uint64) //解ps包过程中,解码回调psm,system header,pes包等 //decodeResult 解码ps包时的产生的错误 //这个回调主要用于debug,查看是否ps包存在问题 OnPacket func(pkg Display, decodeResult error) verifyBuf []byte log nazalog.Logger } func NewPsDemuxer() *PsDemuxer { psDemuxer := &PsDemuxer{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), cache: make([]byte, 0, 256), OnFrame: nil, OnPacket: nil, } return psDemuxer } func (psDemuxer *PsDemuxer) Input(data []byte) error { var bs *BitStream if len(psDemuxer.cache) > 0 { psDemuxer.cache = append(psDemuxer.cache, data...) bs = NewBitStream(psDemuxer.cache) } else { bs = NewBitStream(data) } saveReseved := func() { tmpcache := make([]byte, bs.RemainBytes()) copy(tmpcache, bs.RemainData()) psDemuxer.cache = tmpcache } var ret error = nil for !bs.EOS() { if mpegerr, ok := ret.(Error); ok { if mpegerr.NeedMore() { saveReseved() } break } if bs.RemainBits() < 32 { ret = errNeedMore saveReseved() break } prefix_code := bs.NextBits(32) switch prefix_code { case 0x000001BA: //pack header if psDemuxer.pkg.Header == nil { psDemuxer.pkg.Header = new(PsPackHeader) } ret = psDemuxer.pkg.Header.Decode(bs) psDemuxer.mpeg1 = psDemuxer.pkg.Header.IsMpeg1 if psDemuxer.OnPacket != nil { psDemuxer.OnPacket(psDemuxer.pkg.Header, ret) } case 0x000001BB: //system header if psDemuxer.pkg.Header == nil { return errors.New("PsDemuxer.pkg.Header must not be nil") } if psDemuxer.pkg.System == nil { psDemuxer.pkg.System = new(SystemHeader) } ret = psDemuxer.pkg.System.Decode(bs) if psDemuxer.OnPacket != nil { psDemuxer.OnPacket(psDemuxer.pkg.System, ret) } case 0x000001BC: //program stream map if psDemuxer.pkg.Psm == nil { psDemuxer.pkg.Psm = new(ProgramStreamMap) } if ret = psDemuxer.pkg.Psm.Decode(bs); ret == nil { for _, streaminfo := range psDemuxer.pkg.Psm.StreamMap { if _, found := psDemuxer.streamMap[streaminfo.ElementaryStreamId]; !found { stream := newPsStream(streaminfo.ElementaryStreamId, PsStreamType(streaminfo.StreamType)) psDemuxer.streamMap[stream.sid] = stream } else { stream := psDemuxer.streamMap[streaminfo.ElementaryStreamId] stream.setCid(PsStreamType(streaminfo.StreamType)) } } } if psDemuxer.OnPacket != nil { psDemuxer.OnPacket(psDemuxer.pkg.Psm, ret) } case 0x000001BD, 0x000001BE, 0x000001BF, 0x000001F0, 0x000001F1, 0x000001F2, 0x000001F3, 0x000001F4, 0x000001F5, 0x000001F6, 0x000001F7, 0x000001F8, 0x000001F9, 0x000001FA, 0x000001FB: if psDemuxer.pkg.CommPes == nil { psDemuxer.pkg.CommPes = new(CommonPesPacket) } ret = psDemuxer.pkg.CommPes.Decode(bs) case 0x000001FF: //program stream directory if psDemuxer.pkg.Psd == nil { psDemuxer.pkg.Psd = new(ProgramStreamDirectory) } ret = psDemuxer.pkg.Psd.Decode(bs) case 0x000001B9: //MPEG_program_end_code continue default: if prefix_code&0xFFFFFFE0 == 0x000001C0 || prefix_code&0xFFFFFFE0 == 0x000001E0 { if psDemuxer.pkg.Pes == nil { psDemuxer.pkg.Pes = NewPesPacket() } if psDemuxer.mpeg1 { ret = psDemuxer.pkg.Pes.DecodeMpeg1(bs) } else { ret = psDemuxer.pkg.Pes.Decode(bs) } if psDemuxer.OnPacket != nil { psDemuxer.OnPacket(psDemuxer.pkg.Pes, ret) } if ret == nil { if stream, found := psDemuxer.streamMap[psDemuxer.pkg.Pes.StreamId]; found { if psDemuxer.mpeg1 && stream.cid == PsStreamUnknow { psDemuxer.guessCodecid(stream) } psDemuxer.demuxPespacket(stream, psDemuxer.pkg.Pes) } else { if psDemuxer.mpeg1 { stream := newPsStream(psDemuxer.pkg.Pes.StreamId, PsStreamUnknow) psDemuxer.streamMap[stream.sid] = stream stream.streamBuf = append(stream.streamBuf, psDemuxer.pkg.Pes.PesPayload...) stream.pts = psDemuxer.pkg.Pes.Pts stream.dts = psDemuxer.pkg.Pes.Dts } else if psDemuxer.pkg.Pes.StreamId == uint8(PesStreamVideo) { if len(psDemuxer.verifyBuf) > 256 { psDemuxer.verifyBuf = psDemuxer.verifyBuf[:0] } psDemuxer.verifyBuf = append(psDemuxer.verifyBuf, psDemuxer.pkg.Pes.PesPayload...) if h26x, err := mpegH26xVerify(psDemuxer.verifyBuf); err == nil { switch h26x { case CodecUnknown: case CodecH264: streamH264 := newPsStream(uint8(PesStreamVideo), PsStreamH264) psDemuxer.streamMap[streamH264.sid] = streamH264 psDemuxer.demuxPespacket(streamH264, psDemuxer.pkg.Pes) case CodecH265: streamH265 := newPsStream(uint8(PesStreamVideo), PsStreamH265) psDemuxer.streamMap[streamH265.sid] = streamH265 psDemuxer.demuxPespacket(streamH265, psDemuxer.pkg.Pes) } } } else if psDemuxer.pkg.Pes.StreamId == uint8(PesStreamAudio) { if _, found = psDemuxer.streamMap[uint8(PesStreamVideo)]; found { psStreamType := audioVerify(psDemuxer.pkg.Pes.PesPayload) streamAudio := newPsStream(uint8(PesStreamAudio), psStreamType) psDemuxer.streamMap[streamAudio.sid] = streamAudio psDemuxer.demuxPespacket(streamAudio, psDemuxer.pkg.Pes) } } } } } else { bs.SkipBits(8) } } } if ret == nil && len(psDemuxer.cache) > 0 { psDemuxer.cache = nil } return ret } func (psDemuxer *PsDemuxer) Flush() { for _, stream := range psDemuxer.streamMap { if len(stream.streamBuf) == 0 { continue } if psDemuxer.OnFrame != nil { psDemuxer.OnFrame(stream.streamBuf, stream.cid, stream.pts/90, stream.dts/90) } } } func (psDemuxer *PsDemuxer) guessCodecid(stream *psStream) { if stream.sid&0xE0 == uint8(PesStreamAudio) { psStreamType := audioVerify(psDemuxer.pkg.Pes.PesPayload) stream.cid = psStreamType } else if stream.sid&0xE0 == uint8(PesStreamVideo) { if h26x, err := mpegH26xVerify(stream.streamBuf); err == nil { switch h26x { case CodecUnknown: case CodecH264: stream.cid = PsStreamH264 case CodecH265: stream.cid = PsStreamH265 } } } } func (psDemuxer *PsDemuxer) demuxPespacket(stream *psStream, pes *PesPacket) error { switch stream.cid { case PsStreamAac, PsStreamG711A, PsStreamG711U: return psDemuxer.demuxAudio(stream, pes) case PsStreamH264, PsStreamH265: return psDemuxer.demuxH26x(stream, pes) case PsStreamUnknow: if stream.pts != pes.Pts { stream.streamBuf = nil } stream.streamBuf = append(stream.streamBuf, pes.PesPayload...) stream.pts = pes.Pts stream.dts = pes.Dts } return nil } func (psDemuxer *PsDemuxer) demuxAudio(stream *psStream, pes *PesPacket) error { if psDemuxer.OnFrame != nil { psDemuxer.OnFrame(pes.PesPayload, stream.cid, pes.Pts/90, pes.Dts/90) } return nil } func (psDemuxer *PsDemuxer) demuxH26x(stream *psStream, pes *PesPacket) error { if stream.pts == 0 { stream.streamBuf = append(stream.streamBuf, pes.PesPayload...) stream.pts = pes.Pts stream.dts = pes.Dts } else if stream.pts == pes.Pts || pes.Pts == 0 { stream.streamBuf = append(stream.streamBuf, pes.PesPayload...) } else { start, sc := FindStartCode(stream.streamBuf, 0) for start >= 0 && start < len(stream.streamBuf) { end, sc2 := FindStartCode(stream.streamBuf, start+int(sc)) if end < 0 { end = len(stream.streamBuf) } if stream.cid == PsStreamH264 { naluType := H264NaluType(stream.streamBuf[start:]) if naluType != avc.NaluTypeAud { if psDemuxer.OnFrame != nil { psDemuxer.OnFrame(stream.streamBuf[start:end], stream.cid, stream.pts/90, stream.dts/90) } } } else if stream.cid == PsStreamH265 { naluType := H265NaluType(stream.streamBuf[start:]) if naluType != hevc.NaluTypeAud { if psDemuxer.OnFrame != nil { psDemuxer.OnFrame(stream.streamBuf[start:end], stream.cid, stream.pts/90, stream.dts/90) } } } start = end sc = sc2 } stream.streamBuf = nil stream.streamBuf = append(stream.streamBuf, pes.PesPayload...) stream.pts = pes.Pts stream.dts = pes.Dts } return nil } ================================================ FILE: gb28181/mpegps/ps_demuxer_test.go ================================================ package mpegps import ( "encoding/hex" "fmt" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/rtprtcp" "github.com/q191201771/naza/pkg/nazabytes" "github.com/q191201771/naza/pkg/nazalog" "io" "os" "testing" ) var ps1 []byte = []byte{0x00, 0x00, 0x01, 0xBA} var ps2 []byte = []byte{0x00, 0x00, 0x01, 0xBA, 0x40, 0x01, 0x00, 0x01, 0x33, 0x44, 0xFF, 0xFF, 0xFF, 0xF1, 0xFF} var ps3 []byte = []byte{0x00, 0x00, 0x01, 0xBA, 0x40, 0x01, 0x00, 0x01, 0x33, 0x44, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x01, 0xBB} var ps4 []byte = []byte{0x00, 0x00, 0x01, 0xBA, 0x40, 0x01, 0x00, 0x01, 0x33, 0x44, 0xFF, 0xFF, 0xFF, 0xF1, 0x34, 0x00, 0x00, 0x01, 0xBB, 0x00, 0x01, 0x00, 0x01, 0x33, 0x44, 0xFF, 0x34} var ps5 []byte = []byte{0x00, 0x00, 0x01, 0xBA, 0x40, 0x01, 0x00, 0x01, 0x33, 0x44, 0xFF, 0xFF, 0xFF, 0xF1, 0x34, 0x00, 0x00, 0x01, 0xBB, 0x00, 0x09, 0x00, 0x01, 0x33, 0x44, 0xFF, 0x34, 0x81, 0x00, 0x00} var ps6 []byte = []byte{0x00, 0x00, 0x01, 0xBC, 0x40, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x34, 0x81, 0x00, 0x00} var ps7 []byte = []byte{0x00, 0x00, 0x01, 0xBA, 0x20, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03} func TestPSDemuxer_Input(t *testing.T) { type fields struct { streamMap map[uint8]*psStream pkg *PsPacket cache []byte OnPacket func(pkg Display, decodeResult error) OnFrame func(frame []byte, cid PsStreamType, pts uint64, dts uint64) } type args struct { data []byte } tests := []struct { name string fields fields args args wantErr bool }{ {name: "test1", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps1}, wantErr: true}, {name: "test2", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps2}, wantErr: false}, {name: "test3", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps3}, wantErr: true}, {name: "test4", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps4}, wantErr: true}, {name: "test5", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps5}, wantErr: false}, {name: "test6", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps6}, wantErr: false}, {name: "test-mpeg1", fields: fields{ streamMap: make(map[uint8]*psStream), pkg: new(PsPacket), }, args: args{data: ps7}, wantErr: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { psdemuxer := &PsDemuxer{ streamMap: tt.fields.streamMap, pkg: tt.fields.pkg, cache: tt.fields.cache, OnPacket: tt.fields.OnPacket, OnFrame: tt.fields.OnFrame, } if err := psdemuxer.Input(tt.args.data); (err != nil) != tt.wantErr { t.Errorf("PSDemuxer.Input() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestPSDemuxer(t *testing.T) { var psUnpacker *PsDemuxer os.Remove("h.ps") os.Remove("h.h264") os.Remove("ps_demux_result") dumpFile := base.NewDumpFile() err := dumpFile.OpenToRead("C:\\Users\\Administrator\\Desktop\\dump_37060000001320000001001.raw") if err != nil { fmt.Println(err) return } psUnpacker = NewPsDemuxer() psUnpacker.OnFrame = func(frame []byte, cid PsStreamType, pts uint64, dts uint64) { if cid == PsStreamH264 || cid == PsStreamH265 { writeFile("h.h264", frame) } else { if cid == PsStreamG711A { nazalog.Infof("存在音频g711A 大小:%d dts:%d", len(frame), dts) } else if cid == PsStreamG711U { nazalog.Infof("存在音频g711U 大小:%d dts:%d", len(frame), dts) } else { nazalog.Infof("存在音频aac 大小:%d dts:%d", len(frame), dts) } } } fd3, err := os.OpenFile("ps_demux_result", os.O_CREATE|os.O_RDWR, 0666) if err != nil { fmt.Println(err) return } defer fd3.Close() psUnpacker.OnPacket = func(pkg Display, decodeResult error) { switch value := pkg.(type) { case *PsPackHeader: fd3.WriteString("--------------PS Pack Header--------------\n") if decodeResult == nil { value.PrettyPrint(fd3) } else { fd3.WriteString(fmt.Sprintf("Decode Ps Packet Failed %s\n", decodeResult.Error())) } case *SystemHeader: fd3.WriteString("--------------System Header--------------\n") if decodeResult == nil { value.PrettyPrint(fd3) } else { fd3.WriteString(fmt.Sprintf("Decode Ps Packet Failed %s\n", decodeResult.Error())) } case *ProgramStreamMap: fd3.WriteString("--------------------PSM-------------------\n") if decodeResult == nil { value.PrettyPrint(fd3) } else { fd3.WriteString(fmt.Sprintf("Decode Ps Packet Failed %s\n", decodeResult.Error())) } case *PesPacket: fd3.WriteString("-------------------PES--------------------\n") if decodeResult == nil { value.PrettyPrint(fd3) } else { fd3.WriteString(fmt.Sprintf("Decode Ps Packet Failed %s\n", decodeResult.Error())) } } } if err != nil { return } packe := 0 Seq := 0 for { m, err := dumpFile.ReadOneMessage() if err == io.EOF { break } ipkt, err := rtprtcp.ParseRtpPacket(m.Body) if err != nil { nazalog.Errorf("PsUnpacker ParseRtpPacket failed. b=%s, err=%+v", hex.Dump(nazabytes.Prefix(m.Body, 64)), err) continue } packe++ if ipkt.Header.Seq-uint16(Seq) != 1 { fmt.Printf("pkt Seq:%d ssrc:%d \n", ipkt.Header.Seq, ipkt.Header.Ssrc) } Seq = int(ipkt.Header.Seq) body := ipkt.Body() writeFile("h.ps", body) fmt.Println(psUnpacker.Input(body)) } } func fileExists(fileName string) (bool, error) { _, err := os.Stat(fileName) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err } func writeFile(filename string, buffer []byte) (err error) { var fp *os.File b, err := fileExists(filename) if err != nil { return } if !b { fp, err = os.Create(filename) if err != nil { return } } else { fp, err = os.OpenFile(filename, os.O_CREATE|os.O_APPEND, 6) if err != nil { return } } defer fp.Close() _, err = fp.Write(buffer) return } ================================================ FILE: gb28181/mpegps/ps_muxer.go ================================================ package mpegps //单元来源于https://github.com/yapingcat/gomedia import ( "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/hevc" ) type PsMuxer struct { system *SystemHeader psm *ProgramStreamMap OnPacket func(pkg []byte, pts uint64) firstframe bool } func NewPsMuxer() *PsMuxer { muxer := new(PsMuxer) muxer.firstframe = true muxer.system = new(SystemHeader) muxer.system.RateBound = 26234 muxer.psm = new(ProgramStreamMap) muxer.psm.CurrentNextIndicator = 1 muxer.psm.ProgramStreamMapVersion = 1 muxer.OnPacket = nil return muxer } func (muxer *PsMuxer) AddStream(cid PsStreamType) uint8 { if cid == PsStreamH265 || cid == PsStreamH264 { es := NewElementaryStream(uint8(PesStreamVideo) + muxer.system.VideoBound) es.PStdBufferBoundScale = 1 es.PStdBufferSizeBound = 400 muxer.system.Streams = append(muxer.system.Streams, es) muxer.system.VideoBound++ muxer.psm.StreamMap = append(muxer.psm.StreamMap, NewElementaryStreamElem(uint8(cid), es.StreamId)) muxer.psm.ProgramStreamMapVersion++ return es.StreamId } else { es := NewElementaryStream(uint8(PesStreamAudio) + muxer.system.AudioBound) es.PStdBufferBoundScale = 0 es.PStdBufferSizeBound = 32 muxer.system.Streams = append(muxer.system.Streams, es) muxer.system.AudioBound++ muxer.psm.StreamMap = append(muxer.psm.StreamMap, NewElementaryStreamElem(uint8(cid), es.StreamId)) muxer.psm.ProgramStreamMapVersion++ return es.StreamId } } func (muxer *PsMuxer) Write(sid uint8, frame []byte, pts uint64, dts uint64) error { var stream *ElementaryStreamElem = nil for _, es := range muxer.psm.StreamMap { if es.ElementaryStreamId == sid { stream = es break } } if stream == nil { return errNotFound } if len(frame) <= 0 { return nil } var withaud bool = false var idrFlag bool = false var first bool = true var vcl bool = false if stream.StreamType == uint8(PsStreamH264) || stream.StreamType == uint8(PsStreamH265) { SplitFrame(frame, func(nalu []byte) bool { if stream.StreamType == uint8(PsStreamH264) { naluType := avc.ParseNaluType(nalu[0]) if naluType == avc.NaluTypeAud { withaud = true return false } else if naluType >= avc.NaluTypeSlice && naluType <= avc.NaluTypeIdrSlice { if naluType == avc.NaluTypeIdrSlice { idrFlag = true } vcl = true return false } return true } else { naluType := hevc.ParseNaluType(nalu[0]) if naluType == hevc.NaluTypeAud { withaud = true return false } else if naluType >= hevc.NaluTypeSliceBlaWlp && naluType <= hevc.NaluTypeSliceRsvIrapVcl23 || naluType >= hevc.NaluTypeSliceTrailN && naluType <= hevc.NaluTypeSliceRaslR { if naluType >= hevc.NaluTypeSliceBlaWlp && naluType <= hevc.NaluTypeSliceRsvIrapVcl23 { idrFlag = true } vcl = true return false } return true } }) } dts = dts * 90 pts = pts * 90 bsw := NewBitStreamWriter(1024) var pack PsPackHeader pack.SystemClockReferenceBase = dts - 3600 pack.SystemClockReferenceExtension = 0 pack.ProgramMuxRate = 6106 pack.Encode(bsw) if muxer.firstframe || idrFlag { muxer.system.Encode(bsw) muxer.psm.Encode(bsw) muxer.firstframe = false } pespkg := NewPesPacket() for len(frame) > 0 { peshdrlen := 13 pespkg.StreamId = sid pespkg.PtsDtsFlags = 0x03 pespkg.PesHeaderDataLength = 10 pespkg.Pts = pts pespkg.Dts = dts if idrFlag { pespkg.DataAlignmentIndicator = 1 } if first && !withaud && vcl { if stream.StreamType == uint8(PsStreamH264) { pespkg.PesPayload = append(pespkg.PesPayload, H264AudNalu...) peshdrlen += 6 } else if stream.StreamType == uint8(PsStreamH265) { pespkg.PesPayload = append(pespkg.PesPayload, H265AudNalu...) peshdrlen += 7 } } if peshdrlen+len(frame) >= 0xFFFF { pespkg.PesPacketLength = 0xFFFF pespkg.PesPayload = append(pespkg.PesPayload, frame[0:0xFFFF-peshdrlen]...) frame = frame[0xFFFF-peshdrlen:] } else { pespkg.PesPacketLength = uint16(peshdrlen + len(frame)) pespkg.PesPayload = append(pespkg.PesPayload, frame[0:]...) frame = frame[:0] } pespkg.Encode(bsw) pespkg.PesPayload = pespkg.PesPayload[:0] if muxer.OnPacket != nil { muxer.OnPacket(bsw.Bits(), pts) } bsw.Reset() first = false } return nil } ================================================ FILE: gb28181/mpegps/ps_proto.go ================================================ package mpegps import ( "encoding/binary" "errors" "fmt" "os" ) type Error interface { NeedMore() bool ParserError() bool StreamIdNotFound() bool } var errNeedMore error = &needmoreError{} type needmoreError struct{} func (e *needmoreError) Error() string { return "need more bytes" } func (e *needmoreError) NeedMore() bool { return true } func (e *needmoreError) ParserError() bool { return false } func (e *needmoreError) StreamIdNotFound() bool { return false } var errParser error = &parserError{} type parserError struct{} func (e *parserError) Error() string { return "parser packet error" } func (e *parserError) NeedMore() bool { return false } func (e *parserError) ParserError() bool { return true } func (e *parserError) StreamIdNotFound() bool { return false } var errNotFound error = &sidNotFoundError{} type sidNotFoundError struct{} func (e *sidNotFoundError) Error() string { return "stream id not found" } func (e *sidNotFoundError) NeedMore() bool { return false } func (e *sidNotFoundError) ParserError() bool { return false } func (e *sidNotFoundError) StreamIdNotFound() bool { return true } type PsStreamType int const ( PsStreamUnknow PsStreamType = 0xFF PsStreamAac PsStreamType = 0x0F PsStreamH264 PsStreamType = 0x1B PsStreamH265 PsStreamType = 0x24 PsStreamG711A PsStreamType = 0x90 PsStreamG711U PsStreamType = 0x91 ) // Table 2-33 – Program Stream pack header // pack_header() { // pack_start_code 32 bslbf // '01' 2 bslbf // system_clock_reference_base [32..30] 3 bslbf // marker_bit 1 bslbf // system_clock_reference_base [29..15] 15 bslbf // marker_bit 1 bslbf // system_clock_reference_base [14..0] 15 bslbf // marker_bit 1 bslbf // system_clock_reference_extension 9 uimsbf // marker_bit 1 bslbf // program_mux_rate 22 uimsbf // marker_bit 1 bslbf // marker_bit 1 bslbf // reserved 5 bslbf // pack_stuffing_length 3 uimsbf // for (i = 0; i < pack_stuffing_length; i++) { // stuffing_byte 8 bslbf // } // if (nextbits() == SystemHeader_start_code) { // SystemHeader () // } // } type PsPackHeader struct { IsMpeg1 bool SystemClockReferenceBase uint64 //33 bits SystemClockReferenceExtension uint16 //9 bits ProgramMuxRate uint32 //22 bits PackStuffingLength uint8 //3 bitss } func (psPackHeader *PsPackHeader) PrettyPrint(file *os.File) { file.WriteString(fmt.Sprintf("IsMpeg1:%t\n", psPackHeader.IsMpeg1)) file.WriteString(fmt.Sprintf("system clock reference base:%d\n", psPackHeader.SystemClockReferenceBase)) file.WriteString(fmt.Sprintf("system clock reference extension:%d\n", psPackHeader.SystemClockReferenceExtension)) file.WriteString(fmt.Sprintf("program mux rate:%d\n", psPackHeader.ProgramMuxRate)) file.WriteString(fmt.Sprintf("pack stuffing length:%d\n", psPackHeader.PackStuffingLength)) } func (psPackHeader *PsPackHeader) Decode(bs *BitStream) error { if bs.RemainBytes() < 5 { return errNeedMore } if bs.Uint32(32) != 0x000001BA { return errors.New("ps header must start with 000001BA") } if bs.NextBits(2) == 0x01 { //mpeg2 if bs.RemainBytes() < 10 { return errNeedMore } return psPackHeader.decodeMpeg2(bs) } else if bs.NextBits(4) == 0x02 { //mpeg1 if bs.RemainBytes() < 8 { return errNeedMore } psPackHeader.IsMpeg1 = true return psPackHeader.decodeMpeg1(bs) } else { return errParser } } func (psPackHeader *PsPackHeader) decodeMpeg2(bs *BitStream) error { bs.SkipBits(2) psPackHeader.SystemClockReferenceBase = bs.GetBits(3) bs.SkipBits(1) psPackHeader.SystemClockReferenceBase = psPackHeader.SystemClockReferenceBase<<15 | bs.GetBits(15) bs.SkipBits(1) psPackHeader.SystemClockReferenceBase = psPackHeader.SystemClockReferenceBase<<15 | bs.GetBits(15) bs.SkipBits(1) psPackHeader.SystemClockReferenceExtension = bs.Uint16(9) bs.SkipBits(1) psPackHeader.ProgramMuxRate = bs.Uint32(22) bs.SkipBits(1) bs.SkipBits(1) bs.SkipBits(5) psPackHeader.PackStuffingLength = bs.Uint8(3) if bs.RemainBytes() < int(psPackHeader.PackStuffingLength) { bs.UnRead(10 * 8) return errNeedMore } bs.SkipBits(int(psPackHeader.PackStuffingLength) * 8) return nil } func (psPackHeader *PsPackHeader) decodeMpeg1(bs *BitStream) error { bs.SkipBits(4) psPackHeader.SystemClockReferenceBase = bs.GetBits(3) bs.SkipBits(1) psPackHeader.SystemClockReferenceBase = psPackHeader.SystemClockReferenceBase<<15 | bs.GetBits(15) bs.SkipBits(1) psPackHeader.SystemClockReferenceBase = psPackHeader.SystemClockReferenceBase<<15 | bs.GetBits(15) bs.SkipBits(1) psPackHeader.SystemClockReferenceExtension = 1 psPackHeader.ProgramMuxRate = bs.Uint32(7) bs.SkipBits(1) psPackHeader.ProgramMuxRate = psPackHeader.ProgramMuxRate<<15 | bs.Uint32(15) bs.SkipBits(1) return nil } func (psPackHeader *PsPackHeader) Encode(bsw *BitStreamWriter) { bsw.PutBytes([]byte{0x00, 0x00, 0x01, 0xBA}) bsw.PutUint8(1, 2) bsw.PutUint64(psPackHeader.SystemClockReferenceBase>>30, 3) bsw.PutUint8(1, 1) bsw.PutUint64(psPackHeader.SystemClockReferenceBase>>15, 15) bsw.PutUint8(1, 1) bsw.PutUint64(psPackHeader.SystemClockReferenceBase, 15) bsw.PutUint8(1, 1) bsw.PutUint16(psPackHeader.SystemClockReferenceExtension, 9) bsw.PutUint8(1, 1) bsw.PutUint32(psPackHeader.ProgramMuxRate, 22) bsw.PutUint8(1, 1) bsw.PutUint8(1, 1) bsw.PutUint8(0x1F, 5) bsw.PutUint8(psPackHeader.PackStuffingLength, 3) bsw.PutRepetValue(0xFF, int(psPackHeader.PackStuffingLength)) } type ElementaryStream struct { StreamId uint8 PStdBufferBoundScale uint8 PStdBufferSizeBound uint16 } func NewElementaryStream(sid uint8) *ElementaryStream { return &ElementaryStream{ StreamId: sid, } } // SystemHeader () { // SystemHeader_start_code 32 bslbf // header_length 16 uimsbf // marker_bit 1 bslbf // rate_bound 22 uimsbf // marker_bit 1 bslbf // audio_bound 6 uimsbf // fixed_flag 1 bslbf // CSPS_flag 1 bslbf // system_audio_lock_flag 1 bslbf // system_video_lock_flag 1 bslbf // marker_bit 1 bslbf // video_bound 5 uimsbf // packet_rate_restriction_flag 1 bslbf // reserved_bits 7 bslbf // while (nextbits () == '1') { // stream_id 8 uimsbf // '11' 2 bslbf // P-STD_buffer_bound_scale 1 bslbf // P-STD_buffer_size_bound 13 uimsbf // } // } type SystemHeader struct { HeaderLength uint16 RateBound uint32 AudioBound uint8 FixedFlag uint8 CspsFlag uint8 SystemAudioLockFlag uint8 SystemVideoLockFlag uint8 VideoBound uint8 PacketRateRestrictionFlag uint8 Streams []*ElementaryStream } func (sh *SystemHeader) PrettyPrint(file *os.File) { file.WriteString(fmt.Sprintf("header length:%d\n", sh.HeaderLength)) file.WriteString(fmt.Sprintf("rate bound:%d\n", sh.RateBound)) file.WriteString(fmt.Sprintf("audio bound:%d\n", sh.AudioBound)) file.WriteString(fmt.Sprintf("fixed flag:%d\n", sh.FixedFlag)) file.WriteString(fmt.Sprintf("csps flag:%d\n", sh.CspsFlag)) file.WriteString(fmt.Sprintf("system audio lock flag:%d\n", sh.SystemAudioLockFlag)) file.WriteString(fmt.Sprintf("system video lock flag:%d\n", sh.SystemVideoLockFlag)) file.WriteString(fmt.Sprintf("video bound:%d\n", sh.VideoBound)) file.WriteString(fmt.Sprintf("packet rate restriction flag:%d\n", sh.PacketRateRestrictionFlag)) for i, es := range sh.Streams { file.WriteString(fmt.Sprintf("----streams %d\n", i)) file.WriteString(fmt.Sprintf(" stream id:%d\n", es.StreamId)) file.WriteString(fmt.Sprintf(" PStdBufferBoundScale:%d\n", es.PStdBufferBoundScale)) file.WriteString(fmt.Sprintf(" PStdBufferSizeBound:%d\n", es.PStdBufferSizeBound)) } } func (sh *SystemHeader) Encode(bsw *BitStreamWriter) { bsw.PutBytes([]byte{0x00, 0x00, 0x01, 0xBB}) loc := bsw.ByteOffset() bsw.PutUint16(0, 16) bsw.Markdot() bsw.PutUint8(1, 1) bsw.PutUint32(sh.RateBound, 22) bsw.PutUint8(1, 1) bsw.PutUint8(sh.AudioBound, 6) bsw.PutUint8(sh.FixedFlag, 1) bsw.PutUint8(sh.CspsFlag, 1) bsw.PutUint8(sh.SystemAudioLockFlag, 1) bsw.PutUint8(sh.SystemVideoLockFlag, 1) bsw.PutUint8(1, 1) bsw.PutUint8(sh.VideoBound, 5) bsw.PutUint8(sh.PacketRateRestrictionFlag, 1) bsw.PutUint8(0x7F, 7) for _, stream := range sh.Streams { bsw.PutUint8(stream.StreamId, 8) bsw.PutUint8(3, 2) bsw.PutUint8(stream.PStdBufferBoundScale, 1) bsw.PutUint16(stream.PStdBufferSizeBound, 13) } length := bsw.DistanceFromMarkDot() / 8 bsw.SetUint16(uint16(length), loc) } func (sh *SystemHeader) Decode(bs *BitStream) error { if bs.RemainBytes() < 12 { return errNeedMore } if bs.Uint32(32) != 0x000001BB { return errors.New("system header must start with 000001BB") } sh.HeaderLength = bs.Uint16(16) if bs.RemainBytes() < int(sh.HeaderLength) { bs.UnRead(6 * 8) return errNeedMore } if sh.HeaderLength < 6 || (sh.HeaderLength-6)%3 != 0 { return errParser } bs.SkipBits(1) sh.RateBound = bs.Uint32(22) bs.SkipBits(1) sh.AudioBound = bs.Uint8(6) sh.FixedFlag = bs.Uint8(1) sh.CspsFlag = bs.Uint8(1) sh.SystemAudioLockFlag = bs.Uint8(1) sh.SystemVideoLockFlag = bs.Uint8(1) bs.SkipBits(1) sh.VideoBound = bs.Uint8(5) sh.PacketRateRestrictionFlag = bs.Uint8(1) bs.SkipBits(7) sh.Streams = sh.Streams[:0] least := sh.HeaderLength - 6 for least > 0 && bs.NextBits(1) == 0x01 { es := new(ElementaryStream) es.StreamId = bs.Uint8(8) bs.SkipBits(2) es.PStdBufferBoundScale = bs.GetBit() es.PStdBufferSizeBound = bs.Uint16(13) sh.Streams = append(sh.Streams, es) least -= 3 } if least > 0 { return errParser } return nil } type ElementaryStreamElem struct { StreamType uint8 ElementaryStreamId uint8 ElementaryStreamInfoLength uint16 } func NewElementaryStreamElem(stype uint8, esid uint8) *ElementaryStreamElem { return &ElementaryStreamElem{ StreamType: stype, ElementaryStreamId: esid, } } // program_stream_map() { // packet_start_code_prefix 24 bslbf // map_stream_id 8 uimsbf // program_stream_map_length 16 uimsbf // current_next_indicator 1 bslbf // reserved 2 bslbf // program_stream_map_version 5 uimsbf // reserved 7 bslbf // marker_bit 1 bslbf // program_stream_info_length 16 uimsbf // for (i = 0; i < N; i++) { // descriptor() // } // elementary_stream_map_length 16 uimsbf // for (i = 0; i < N1; i++) { // stream_type 8 uimsbf // elementary_stream_id 8 uimsbf // elementary_stream_info_length 16 uimsbf // for (i = 0; i < N2; i++) { // descriptor() // } // } // CRC_32 32 rpchof // } type ProgramStreamMap struct { MapStreamId uint8 ProgramStreamMapLength uint16 CurrentNextIndicator uint8 ProgramStreamMapVersion uint8 ProgramStreamInfoLength uint16 ElementaryStreamMapLength uint16 StreamMap []*ElementaryStreamElem } func (psm *ProgramStreamMap) PrettyPrint(file *os.File) { file.WriteString(fmt.Sprintf("map stream id:%d\n", psm.MapStreamId)) file.WriteString(fmt.Sprintf("program stream map length:%d\n", psm.ProgramStreamMapLength)) file.WriteString(fmt.Sprintf("current next indicator:%d\n", psm.CurrentNextIndicator)) file.WriteString(fmt.Sprintf("program stream map version:%d\n", psm.ProgramStreamMapVersion)) file.WriteString(fmt.Sprintf("program stream info length:%d\n", psm.ProgramStreamInfoLength)) file.WriteString(fmt.Sprintf("elementary stream map length:%d\n", psm.ElementaryStreamMapLength)) for i, es := range psm.StreamMap { file.WriteString(fmt.Sprintf("----ES stream %d\n", i)) if es.StreamType == uint8(PsStreamAac) { file.WriteString(" streamType:AAC\n") } else if es.StreamType == uint8(PsStreamG711A) { file.WriteString(" streamType:G711A\n") } else if es.StreamType == uint8(PsStreamG711U) { file.WriteString(" streamType:G711U\n") } else if es.StreamType == uint8(PsStreamH264) { file.WriteString(" streamType:H264\n") } else if es.StreamType == uint8(PsStreamH265) { file.WriteString(" streamType:H265\n") } file.WriteString(fmt.Sprintf(" elementary stream id:%d\n", es.ElementaryStreamId)) file.WriteString(fmt.Sprintf(" elementary stream info length:%d\n", es.ElementaryStreamInfoLength)) } } func (psm *ProgramStreamMap) Encode(bsw *BitStreamWriter) { bsw.PutBytes([]byte{0x00, 0x00, 0x01, 0xBC}) loc := bsw.ByteOffset() bsw.PutUint16(psm.ElementaryStreamMapLength, 16) bsw.Markdot() bsw.PutUint8(psm.CurrentNextIndicator, 1) bsw.PutUint8(3, 2) bsw.PutUint8(psm.ProgramStreamMapVersion, 5) bsw.PutUint8(0x7F, 7) bsw.PutUint8(1, 1) bsw.PutUint16(0, 16) psm.ElementaryStreamMapLength = uint16(len(psm.StreamMap) * 4) bsw.PutUint16(psm.ElementaryStreamMapLength, 16) for _, streaminfo := range psm.StreamMap { bsw.PutUint8(streaminfo.StreamType, 8) bsw.PutUint8(streaminfo.ElementaryStreamId, 8) bsw.PutUint16(0, 16) } length := bsw.DistanceFromMarkDot()/8 + 4 bsw.SetUint16(uint16(length), loc) crc := CalcCrc32(0xffffffff, bsw.Bits()[bsw.ByteOffset()-int(length-4)-4:bsw.ByteOffset()]) tmpcrc := make([]byte, 4) binary.LittleEndian.PutUint32(tmpcrc, crc) bsw.PutBytes(tmpcrc) } func (psm *ProgramStreamMap) Decode(bs *BitStream) error { if bs.RemainBytes() < 16 { return errNeedMore } if bs.Uint32(24) != 0x000001 { return errors.New("program stream map must startwith 0x000001") } psm.MapStreamId = bs.Uint8(8) if psm.MapStreamId != 0xBC { return errors.New("map stream id must be 0xBC") } psm.ProgramStreamMapLength = bs.Uint16(16) if bs.RemainBytes() < int(psm.ProgramStreamMapLength) { bs.UnRead(6 * 8) return errNeedMore } psm.CurrentNextIndicator = bs.Uint8(1) bs.SkipBits(2) psm.ProgramStreamMapVersion = bs.Uint8(5) bs.SkipBits(8) psm.ProgramStreamInfoLength = bs.Uint16(16) if bs.RemainBytes() < int(psm.ProgramStreamInfoLength)+2 { bs.UnRead(10 * 8) return errNeedMore } bs.SkipBits(int(psm.ProgramStreamInfoLength) * 8) psm.ElementaryStreamMapLength = bs.Uint16(16) psm.ElementaryStreamMapLength = psm.ProgramStreamMapLength - psm.ProgramStreamInfoLength - 10 if bs.RemainBytes() < int(psm.ElementaryStreamMapLength)+4 { bs.UnRead(12*8 + int(psm.ProgramStreamInfoLength)*8) return errNeedMore } i := 0 psm.StreamMap = psm.StreamMap[:0] for i < int(psm.ElementaryStreamMapLength) { elem := new(ElementaryStreamElem) elem.StreamType = bs.Uint8(8) elem.ElementaryStreamId = bs.Uint8(8) elem.ElementaryStreamInfoLength = bs.Uint16(16) //TODO Parser descriptor if bs.RemainBytes() < int(elem.ElementaryStreamInfoLength) { return errParser } bs.SkipBits(int(elem.ElementaryStreamInfoLength) * 8) i += int(4 + elem.ElementaryStreamInfoLength) psm.StreamMap = append(psm.StreamMap, elem) } if i != int(psm.ElementaryStreamMapLength) { return errParser } bs.SkipBits(32) return nil } type ProgramStreamDirectory struct { PesPacketLength uint16 } func (psd *ProgramStreamDirectory) Decode(bs *BitStream) error { if bs.RemainBytes() < 6 { return errNeedMore } if bs.Uint32(32) != 0x000001FF { return errors.New("program stream directory 000001FF") } psd.PesPacketLength = bs.Uint16(16) if bs.RemainBytes() < int(psd.PesPacketLength) { bs.UnRead(6 * 8) return errNeedMore } //TODO Program Stream directory bs.SkipBits(int(psd.PesPacketLength) * 8) return nil } type CommonPesPacket struct { StreamId uint8 PesPacketLength uint16 } func (compes *CommonPesPacket) Decode(bs *BitStream) error { if bs.RemainBytes() < 6 { return errNeedMore } bs.SkipBits(24) compes.StreamId = bs.Uint8(8) compes.PesPacketLength = bs.Uint16(16) if bs.RemainBytes() < int(compes.PesPacketLength) { bs.UnRead(6 * 8) return errNeedMore } bs.SkipBits(int(compes.PesPacketLength) * 8) return nil } type PsPacket struct { Header *PsPackHeader System *SystemHeader Psm *ProgramStreamMap Psd *ProgramStreamDirectory CommPes *CommonPesPacket Pes *PesPacket } ================================================ FILE: gb28181/mpegps/util.go ================================================ package mpegps import ( "bytes" "errors" ) const ( CodecUnknown = iota CodecH264 CodecH265 CodecH266 CodecMpeg4 ) var crc32table [256]uint32 = [256]uint32{ 0x00000000, 0xB71DC104, 0x6E3B8209, 0xD926430D, 0xDC760413, 0x6B6BC517, 0xB24D861A, 0x0550471E, 0xB8ED0826, 0x0FF0C922, 0xD6D68A2F, 0x61CB4B2B, 0x649B0C35, 0xD386CD31, 0x0AA08E3C, 0xBDBD4F38, 0x70DB114C, 0xC7C6D048, 0x1EE09345, 0xA9FD5241, 0xACAD155F, 0x1BB0D45B, 0xC2969756, 0x758B5652, 0xC836196A, 0x7F2BD86E, 0xA60D9B63, 0x11105A67, 0x14401D79, 0xA35DDC7D, 0x7A7B9F70, 0xCD665E74, 0xE0B62398, 0x57ABE29C, 0x8E8DA191, 0x39906095, 0x3CC0278B, 0x8BDDE68F, 0x52FBA582, 0xE5E66486, 0x585B2BBE, 0xEF46EABA, 0x3660A9B7, 0x817D68B3, 0x842D2FAD, 0x3330EEA9, 0xEA16ADA4, 0x5D0B6CA0, 0x906D32D4, 0x2770F3D0, 0xFE56B0DD, 0x494B71D9, 0x4C1B36C7, 0xFB06F7C3, 0x2220B4CE, 0x953D75CA, 0x28803AF2, 0x9F9DFBF6, 0x46BBB8FB, 0xF1A679FF, 0xF4F63EE1, 0x43EBFFE5, 0x9ACDBCE8, 0x2DD07DEC, 0x77708634, 0xC06D4730, 0x194B043D, 0xAE56C539, 0xAB068227, 0x1C1B4323, 0xC53D002E, 0x7220C12A, 0xCF9D8E12, 0x78804F16, 0xA1A60C1B, 0x16BBCD1F, 0x13EB8A01, 0xA4F64B05, 0x7DD00808, 0xCACDC90C, 0x07AB9778, 0xB0B6567C, 0x69901571, 0xDE8DD475, 0xDBDD936B, 0x6CC0526F, 0xB5E61162, 0x02FBD066, 0xBF469F5E, 0x085B5E5A, 0xD17D1D57, 0x6660DC53, 0x63309B4D, 0xD42D5A49, 0x0D0B1944, 0xBA16D840, 0x97C6A5AC, 0x20DB64A8, 0xF9FD27A5, 0x4EE0E6A1, 0x4BB0A1BF, 0xFCAD60BB, 0x258B23B6, 0x9296E2B2, 0x2F2BAD8A, 0x98366C8E, 0x41102F83, 0xF60DEE87, 0xF35DA999, 0x4440689D, 0x9D662B90, 0x2A7BEA94, 0xE71DB4E0, 0x500075E4, 0x892636E9, 0x3E3BF7ED, 0x3B6BB0F3, 0x8C7671F7, 0x555032FA, 0xE24DF3FE, 0x5FF0BCC6, 0xE8ED7DC2, 0x31CB3ECF, 0x86D6FFCB, 0x8386B8D5, 0x349B79D1, 0xEDBD3ADC, 0x5AA0FBD8, 0xEEE00C69, 0x59FDCD6D, 0x80DB8E60, 0x37C64F64, 0x3296087A, 0x858BC97E, 0x5CAD8A73, 0xEBB04B77, 0x560D044F, 0xE110C54B, 0x38368646, 0x8F2B4742, 0x8A7B005C, 0x3D66C158, 0xE4408255, 0x535D4351, 0x9E3B1D25, 0x2926DC21, 0xF0009F2C, 0x471D5E28, 0x424D1936, 0xF550D832, 0x2C769B3F, 0x9B6B5A3B, 0x26D61503, 0x91CBD407, 0x48ED970A, 0xFFF0560E, 0xFAA01110, 0x4DBDD014, 0x949B9319, 0x2386521D, 0x0E562FF1, 0xB94BEEF5, 0x606DADF8, 0xD7706CFC, 0xD2202BE2, 0x653DEAE6, 0xBC1BA9EB, 0x0B0668EF, 0xB6BB27D7, 0x01A6E6D3, 0xD880A5DE, 0x6F9D64DA, 0x6ACD23C4, 0xDDD0E2C0, 0x04F6A1CD, 0xB3EB60C9, 0x7E8D3EBD, 0xC990FFB9, 0x10B6BCB4, 0xA7AB7DB0, 0xA2FB3AAE, 0x15E6FBAA, 0xCCC0B8A7, 0x7BDD79A3, 0xC660369B, 0x717DF79F, 0xA85BB492, 0x1F467596, 0x1A163288, 0xAD0BF38C, 0x742DB081, 0xC3307185, 0x99908A5D, 0x2E8D4B59, 0xF7AB0854, 0x40B6C950, 0x45E68E4E, 0xF2FB4F4A, 0x2BDD0C47, 0x9CC0CD43, 0x217D827B, 0x9660437F, 0x4F460072, 0xF85BC176, 0xFD0B8668, 0x4A16476C, 0x93300461, 0x242DC565, 0xE94B9B11, 0x5E565A15, 0x87701918, 0x306DD81C, 0x353D9F02, 0x82205E06, 0x5B061D0B, 0xEC1BDC0F, 0x51A69337, 0xE6BB5233, 0x3F9D113E, 0x8880D03A, 0x8DD09724, 0x3ACD5620, 0xE3EB152D, 0x54F6D429, 0x7926A9C5, 0xCE3B68C1, 0x171D2BCC, 0xA000EAC8, 0xA550ADD6, 0x124D6CD2, 0xCB6B2FDF, 0x7C76EEDB, 0xC1CBA1E3, 0x76D660E7, 0xAFF023EA, 0x18EDE2EE, 0x1DBDA5F0, 0xAAA064F4, 0x738627F9, 0xC49BE6FD, 0x09FDB889, 0xBEE0798D, 0x67C63A80, 0xD0DBFB84, 0xD58BBC9A, 0x62967D9E, 0xBBB03E93, 0x0CADFF97, 0xB110B0AF, 0x060D71AB, 0xDF2B32A6, 0x6836F3A2, 0x6D66B4BC, 0xDA7B75B8, 0x035D36B5, 0xB440F7B1, } func CalcCrc32(crc uint32, buffer []byte) uint32 { var i int = 0 for i = 0; i < len(buffer); i++ { crc = crc32table[(crc^uint32(buffer[i]))&0xff] ^ (crc >> 8) } return crc } type StartCodeType int const ( StartCode3 StartCodeType = 3 STartCode4 StartCodeType = 4 ) func FindStartCode(nalu []byte, offset int) (int, StartCodeType) { idx := bytes.Index(nalu[offset:], []byte{0x00, 0x00, 0x01}) switch { case idx > 0: if nalu[offset+idx-1] == 0x00 { return offset + idx - 1, STartCode4 } fallthrough case idx == 0: return offset + idx, StartCode3 } return -1, StartCode3 } func SplitFrame(frames []byte, onFrame func(nalu []byte) bool) { beg, sc := FindStartCode(frames, 0) for beg >= 0 { end, sc2 := FindStartCode(frames, beg+int(sc)) if end == -1 { if onFrame != nil { onFrame(frames[beg+int(sc):]) } break } if onFrame != nil && onFrame(frames[beg+int(sc):end]) == false { break } beg = end sc = sc2 } } func H264NaluType(h264 []byte) uint8 { loc, sc := FindStartCode(h264, 0) return h264[loc+int(sc)] & 0x1F } func H265NaluType(h265 []byte) uint8 { loc, sc := FindStartCode(h265, 0) return (h265[loc+int(sc)] >> 1) & 0x3F } func mpegH264FindNALU(data []byte) (int, int, error) { var zeros, i int for i = 0; i+2 < len(data); i++ { if data[i] == 0x01 && zeros >= 2 { return i + 1, zeros + 1, nil // 返回 NALU 的长度和前导零的数量 } if data[i] == 0 { zeros++ } else { zeros = 0 } } return -1, 0, errors.New("no valid NALU found") } // 来自media-server func mpegH26xVerify(data []byte) (int, error) { h264Flags := uint32(0x01A0) // SPS/PPS/IDR h265Flags := uint64(0x700000000) // VPS/SPS/PPS h266Flags := uint32(0xC000) // VPS/SPS/PPS count := 0 h26x := [5][10]int{} p := 0 end := len(data) for p < end && count < len(h26x[0]) { n, _, err := mpegH264FindNALU(data[p:]) if err != nil { break } if p+n+1 > end { break } h26x[0][count] = int(data[p+n]) & 0x1F // H.264 NALU type h26x[1][count] = (int(data[p+n]) >> 1) & 0x3F // H.265 NALU type h26x[2][count] = (int(data[p+n+1]) >> 3) & 0x1F // H.266 NALU type h26x[3][count] = int(data[p+n]) // MPEG-4 VOP start code h26x[4][count] = int(data[p+n+1]) // MPEG-4 VOP coding type count++ p += n // 移动到下一个 NALU } for n := 0; n < count; n++ { h264Flags &= ^(1 << h26x[0][n]) h265Flags &= ^(1 << h26x[1][n]) h266Flags &= ^(1 << h26x[2][n]) } if h264Flags == 0 && h265Flags != 0 && h266Flags != 0 { // match SPS/PPS/IDR return CodecH264, nil } else if h265Flags == 0 && h264Flags != 0 && h266Flags != 0 { // match VPS/SPS/PPS return CodecH265, nil } else if h266Flags == 0 && h264Flags != 0 && h265Flags != 0 { // match SPS/PPS return CodecH266, nil } else if h26x[3][0] == 0xB0 && (h26x[4][0]&0x30) == 0 { // match VOP start code return CodecMpeg4, nil } return CodecUnknown, nil } func audioVerify(data []byte) PsStreamType { if data[0] == 0xFF && (data[1]&0xF0) == 0xF0 && len(data) > 7 { aacLen := ((int(data[3]) & 0x03) << 11) | (int(data[4]) << 3) | (int(data[5]) >> 5 & 0x07) if len(data) == aacLen { return PsStreamAac } } return PsStreamG711A } ================================================ FILE: gb28181/ptz.go ================================================ package gb28181 import ( "encoding/hex" "encoding/xml" ) type MessagePtz struct { XMLName xml.Name `xml:"Control"` CmdType string `xml:"CmdType"` SN int `xml:"SN"` DeviceID string `xml:"DeviceID"` PTZCmd string `xml:"PTZCmd"` } const DeviceControl = "DeviceControl" const PTZFirstByte = 0xA5 const ( PresetSet = 0x81 PresetCall = 0x82 PresetDel = 0x83 ) const ( CruiseAdd = 0x84 CruiseDel = 0x85 CruiseSetSpeed = 0x86 CruiseStopTime = 0x87 CruiseStart = 0x88 ) const ( ScanningStart = 0x89 ScanningSpeed = 0x8A ) /* 表 A.3 指令格式 字节 字节1 字节2 字节3 字节4 字节5 字节6 字节7 字节8 含义 A5H 组合码1 地址 指令 数据1 数据2 组合码2 校验码 各字节定义如下: 字节1: 指令的首字节为 A5H。 字节2: 组合码1, 高4 位是版本信息, 低4 位是校验位。 本标准的版本号是1.0, 版本信息为0H。 校验位= (字节1 的高4 位+ 字节1 的低4 位+ 字节2 的高4 位) %16。 字节3: 地址的低8 位。 字节4: 指令码。 字节5、6: 数据1 和数据2。 字节7: 组合码2, 高4 位是数据3, 低4 位是地址的高4 位; 在后续叙述中, 没有特别指明的高4 位, 表示该4 位与所指定的功能无关。 字节8: 校验码, 为前面的第1~7 字节的算术和的低8 位, 即算术和对256 取模后的结果。 字节8= (字节1+ 字节2+ 字节3+ 字节4+ 字节5+ 字节6+ 字节7) %256。 地址范围000H~FFFH(即0~4095) , 其中000H 地址作为广播地址。 注: 前端设备控制中, 不使用字节3 和字节7 的低4 位地址码, 使用前端设备控制消息体中的 统一编码 标识控制的前端设备 */ type PtzHead struct { FirstByte uint8 AssembleByte uint8 Addr uint8 //低地址码0-ff } // 获取组合码 func getAssembleCode() uint8 { return (PTZFirstByte>>4 + PTZFirstByte&0xF + 0) % 16 } func getVerificationCode(ptz []byte) { sum := uint8(0) for i := 0; i < len(ptz)-1; i++ { sum += ptz[i] } ptz[len(ptz)-1] = sum } /* 注1 : 字节4 中的 Bit5、Bit4 分别控制镜头变倍的缩小和放大, 字节4 中的 Bit3、Bit2、Bit1、Bit0 位分别控制云台 上、 下、 左、 右方向的转动, 相应 Bit 位置1 时, 启动云台向相应方向转动, 相应 Bit 位清0 时, 停止云台相应 方向的转动。 云台的转动方向以监视器显示图像的移动方向为准。 注2: 字节5 控制水平方向速度, 速度范围由慢到快为00H~FFH; 字节6 控制垂直方向速度, 速度范围由慢到快 为00H-FFH。 注3: 字节7 的高4 位为变焦速度, 速度范围由慢到快为0H~FH; 低4 位为地址的高4 位。 */ type Ptz struct { ZoomOut bool ZoomIn bool Up bool Down bool Left bool Right bool Speed byte //0-8 } func (p *Ptz) Pack() string { buf := make([]byte, 8) buf[0] = PTZFirstByte buf[1] = getAssembleCode() buf[2] = 1 buf[4] = 0 buf[5] = 0 buf[6] = 0 if p.ZoomOut { buf[3] |= 1 << 5 buf[6] = p.Speed << 4 } if p.ZoomIn { buf[3] |= 1 << 4 buf[6] = p.Speed << 4 } if p.Up { buf[3] |= 1 << 3 buf[5] = p.Speed } if p.Down { buf[3] |= 1 << 2 buf[5] = p.Speed } if p.Left { buf[3] |= 1 << 1 buf[4] = p.Speed } if p.Right { buf[3] |= 1 buf[4] = p.Speed } getVerificationCode(buf) return hex.EncodeToString(buf) } func (p *Ptz) Stop() string { buf := make([]byte, 8) buf[0] = PTZFirstByte buf[1] = getAssembleCode() buf[2] = 1 buf[3] = 0 buf[4] = 0 buf[5] = 0 buf[6] = 0 getVerificationCode(buf) return hex.EncodeToString(buf) } /* 注1 : 字节4 中的 Bit3 为1 时, 光圈缩小;Bit2 为1 时, 光圈放大。 Bit1 为1 时, 聚焦近;Bit0 为1 时, 聚焦远。 Bit3~ Bit0 的相应位清0, 则相应控制操作停止动作。 注2: 字节5 表示聚焦速度, 速度范围由慢到快为00H~FFH。 注3: 字节6 表示光圈速度, 速度范围由慢到快为00H~FFH */ type Fi struct { IrisIn bool IrisOut bool FocusNear bool FocusFar bool Speed byte //0-8 } func (f *Fi) Pack() string { buf := make([]byte, 8) buf[0] = PTZFirstByte buf[1] = getAssembleCode() buf[2] = 1 buf[3] |= 1 << 6 buf[4] = 0 buf[5] = 0 buf[6] = 0 if f.IrisIn { buf[3] |= 1 << 3 buf[5] = f.Speed } if f.IrisOut { buf[3] |= 1 << 2 buf[5] = f.Speed } if f.FocusNear { buf[3] |= 1 << 1 buf[4] = f.Speed } if f.FocusFar { buf[3] |= 1 buf[4] = f.Speed } getVerificationCode(buf) return hex.EncodeToString(buf) } type Preset struct { CMD byte Point byte } func (p *Preset) Pack() string { buf := make([]byte, 8) buf[0] = PTZFirstByte buf[1] = getAssembleCode() buf[2] = 1 buf[3] = p.CMD buf[4] = 0 buf[5] = p.Point buf[6] = 0 getVerificationCode(buf) return hex.EncodeToString(buf) } /* 注1 : 字节5 表示巡航组号, 字节6 表示预置位号。 注2: 序号2 中, 字节6 为00H 时, 删除对应的整条巡航; 序号3、4 中字节6 表示数据的低8 位, 字节7 的高4 位 表示数据的高4 位。 注3: 巡航停留时间的单位是秒(s) 。 注4: 停止巡航用 PTZ 指令中的字节4 的各 Bit 位均为0 的停止指令。 */ type Cruise struct { CMD byte GroupNum byte Value uint16 } func (c *Cruise) Pack() string { buf := make([]byte, 8) buf[0] = PTZFirstByte buf[1] = getAssembleCode() buf[2] = 1 buf[3] = c.CMD buf[4] = c.GroupNum buf[5] = byte(c.Value & 0xFF) buf[6] = byte(c.Value>>8) & 0x0F getVerificationCode(buf) return hex.EncodeToString(buf) } /* 注1 : 字节5 表示扫描组号。 注2: 序号4 中, 字节6 表示数据的低8 位, 字节7 的高4 位表示数据的高4 位。 注3: 停止自动扫描用 PTZ 指令中的字节4 的各 Bit 位均为0 的停止指令。 注4: 自动扫描开始时, 整体画面从右向左移动。 */ type Scanning struct { CMD byte No byte Value byte HighAddr byte // 0-f 后4位高地址码 0-f } ================================================ FILE: gb28181/rtppub/manager.go ================================================ package rtppub import ( "errors" "fmt" "net" "sync" "time" udpTransport "github.com/pion/transport/v3/udp" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/lalmax/config" "github.com/q191201771/lalmax/gb28181/mediaserver" "github.com/q191201771/naza/pkg/nazalog" ) const ( defaultPortMin = 30000 defaultPortMaxIncrement = 3000 ) var ( errDuplicateStream = errors.New("rtp pub stream already exists") errSessionNotFound = errors.New("rtp pub session not found") ) type Manager struct { mu sync.Mutex lalServer logic.ILalServer portMin int portMax int sessionsByID map[string]*Session sessionsByStream map[string]*Session sessionsByKey map[string]*Session } type Session struct { ID string StreamName string MediaKey string Network string Port int mediaInfo mediaserver.MediaInfo server *mediaserver.GB28181MediaServer lastActive time.Time done chan struct{} closeOnce sync.Once } func NewManager(lalServer logic.ILalServer, mediaConfig config.GB28181MediaConfig) *Manager { basePort := int(mediaConfig.ListenPort) if basePort == 0 { basePort = defaultPortMin } maxIncrement := mediaConfig.MultiPortMaxIncrement if maxIncrement == 0 { maxIncrement = defaultPortMaxIncrement } portMin := basePort if mediaConfig.ListenPort != 0 { portMin++ } return &Manager{ lalServer: lalServer, portMin: portMin, portMax: basePort + int(maxIncrement), sessionsByID: make(map[string]*Session), sessionsByStream: make(map[string]*Session), sessionsByKey: make(map[string]*Session), } } func (m *Manager) Start(req base.ApiCtrlStartRtpPubReq) (ret base.ApiCtrlStartRtpPubResp) { if req.StreamName == "" { ret.ErrorCode = base.ErrorCodeParamMissing ret.Desp = base.DespParamMissing return } network := "udp" if req.IsTcpFlag != 0 { network = "tcp" } m.mu.Lock() if _, ok := m.sessionsByStream[req.StreamName]; ok { m.mu.Unlock() ret.ErrorCode = base.ErrorCodeListenUdpPortFail ret.Desp = errDuplicateStream.Error() return } m.mu.Unlock() listener, port, err := m.listen(req.Port, network) if err != nil { ret.ErrorCode = base.ErrorCodeListenUdpPortFail ret.Desp = err.Error() return } sessionID := base.GenUkPsPubSession() mediaKey := fmt.Sprintf("%s%d", network, port) session := &Session{ ID: sessionID, StreamName: req.StreamName, MediaKey: mediaKey, Network: network, Port: port, mediaInfo: mediaserver.MediaInfo{ StreamName: req.StreamName, DumpFileName: req.DebugDumpPacket, MediaKey: mediaKey, }, lastActive: time.Now(), done: make(chan struct{}), } readTimeout := time.Duration(req.TimeoutMs) * time.Millisecond session.server = mediaserver.NewGB28181MediaServer(port, mediaKey, m, m.lalServer). WithPreferMediaKeyLookup(true). WithReadTimeout(readTimeout) m.mu.Lock() if _, ok := m.sessionsByStream[req.StreamName]; ok { m.mu.Unlock() _ = listener.Close() ret.ErrorCode = base.ErrorCodeListenUdpPortFail ret.Desp = errDuplicateStream.Error() return } m.sessionsByID[session.ID] = session m.sessionsByStream[session.StreamName] = session m.sessionsByKey[session.MediaKey] = session m.mu.Unlock() if err = session.server.Start(listener); err != nil { m.stopSession(session) ret.ErrorCode = base.ErrorCodeListenUdpPortFail ret.Desp = err.Error() return } if req.TimeoutMs > 0 { go m.watchTimeout(session, time.Duration(req.TimeoutMs)*time.Millisecond) } ret.ErrorCode = base.ErrorCodeSucc ret.Desp = base.DespSucc ret.Data.SessionId = session.ID ret.Data.StreamName = session.StreamName ret.Data.Port = session.Port return } func (m *Manager) Stop(streamName, sessionID string) (*Session, error) { m.mu.Lock() var session *Session if sessionID != "" { session = m.sessionsByID[sessionID] } else if streamName != "" { session = m.sessionsByStream[streamName] } m.mu.Unlock() if session == nil { return nil, errSessionNotFound } m.stopSession(session) return session, nil } func (m *Manager) GetMediaInfoByKey(key string) (*mediaserver.MediaInfo, bool) { m.mu.Lock() defer m.mu.Unlock() session, ok := m.sessionsByKey[key] if !ok { return nil, false } return &session.mediaInfo, true } func (m *Manager) CheckSsrc(ssrc uint32) (*mediaserver.MediaInfo, bool) { return nil, false } func (m *Manager) NotifyClose(streamName string) { } // UpdatePortRange 动态更新端口范围,由 setServerConfig 接口调用 // 为什么:owl 通过 setServerConfig 下发 rtp_proxy.port_range,需运行时生效 func (m *Manager) UpdatePortRange(portMin, portMax int) { m.mu.Lock() defer m.mu.Unlock() m.portMin = portMin m.portMax = portMax nazalog.Infof("rtp pub port range updated. min=%d, max=%d", portMin, portMax) } func (m *Manager) OnRtpPacket(streamName string, mediaKey string) { m.mu.Lock() defer m.mu.Unlock() if session, ok := m.sessionsByKey[mediaKey]; ok { session.lastActive = time.Now() } } func (m *Manager) stopSession(session *Session) { m.mu.Lock() if current := m.sessionsByID[session.ID]; current != session { m.mu.Unlock() return } delete(m.sessionsByID, session.ID) delete(m.sessionsByStream, session.StreamName) delete(m.sessionsByKey, session.MediaKey) session.closeOnce.Do(func() { close(session.done) }) m.mu.Unlock() session.server.Dispose() } func (m *Manager) watchTimeout(session *Session, timeout time.Duration) { interval := timeout / 2 if interval < time.Second { interval = time.Second } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-session.done: return case <-ticker.C: m.mu.Lock() current := m.sessionsByID[session.ID] expired := current == session && time.Since(session.lastActive) >= timeout m.mu.Unlock() if expired { nazalog.Warnf("rtp pub timeout, streamName:%s, sessionId:%s", session.StreamName, session.ID) m.stopSession(session) return } } } } func (m *Manager) listen(port int, network string) (net.Listener, int, error) { if port > 0 { listener, err := listenPort(port, network) return listener, port, err } var lastErr error for p := m.portMin; p <= m.portMax; p++ { listener, err := listenPort(p, network) if err == nil { return listener, p, nil } lastErr = err } if lastErr == nil { lastErr = fmt.Errorf("no available %s port in range [%d,%d]", network, m.portMin, m.portMax) } return nil, 0, lastErr } func listenPort(port int, network string) (net.Listener, error) { addr := fmt.Sprintf(":%d", port) if network == "tcp" { return net.Listen("tcp", addr) } udpAddr, err := net.ResolveUDPAddr("udp", addr) if err != nil { return nil, err } return udpTransport.Listen("udp", udpAddr) } ================================================ FILE: gb28181/rtppub/manager_test.go ================================================ package rtppub import ( "errors" "net" "testing" "time" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lalmax/config" ) func freeTCPPort(t *testing.T) uint16 { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() return uint16(listener.Addr().(*net.TCPAddr).Port) } func newTestManager(t *testing.T) *Manager { t.Helper() return NewManager(nil, config.GB28181MediaConfig{}) } func TestManagerStartStopBySessionID(t *testing.T) { manager := newTestManager(t) resp := manager.Start(base.ApiCtrlStartRtpPubReq{ StreamName: "rtp-pub-start-stop", Port: int(freeTCPPort(t)), TimeoutMs: 0, IsTcpFlag: 1, }) if resp.ErrorCode != base.ErrorCodeSucc { t.Fatalf("start failed, code=%d desp=%s", resp.ErrorCode, resp.Desp) } if resp.Data.SessionId == "" || resp.Data.Port == 0 { t.Fatalf("unexpected start response: %+v", resp.Data) } session, err := manager.Stop("", resp.Data.SessionId) if err != nil { t.Fatal(err) } if session.ID != resp.Data.SessionId { t.Fatalf("stopped session id = %s, want %s", session.ID, resp.Data.SessionId) } if _, err = manager.Stop("", resp.Data.SessionId); !errors.Is(err, errSessionNotFound) { t.Fatalf("second stop err = %v, want %v", err, errSessionNotFound) } } func TestManagerRejectsDuplicateStream(t *testing.T) { manager := newTestManager(t) resp := manager.Start(base.ApiCtrlStartRtpPubReq{ StreamName: "rtp-pub-duplicate", Port: int(freeTCPPort(t)), TimeoutMs: 0, IsTcpFlag: 1, }) if resp.ErrorCode != base.ErrorCodeSucc { t.Fatalf("start failed, code=%d desp=%s", resp.ErrorCode, resp.Desp) } defer manager.Stop(resp.Data.StreamName, "") duplicate := manager.Start(base.ApiCtrlStartRtpPubReq{ StreamName: "rtp-pub-duplicate", Port: int(freeTCPPort(t)), TimeoutMs: 0, IsTcpFlag: 1, }) if duplicate.ErrorCode == base.ErrorCodeSucc { t.Fatalf("duplicate stream start unexpectedly succeeded: %+v", duplicate.Data) } } func TestManagerTimeoutRemovesIdleSession(t *testing.T) { manager := newTestManager(t) resp := manager.Start(base.ApiCtrlStartRtpPubReq{ StreamName: "rtp-pub-timeout", Port: int(freeTCPPort(t)), TimeoutMs: 10, IsTcpFlag: 1, }) if resp.ErrorCode != base.ErrorCodeSucc { t.Fatalf("start failed, code=%d desp=%s", resp.ErrorCode, resp.Desp) } defer manager.Stop(resp.Data.StreamName, "") deadline := time.After(2 * time.Second) ticker := time.NewTicker(20 * time.Millisecond) defer ticker.Stop() for { select { case <-deadline: t.Fatal("session was not removed after timeout") case <-ticker.C: manager.mu.Lock() _, ok := manager.sessionsByID[resp.Data.SessionId] manager.mu.Unlock() if !ok { return } } } } func TestNewManagerUsesConfiguredPortRangeAfterListenPort(t *testing.T) { manager := NewManager(nil, config.GB28181MediaConfig{ ListenPort: 31000, MultiPortMaxIncrement: 10, }) if manager.portMin != 31001 || manager.portMax != 31010 { t.Fatalf("port range = [%d,%d], want [31001,31010]", manager.portMin, manager.portMax) } } ================================================ FILE: gb28181/rtppush/lower_push_session.go ================================================ package rtppush import ( "fmt" "net" "sync" "time" "github.com/q191201771/lal/pkg/aac" "github.com/q191201771/lal/pkg/avc" lalbase "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/hevc" "github.com/q191201771/lal/pkg/rtprtcp" "github.com/q191201771/lalmax/gb28181/mpegps" "github.com/q191201771/naza/pkg/nazalog" "github.com/q191201771/naza/pkg/unique" ) const ( lowerPushNetworkUDP = "udp" lowerPushNetworkTCP = "tcp" // 单个 RTP 包承载的 PS 数据最大长度,避免 UDP 包过大导致分片。 lowerPushRtpPacketMax = 1400 // 启动阶段待处理 RTMP 消息的最大缓存数量,不是持续发送队列长度。 lowerPushQueueMax = 256 ) var lowerPushUnique = unique.NewSingleGenerator("GBLOWERPUSH") // LowerPushSession 表示 lalmax 作为 GB28181 下级平台, // 主动向上级平台推送 RTP 媒体流。 // 这里只支持主动 UDP/TCP 模式。 type LowerPushSession struct { uniqueKey string streamName string network string localIP string localPort int peerIP string peerPort int udpConn net.Conn tcpConn net.Conn log nazalog.Logger disposeOnce sync.Once writeBytes uint64 psMuxer *mpegps.PsMuxer ssrc uint32 seq uint16 videoID uint8 audioID uint8 videoHeader *lalbase.RtmpMsg audioHeader *lalbase.RtmpMsg ascCtx *aac.AscContext videoCodec []byte pending []lalbase.RtmpMsg ready bool onlyAudio bool waitKeyFrame bool eraseSei bool } // NewLowerPushSession 创建下级平台推流会话,并绑定 PS 到 RTP 的发送回调。 func NewLowerPushSession() *LowerPushSession { s := &LowerPushSession{ uniqueKey: lowerPushUnique.GenUniqueKey(), log: nazalog.GetGlobalLogger(), eraseSei: true, } s.log = s.log.WithPrefix(s.uniqueKey) s.psMuxer = mpegps.NewPsMuxer() s.psMuxer.OnPacket = func(pkg []byte, pts uint64) { for _, pkt := range s.packRtp(pkg, uint32(pts)) { if err := s.WriteRtpPacket(pkt); err != nil { s.log.Warnf("gb28181 lower push write rtp failed. err=%v", err) return } } } return s } // WithStreamName 设置业务流名称,便于日志和外部管理。 func (s *LowerPushSession) WithStreamName(streamName string) *LowerPushSession { s.streamName = streamName return s } // WithLogPrefix 设置日志前缀,用于区分不同推流会话。 func (s *LowerPushSession) WithLogPrefix(prefix string) *LowerPushSession { s.log = s.log.WithPrefix(prefix) return s } // SetLocalIP 设置本地绑定 IP,为空时由系统自动选择。 func (s *LowerPushSession) SetLocalIP(localIP string) { s.localIP = localIP } // SetLocalPort 设置本地绑定端口,为 0 时由系统自动分配。 func (s *LowerPushSession) SetLocalPort(localPort int) { s.localPort = localPort } // SetPeerIP 设置上级平台接收 RTP 的 IP。 func (s *LowerPushSession) SetPeerIP(peerIP string) { s.peerIP = peerIP } // SetPeerPort 设置上级平台接收 RTP 的端口。 func (s *LowerPushSession) SetPeerPort(peerPort int) { s.peerPort = peerPort } // SetSsrc 设置 RTP 包中的 SSRC。 func (s *LowerPushSession) SetSsrc(ssrc uint32) { s.ssrc = ssrc } // Start 按指定网络类型启动推流连接,当前支持 UDP 和 TCP 主动连接。 func (s *LowerPushSession) Start(network string) error { switch network { case lowerPushNetworkUDP: return s.startUDP() case lowerPushNetworkTCP: return s.startTCP() default: return fmt.Errorf("gb28181 lower push invalid network: %s", network) } } // startUDP 建立到上级平台的 UDP 连接。 func (s *LowerPushSession) startUDP() error { laddr := &net.UDPAddr{Port: s.localPort} if s.localIP != "" { laddr.IP = net.ParseIP(s.localIP) } raddr := &net.UDPAddr{ IP: net.ParseIP(s.peerIP), Port: s.peerPort, } if raddr.IP == nil || raddr.Port == 0 { return fmt.Errorf("gb28181 lower push invalid udp peer addr: %s:%d", s.peerIP, s.peerPort) } conn, err := net.DialUDP(lowerPushNetworkUDP, laddr, raddr) if err != nil { return err } s.network = lowerPushNetworkUDP s.udpConn = conn s.log.Infof("gb28181 lower push udp ready. local=%s remote=%s", conn.LocalAddr(), conn.RemoteAddr()) return nil } // startTCP 建立到上级平台的 TCP 连接。 func (s *LowerPushSession) startTCP() error { localAddr := &net.TCPAddr{Port: s.localPort} if s.localIP != "" { localAddr.IP = net.ParseIP(s.localIP) } dialer := &net.Dialer{ LocalAddr: localAddr, Timeout: 3 * time.Second, } conn, err := dialer.Dial(lowerPushNetworkTCP, net.JoinHostPort(s.peerIP, fmt.Sprintf("%d", s.peerPort))) if err != nil { return err } s.network = lowerPushNetworkTCP s.tcpConn = conn s.log.Infof("gb28181 lower push tcp ready. local=%s remote=%s", conn.LocalAddr(), conn.RemoteAddr()) return nil } // OnMsg 接收 RTMP 消息,完成启动阶段缓存、音视频头解析和后续 PS/RTP 推送。 func (s *LowerPushSession) OnMsg(msg lalbase.RtmpMsg) { if s.consumeControlMsg(msg) { if !s.ready && s.shouldDrain(msg) { s.drain() } return } if s.ready { if err := s.feedRtmpMsg(msg); err != nil { s.log.Warnf("gb28181 lower push feed msg failed. err=%v, msg=%s", err, msg.DebugString()) } return } s.pending = append(s.pending, msg.Clone()) if s.shouldDrain(msg) || len(s.pending) >= lowerPushQueueMax { s.drain() } } // OnStop 在上游流停止时释放推流连接。 func (s *LowerPushSession) OnStop() { _ = s.Dispose() } // WriteRtpPacket 按当前网络类型发送已经封装好的 RTP 包。 func (s *LowerPushSession) WriteRtpPacket(pkt rtprtcp.RtpPacket) error { if s.network == "" { return lalbase.ErrSessionNotStarted } switch s.network { case lowerPushNetworkUDP: return s.writeUDP(pkt.Raw) case lowerPushNetworkTCP: return s.writeTCP(pkt.Raw) default: return fmt.Errorf("gb28181 lower push invalid network state: %s", s.network) } } // WriteRtpPsPacket 写入单个 RTP/PS 包。 // 在 TCP 模式下,既支持原始 RTP 负载,也支持带 2 字节长度前缀的 RTP 包。 // 在 UDP 模式下,只发送 RTP 负载本身。 func (s *LowerPushSession) WriteRtpPsPacket(buf []byte) error { if s.network == "" { return lalbase.ErrSessionNotStarted } payload := buf if len(buf) >= 2 && len(buf) == int(uint16(buf[0])<<8|uint16(buf[1]))+2 { payload = buf[2:] } switch s.network { case lowerPushNetworkUDP: return s.writeUDP(payload) case lowerPushNetworkTCP: return s.writeTCP(payload) default: return fmt.Errorf("gb28181 lower push invalid network state: %s", s.network) } } // writeUDP 通过 UDP 直接发送 RTP 负载。 func (s *LowerPushSession) writeUDP(payload []byte) error { if s.udpConn == nil { return lalbase.ErrSessionNotStarted } n, err := s.udpConn.Write(payload) s.writeBytes += uint64(n) return err } // writeTCP 按 GB28181 TCP 传输格式添加 2 字节长度前缀后发送 RTP 负载。 func (s *LowerPushSession) writeTCP(payload []byte) error { if s.tcpConn == nil { return lalbase.ErrSessionNotStarted } header := []byte{byte(len(payload) >> 8), byte(len(payload))} n, err := s.tcpConn.Write(append(header, payload...)) s.writeBytes += uint64(n) return err } // Dispose 关闭底层连接,重复调用是安全的。 func (s *LowerPushSession) Dispose() error { var retErr error s.disposeOnce.Do(func() { if s.udpConn != nil { retErr = s.udpConn.Close() s.udpConn = nil } if s.tcpConn != nil { if err := s.tcpConn.Close(); retErr == nil { retErr = err } s.tcpConn = nil } }) return retErr } // UniqueKey 返回当前推流会话的唯一标识。 func (s *LowerPushSession) UniqueKey() string { return s.uniqueKey } // StreamName 返回业务流名称,未设置时使用会话唯一标识。 func (s *LowerPushSession) StreamName() string { if s.streamName == "" { return s.uniqueKey } return s.streamName } // LocalAddr 返回当前连接的本地地址。 func (s *LowerPushSession) LocalAddr() net.Addr { if s.udpConn != nil { return s.udpConn.LocalAddr() } if s.tcpConn != nil { return s.tcpConn.LocalAddr() } return nil } // RemoteAddr 返回当前连接的远端地址。 func (s *LowerPushSession) RemoteAddr() net.Addr { if s.udpConn != nil { return s.udpConn.RemoteAddr() } if s.tcpConn != nil { return s.tcpConn.RemoteAddr() } return nil } // consumeControlMsg 处理元数据、音视频序列头和不支持的消息,返回 true 表示不再进入媒体发送流程。 func (s *LowerPushSession) consumeControlMsg(msg lalbase.RtmpMsg) bool { switch msg.Header.MsgTypeId { case lalbase.RtmpTypeIdMetadata: return true case lalbase.RtmpTypeIdVideo: if len(msg.Payload) < 2 { return true } if msg.IsVideoKeySeqHeader() { if err := s.updateVideoHeader(msg); err != nil { s.log.Warnf("gb28181 lower push parse video seq header failed. err=%v", err) } return true } if msg.IsEnhanced() && !msg.IsEnchanedHevcNalu() { return true } case lalbase.RtmpTypeIdAudio: if len(msg.Payload) < 1 { return true } switch msg.AudioCodecId() { case lalbase.RtmpSoundFormatAac: if len(msg.Payload) < 2 { return true } if msg.IsAacSeqHeader() { if err := s.updateAacHeader(msg); err != nil { s.log.Warnf("gb28181 lower push parse aac seq header failed. err=%v", err) } return true } case lalbase.RtmpSoundFormatG711A: if s.audioID == 0 { s.audioID = s.psMuxer.AddStream(mpegps.PsStreamG711A) } if s.audioHeader == nil { cloned := msg.Clone() s.audioHeader = &cloned } case lalbase.RtmpSoundFormatG711U: if s.audioID == 0 { s.audioID = s.psMuxer.AddStream(mpegps.PsStreamG711U) } if s.audioHeader == nil { cloned := msg.Clone() s.audioHeader = &cloned } case lalbase.RtmpSoundFormatOpus: return true } } return false } // updateVideoHeader 解析并缓存 H264/H265 序列头,同时注册对应的 PS 视频流。 func (s *LowerPushSession) updateVideoHeader(msg lalbase.RtmpMsg) error { if msg.IsAvcKeySeqHeader() { if s.videoID == 0 { s.videoID = s.psMuxer.AddStream(mpegps.PsStreamH264) } codec, err := avc.SpsPpsSeqHeader2Annexb(msg.Payload) if err != nil { return err } s.videoCodec = append(s.videoCodec[:0], codec...) } else if msg.IsHevcKeySeqHeader() { if s.videoID == 0 { s.videoID = s.psMuxer.AddStream(mpegps.PsStreamH265) } var ( codec []byte err error ) if msg.IsEnhanced() { codec, err = hevc.VpsSpsPpsEnhancedSeqHeader2Annexb(msg.Payload) } else { codec, err = hevc.VpsSpsPpsSeqHeader2Annexb(msg.Payload) } if err != nil { return err } s.videoCodec = append(s.videoCodec[:0], codec...) } cloned := msg.Clone() s.videoHeader = &cloned return nil } // updateAacHeader 解析并缓存 AAC 序列头,同时注册对应的 PS 音频流。 func (s *LowerPushSession) updateAacHeader(msg lalbase.RtmpMsg) error { if s.audioID == 0 { s.audioID = s.psMuxer.AddStream(mpegps.PsStreamAac) } ascCtx, err := aac.NewAscContext(msg.Payload[2:]) if err != nil { return err } s.ascCtx = ascCtx cloned := msg.Clone() s.audioHeader = &cloned return nil } // shouldDrain 判断启动阶段缓存是否已经满足发送条件。 func (s *LowerPushSession) shouldDrain(msg lalbase.RtmpMsg) bool { if s.videoHeader != nil && s.audioHeader != nil { return true } if s.videoHeader != nil && msg.Header.MsgTypeId == lalbase.RtmpTypeIdVideo && !msg.IsVideoKeySeqHeader() { return true } if s.videoHeader == nil && s.audioHeader != nil && msg.Header.MsgTypeId == lalbase.RtmpTypeIdAudio { if len(msg.Payload) == 0 { return false } if msg.AudioCodecId() == lalbase.RtmpSoundFormatAac { return !msg.IsAacSeqHeader() } return true } return false } // drain 结束启动阶段缓存,将已缓存消息按顺序送入打包流程。 func (s *LowerPushSession) drain() { if s.ready { return } s.ready = true s.onlyAudio = s.videoHeader == nil && s.audioHeader != nil s.waitKeyFrame = s.videoHeader != nil for i := range s.pending { if err := s.feedRtmpMsg(s.pending[i]); err != nil { s.log.Warnf("gb28181 lower push drain msg failed. err=%v, msg=%s", err, s.pending[i].DebugString()) } } s.pending = nil } // feedRtmpMsg 按消息类型分发音频或视频数据。 func (s *LowerPushSession) feedRtmpMsg(msg lalbase.RtmpMsg) error { switch msg.Header.MsgTypeId { case lalbase.RtmpTypeIdVideo: if s.onlyAudio { return nil } return s.feedVideo(msg) case lalbase.RtmpTypeIdAudio: return s.feedAudio(msg) default: return nil } } // feedVideo 将 RTMP 视频帧转换为 Annex-B 格式,并写入 PS 复用器。 func (s *LowerPushSession) feedVideo(msg lalbase.RtmpMsg) error { startIndex := 5 if msg.IsEnchanedHevcNalu() { startIndex = msg.GetEnchanedHevcNaluIndex() } if len(msg.Payload) <= startIndex { return nil } var ( buf []byte appendCodec bool sps []byte pps []byte vps []byte err error isH264 = msg.VideoCodecId() == lalbase.RtmpCodecIdAvc videoPayload = msg.Payload[startIndex:] appendStartCde = avc.NaluStartCode4 ) err = avc.IterateNaluAvcc(videoPayload, func(nal []byte) { if len(nal) == 0 { return } if isH264 { switch avc.ParseNaluType(nal[0]) { case avc.NaluTypeSps: sps = nal case avc.NaluTypePps: pps = nal if len(sps) != 0 { s.videoCodec = s.videoCodec[:0] s.videoCodec = append(s.videoCodec, appendStartCde...) s.videoCodec = append(s.videoCodec, sps...) s.videoCodec = append(s.videoCodec, appendStartCde...) s.videoCodec = append(s.videoCodec, pps...) } case avc.NaluTypeIdrSlice: if !appendCodec && len(s.videoCodec) != 0 { buf = append(buf, s.videoCodec...) appendCodec = true } buf = append(buf, appendStartCde...) buf = append(buf, nal...) s.waitKeyFrame = false case avc.NaluTypeSei: if !s.eraseSei { buf = append(buf, appendStartCde...) buf = append(buf, nal...) } default: if s.waitKeyFrame { return } buf = append(buf, appendStartCde...) buf = append(buf, nal...) } return } switch hevc.ParseNaluType(nal[0]) { case hevc.NaluTypeVps: vps = nal case hevc.NaluTypeSps: sps = nal case hevc.NaluTypePps: pps = nal if len(vps) != 0 && len(sps) != 0 { s.videoCodec = s.videoCodec[:0] s.videoCodec = append(s.videoCodec, appendStartCde...) s.videoCodec = append(s.videoCodec, vps...) s.videoCodec = append(s.videoCodec, appendStartCde...) s.videoCodec = append(s.videoCodec, sps...) s.videoCodec = append(s.videoCodec, appendStartCde...) s.videoCodec = append(s.videoCodec, pps...) } case hevc.NaluTypeSei, hevc.NaluTypeSeiSuffix: if !s.eraseSei { buf = append(buf, appendStartCde...) buf = append(buf, nal...) } default: if hevc.IsIrapNalu(hevc.ParseNaluType(nal[0])) { if !appendCodec && len(s.videoCodec) != 0 { buf = append(buf, s.videoCodec...) appendCodec = true } buf = append(buf, appendStartCde...) buf = append(buf, nal...) s.waitKeyFrame = false return } if s.waitKeyFrame { return } buf = append(buf, appendStartCde...) buf = append(buf, nal...) } }) if err != nil { return err } if len(buf) == 0 || s.videoID == 0 { return nil } return s.psMuxer.Write(s.videoID, buf, uint64(msg.Pts()), uint64(msg.Dts())) } // feedAudio 将 RTMP 音频帧转换为 PS 支持的音频负载,并写入 PS 复用器。 func (s *LowerPushSession) feedAudio(msg lalbase.RtmpMsg) error { if s.waitKeyFrame { return nil } if len(msg.Payload) == 0 { return nil } switch msg.AudioCodecId() { case lalbase.RtmpSoundFormatAac: if len(msg.Payload) <= 2 || s.ascCtx == nil || s.audioID == 0 { return nil } buf := s.ascCtx.PackAdtsHeader(len(msg.Payload) - 2) buf = append(buf, msg.Payload[2:]...) return s.psMuxer.Write(s.audioID, buf, uint64(msg.Dts()), uint64(msg.Dts())) case lalbase.RtmpSoundFormatG711A, lalbase.RtmpSoundFormatG711U: if len(msg.Payload) <= 1 || s.audioID == 0 { return nil } return s.psMuxer.Write(s.audioID, msg.Payload[1:], uint64(msg.Dts()), uint64(msg.Dts())) default: return nil } } // nextSeq 生成下一个 RTP 序列号。 func (s *LowerPushSession) nextSeq() uint16 { s.seq++ return s.seq } // packRtp 将 PS 数据按 RTP 最大负载长度切片,并生成 RTP 包。 func (s *LowerPushSession) packRtp(buf []byte, timestamp uint32) []rtprtcp.RtpPacket { var out []rtprtcp.RtpPacket for offset := 0; offset < len(buf); { size := len(buf) - offset mark := uint8(1) if size > lowerPushRtpPacketMax { size = lowerPushRtpPacketMax mark = 0 } h := rtprtcp.MakeDefaultRtpHeader() h.Mark = mark h.PacketType = uint8(lalbase.AvPacketPtAvc) h.Seq = s.nextSeq() h.Timestamp = timestamp h.Ssrc = s.ssrc out = append(out, rtprtcp.MakeRtpPacket(h, buf[offset:offset+size])) offset += size } return out } ================================================ FILE: gb28181/rtppush/lower_push_session_test.go ================================================ package rtppush import ( "io" "net" "testing" "time" "github.com/q191201771/lal/pkg/aac" "github.com/q191201771/lal/pkg/avc" lalbase "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/rtprtcp" ) func TestLowerPushSessionUDPWriteRtpPacket(t *testing.T) { ln, err := net.ListenPacket("udp", "127.0.0.1:0") if err != nil { t.Fatalf("listen udp: %v", err) } defer ln.Close() serverAddr := ln.LocalAddr().(*net.UDPAddr) got := make(chan []byte, 1) errCh := make(chan error, 1) go func() { buf := make([]byte, 1500) _ = ln.SetDeadline(time.Now().Add(3 * time.Second)) n, _, err := ln.ReadFrom(buf) if err != nil { errCh <- err return } got <- append([]byte(nil), buf[:n]...) }() session := NewLowerPushSession() session.SetPeerIP(serverAddr.IP.String()) session.SetPeerPort(serverAddr.Port) if err := session.Start("udp"); err != nil { t.Fatalf("start udp: %v", err) } defer session.Dispose() pkt := makeTestRtpPacket([]byte{0x11, 0x22, 0x33, 0x44}) if err := session.WriteRtpPacket(pkt); err != nil { t.Fatalf("write udp rtp: %v", err) } select { case err := <-errCh: t.Fatalf("udp read failed: %v", err) case b := <-got: if string(b) != string(pkt.Raw) { t.Fatalf("udp payload mismatch, got=%v want=%v", b, pkt.Raw) } case <-time.After(4 * time.Second): t.Fatal("udp read timeout") } } func TestLowerPushSessionTCPWriteRtpPsPacket(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen tcp: %v", err) } defer ln.Close() got := make(chan []byte, 1) errCh := make(chan error, 1) go func() { conn, err := ln.Accept() if err != nil { errCh <- err return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) buf := make([]byte, 6) if _, err := io.ReadFull(conn, buf); err != nil { errCh <- err return } got <- buf }() addr := ln.Addr().(*net.TCPAddr) session := NewLowerPushSession() session.SetPeerIP(addr.IP.String()) session.SetPeerPort(addr.Port) if err := session.Start("tcp"); err != nil { t.Fatalf("start tcp: %v", err) } defer session.Dispose() rawRTP := []byte{0x80, 0x60, 0x00, 0x01} if err := session.WriteRtpPsPacket(rawRTP); err != nil { t.Fatalf("write tcp rtp/ps: %v", err) } select { case err := <-errCh: t.Fatalf("tcp read failed: %v", err) case b := <-got: want := []byte{0x00, 0x04, 0x80, 0x60, 0x00, 0x01} if string(b) != string(want) { t.Fatalf("tcp payload mismatch, got=%v want=%v", b, want) } case <-time.After(4 * time.Second): t.Fatal("tcp read timeout") } } func TestLowerPushSessionWriteBeforeStart(t *testing.T) { session := NewLowerPushSession() err := session.WriteRtpPacket(makeTestRtpPacket([]byte{0x01})) if err != lalbase.ErrSessionNotStarted { t.Fatalf("unexpected error: %v", err) } } func TestLowerPushSessionOnMsgVideoUDP(t *testing.T) { ln, err := net.ListenPacket("udp", "127.0.0.1:0") if err != nil { t.Fatalf("listen udp: %v", err) } defer ln.Close() serverAddr := ln.LocalAddr().(*net.UDPAddr) got := make(chan []byte, 2) errCh := make(chan error, 1) go func() { for i := 0; i < 2; i++ { buf := make([]byte, 2048) _ = ln.SetDeadline(time.Now().Add(3 * time.Second)) n, _, err := ln.ReadFrom(buf) if err != nil { errCh <- err return } got <- append([]byte(nil), buf[:n]...) } }() session := NewLowerPushSession() session.SetPeerIP(serverAddr.IP.String()) session.SetPeerPort(serverAddr.Port) session.SetSsrc(0x11223344) if err := session.Start("udp"); err != nil { t.Fatalf("start udp: %v", err) } defer session.Dispose() session.OnMsg(makeAvcSeqHeaderMsg()) session.OnMsg(makeAacSeqHeaderMsg()) session.OnMsg(makeAvcKeyFrameMsg()) session.OnMsg(makeAacRawMsg()) var pkts []rtprtcp.RtpPacket deadline := time.After(4 * time.Second) for len(pkts) < 2 { select { case err := <-errCh: t.Fatalf("udp read failed: %v", err) case b := <-got: pkt, err := rtprtcp.ParseRtpPacket(b) if err != nil { t.Fatalf("parse rtp packet failed: %v", err) } pkts = append(pkts, pkt) case <-deadline: t.Fatal("udp onmsg timeout") } } foundPS := false for _, pkt := range pkts { if pkt.Header.Ssrc != 0x11223344 { t.Fatalf("ssrc mismatch. got=%d", pkt.Header.Ssrc) } if pkt.Header.PacketType != uint8(lalbase.AvPacketPtAvc) { t.Fatalf("payload type mismatch. got=%d", pkt.Header.PacketType) } body := pkt.Body() if len(body) >= 4 && body[0] == 0x00 && body[1] == 0x00 && body[2] == 0x01 && body[3] == 0xBA { foundPS = true } } if !foundPS { t.Fatalf("expected ps pack header in udp packets") } } func TestLowerPushSessionOnMsgAudioTCP(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen tcp: %v", err) } defer ln.Close() got := make(chan []byte, 1) errCh := make(chan error, 1) go func() { conn, err := ln.Accept() if err != nil { errCh <- err return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) sizeBuf := make([]byte, 2) if _, err := io.ReadFull(conn, sizeBuf); err != nil { errCh <- err return } size := int(sizeBuf[0])<<8 | int(sizeBuf[1]) payload := make([]byte, size) if _, err := io.ReadFull(conn, payload); err != nil { errCh <- err return } got <- append(sizeBuf, payload...) }() addr := ln.Addr().(*net.TCPAddr) session := NewLowerPushSession() session.SetPeerIP(addr.IP.String()) session.SetPeerPort(addr.Port) session.SetSsrc(0x55667788) if err := session.Start("tcp"); err != nil { t.Fatalf("start tcp: %v", err) } defer session.Dispose() session.OnMsg(makeG711AMsg()) select { case err := <-errCh: t.Fatalf("tcp read failed: %v", err) case b := <-got: if len(b) < 14 { t.Fatalf("tcp packet too short: %d", len(b)) } size := int(b[0])<<8 | int(b[1]) if size != len(b)-2 { t.Fatalf("tcp length mismatch. prefix=%d actual=%d", size, len(b)-2) } pkt, err := rtprtcp.ParseRtpPacket(b[2:]) if err != nil { t.Fatalf("parse tcp rtp failed: %v", err) } if pkt.Header.Ssrc != 0x55667788 { t.Fatalf("ssrc mismatch. got=%d", pkt.Header.Ssrc) } body := pkt.Body() if len(body) < 4 || body[0] != 0x00 || body[1] != 0x00 || body[2] != 0x01 || body[3] != 0xBA { t.Fatalf("expected ps pack header, body prefix=%v", body[:min(4, len(body))]) } case <-time.After(4 * time.Second): t.Fatal("tcp onmsg timeout") } } func makeTestRtpPacket(payload []byte) rtprtcp.RtpPacket { h := rtprtcp.MakeDefaultRtpHeader() h.PacketType = uint8(lalbase.AvPacketPtAvc) h.Seq = 1 h.Timestamp = 90000 h.Ssrc = 1234 return rtprtcp.MakeRtpPacket(h, payload) } func makeAvcSeqHeaderMsg() lalbase.RtmpMsg { payload := []byte{ 0x17, 0x00, 0x00, 0x00, 0x00, 0x01, 0x64, 0x00, 0x20, 0xFF, 0xE1, 0x00, 0x19, 0x67, 0x64, 0x00, 0x20, 0xAC, 0xD9, 0x40, 0xC0, 0x29, 0xB0, 0x11, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x03, 0x00, 0x32, 0x0F, 0x18, 0x31, 0x96, 0x01, 0x00, 0x05, 0x68, 0xEB, 0xEC, 0xB2, 0x2C, } return lalbase.RtmpMsg{ Header: lalbase.RtmpHeader{ MsgTypeId: lalbase.RtmpTypeIdVideo, MsgLen: uint32(len(payload)), TimestampAbs: 0, }, Payload: payload, } } func makeAvcKeyFrameMsg() lalbase.RtmpMsg { idr := []byte{0x65, 0x88, 0x84, 0x21, 0xA0} payload := make([]byte, 5+4+len(idr)) payload[0] = lalbase.RtmpAvcKeyFrame payload[1] = lalbase.RtmpAvcPacketTypeNalu payload[2] = 0 payload[3] = 0 payload[4] = 0 payload[5] = 0 payload[6] = 0 payload[7] = 0 payload[8] = byte(len(idr)) copy(payload[9:], idr) return lalbase.RtmpMsg{ Header: lalbase.RtmpHeader{ MsgTypeId: lalbase.RtmpTypeIdVideo, MsgLen: uint32(len(payload)), TimestampAbs: 40, }, Payload: payload, } } func makeAacSeqHeaderMsg() lalbase.RtmpMsg { payload := []byte{0xAF, 0x00, 0x11, 0x90} return lalbase.RtmpMsg{ Header: lalbase.RtmpHeader{ MsgTypeId: lalbase.RtmpTypeIdAudio, MsgLen: uint32(len(payload)), TimestampAbs: 0, }, Payload: payload, } } func makeAacRawMsg() lalbase.RtmpMsg { raw := []byte{0x21, 0x2B, 0x94, 0xA5, 0xB6, 0x0A, 0xE1, 0x63} payload := append([]byte{0xAF, 0x01}, raw...) return lalbase.RtmpMsg{ Header: lalbase.RtmpHeader{ MsgTypeId: lalbase.RtmpTypeIdAudio, MsgLen: uint32(len(payload)), TimestampAbs: 40, }, Payload: payload, } } func makeG711AMsg() lalbase.RtmpMsg { payload := []byte{lalbase.RtmpSoundFormatG711A << 4, 0xD5, 0x5A, 0x11, 0x22} return lalbase.RtmpMsg{ Header: lalbase.RtmpHeader{ MsgTypeId: lalbase.RtmpTypeIdAudio, MsgLen: uint32(len(payload)), TimestampAbs: 20, }, Payload: payload, } } func min(a, b int) int { if a < b { return a } return b } func TestFixturesAreValid(t *testing.T) { if _, _, err := avc.ParseSpsPpsFromSeqHeader(makeAvcSeqHeaderMsg().Payload); err != nil { t.Fatalf("invalid avc fixture: %v", err) } if _, err := aac.NewAscContext(makeAacSeqHeaderMsg().Payload[2:]); err != nil { t.Fatalf("invalid aac fixture: %v", err) } } ================================================ FILE: gb28181/server.go ================================================ package gb28181 import ( "bytes" "encoding/xml" "fmt" "net" "net/http" "strconv" "strings" "sync" "time" udpTransport "github.com/pion/transport/v3/udp" config "github.com/q191201771/lalmax/config" "github.com/q191201771/lalmax/gb28181/mediaserver" "github.com/ghettovoice/gosip" "github.com/ghettovoice/gosip/log" "github.com/ghettovoice/gosip/sip" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" "golang.org/x/net/html/charset" ) type IMediaOpObserver interface { OnStartMediaServer(netWork string, singlePort bool, deviceId string, channelId string) *mediaserver.GB28181MediaServer OnStopMediaServer(netWork string, singlePort bool, deviceId string, channelId string, StreamName string) error } type GB28181Server struct { conf config.GB28181Config RegisterValidity time.Duration // 注册有效期,单位秒,默认 3600 HeartbeatInterval time.Duration // 心跳间隔,单位秒,默认 60 RemoveBanInterval time.Duration // 移除禁止设备间隔,默认600s keepaliveInterval int lalServer logic.ILalServer udpAvailConnPool *AvailConnPool tcpAvailConnPool *AvailConnPool sipUdpSvr gosip.Server sipTcpSvr gosip.Server MediaServerMap sync.Map disposeOnce sync.Once } const MaxRegisterCount = 3 var ( logger log.Logger sipsvr gosip.Server ) func init() { logger = log.NewDefaultLogrusLogger().WithPrefix("LalMaxServer") } func NewGB28181Server(conf config.GB28181Config, lal logic.ILalServer) *GB28181Server { if conf.ListenAddr == "" { conf.ListenAddr = "0.0.0.0" } if conf.SipPort == 0 { conf.SipPort = 5060 } if conf.KeepaliveInterval == 0 { conf.KeepaliveInterval = 60 } if conf.Serial == "" { conf.Serial = "34020000002000000001" } if conf.Realm == "" { conf.Realm = "3402000000" } if conf.MediaConfig.MediaIp == "" { conf.MediaConfig.MediaIp = "0.0.0.0" } if conf.MediaConfig.ListenPort == 0 { conf.MediaConfig.ListenPort = 30000 } if conf.MediaConfig.MultiPortMaxIncrement == 0 { conf.MediaConfig.MultiPortMaxIncrement = 3000 } gb28181Server := &GB28181Server{ conf: conf, RegisterValidity: 3600 * time.Second, HeartbeatInterval: 60 * time.Second, RemoveBanInterval: 600 * time.Second, keepaliveInterval: conf.KeepaliveInterval, lalServer: lal, udpAvailConnPool: NewAvailConnPool(conf.MediaConfig.ListenPort+1, conf.MediaConfig.ListenPort+conf.MediaConfig.MultiPortMaxIncrement), tcpAvailConnPool: NewAvailConnPool(conf.MediaConfig.ListenPort+1, conf.MediaConfig.ListenPort+conf.MediaConfig.MultiPortMaxIncrement), } gb28181Server.tcpAvailConnPool.onListenWithPort = func(port uint16) (net.Listener, error) { return net.Listen("tcp", fmt.Sprintf(":%d", port)) } gb28181Server.udpAvailConnPool.onListenWithPort = func(port uint16) (net.Listener, error) { addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", port)) if err != nil { nazalog.Error("gb28181 media server udp listen failed,err:", err) return nil, err } return udpTransport.Listen("udp", addr) } return gb28181Server } func (s *GB28181Server) Start() { s.sipUdpSvr = s.newSipServer("udp") s.sipTcpSvr = s.newSipServer("tcp") go s.startJob() } func (s *GB28181Server) newSipServer(network string) gosip.Server { srvConf := gosip.ServerConfig{} if s.conf.SipIP != "" { srvConf.Host = s.conf.SipIP } sipSvr := gosip.NewServer(srvConf, nil, nil, logger) sipSvr.OnRequest(sip.REGISTER, s.OnRegister) sipSvr.OnRequest(sip.MESSAGE, s.OnMessage) sipSvr.OnRequest(sip.NOTIFY, s.OnNotify) sipSvr.OnRequest(sip.BYE, s.OnBye) addr := s.conf.ListenAddr + ":" + strconv.Itoa(int(s.conf.SipPort)) err := sipSvr.Listen(network, addr) if err != nil { nazalog.Fatal(err) } nazalog.Info(" start sip server listen. addr= " + addr + " network:" + network) return sipSvr } func (s *GB28181Server) Dispose() { s.disposeOnce.Do( func() { s.MediaServerMap.Range(func(_, value any) bool { mediaServer := value.(*mediaserver.GB28181MediaServer) mediaServer.Dispose() return true }) s.sipTcpSvr.Shutdown() s.sipUdpSvr.Shutdown() }) } func (s *GB28181Server) OnStartMediaServer(netWork string, singlePort bool, deviceId string, channelId string) *mediaserver.GB28181MediaServer { isTcpFlag := false if netWork == "tcp" { isTcpFlag = true } var mediasvr *mediaserver.GB28181MediaServer if singlePort { if isTcpFlag { value, ok := s.MediaServerMap.Load(fmt.Sprintf("%s%d", "tcp", s.conf.MediaConfig.ListenPort)) if ok { mediasvr = value.(*mediaserver.GB28181MediaServer) } } else { value, ok := s.MediaServerMap.Load(fmt.Sprintf("%s%d", "udp", s.conf.MediaConfig.ListenPort)) if ok { mediasvr = value.(*mediaserver.GB28181MediaServer) } } } else { value, ok := s.MediaServerMap.Load(fmt.Sprintf("%s%s", deviceId, channelId)) if ok { mediasvr = value.(*mediaserver.GB28181MediaServer) } } var listener net.Listener var err error var port uint16 if mediasvr == nil { if singlePort { if isTcpFlag { mediasvr = mediaserver.NewGB28181MediaServer(int(s.conf.MediaConfig.ListenPort), fmt.Sprintf("%s%d", "tcp", s.conf.MediaConfig.ListenPort), s, s.lalServer) listener, err = s.tcpAvailConnPool.ListenWithPort(s.conf.MediaConfig.ListenPort) if err != nil { nazalog.Errorf("gb28181 media server tcp Listen failed:%s", err.Error()) return nil } s.MediaServerMap.Store(fmt.Sprintf("%s%d", "tcp", s.conf.MediaConfig.ListenPort), mediasvr) } else { mediasvr = mediaserver.NewGB28181MediaServer(int(s.conf.MediaConfig.ListenPort), fmt.Sprintf("%s%d", "udp", s.conf.MediaConfig.ListenPort), s, s.lalServer) listener, err = s.udpAvailConnPool.ListenWithPort(s.conf.MediaConfig.ListenPort) if err != nil { nazalog.Errorf("gb28181 media server udp Listen failed:%s", err.Error()) return nil } s.MediaServerMap.Store(fmt.Sprintf("%s%d", "udp", s.conf.MediaConfig.ListenPort), mediasvr) } } else { mediaKey := "" if isTcpFlag { listener, port, err = s.tcpAvailConnPool.Acquire() if err != nil { nazalog.Errorf("gb28181 media server tcp acquire failed:%s", err.Error()) return nil } mediaKey = fmt.Sprintf("%s%d", "tcp", port) } else { listener, port, err = s.udpAvailConnPool.Acquire() if err != nil { nazalog.Errorf("gb28181 media server udp acquire failed:%s", err.Error()) return nil } mediaKey = fmt.Sprintf("%s%d", "udp", port) } mediasvr = mediaserver.NewGB28181MediaServer(int(port), mediaKey, s, s.lalServer) s.MediaServerMap.Store(fmt.Sprintf("%s%s", deviceId, channelId), mediasvr) } go mediasvr.Start(listener) } return mediasvr } func (s *GB28181Server) OnStopMediaServer(netWork string, singlePort bool, deviceId string, channelId string, StreamName string) error { isTcpFlag := false if netWork == "tcp" { isTcpFlag = true } var mediasvr *mediaserver.GB28181MediaServer if singlePort { if isTcpFlag { key := fmt.Sprintf("%s%d", "tcp", s.conf.MediaConfig.ListenPort) value, ok := s.MediaServerMap.Load(key) if ok { mediasvr = value.(*mediaserver.GB28181MediaServer) s.MediaServerMap.Delete(key) } } else { key := fmt.Sprintf("%s%d", "udp", s.conf.MediaConfig.ListenPort) value, ok := s.MediaServerMap.Load(key) if ok { mediasvr = value.(*mediaserver.GB28181MediaServer) s.MediaServerMap.Delete(key) } } } else { key := fmt.Sprintf("%s%s", deviceId, channelId) value, ok := s.MediaServerMap.Load(key) if ok { mediasvr = value.(*mediaserver.GB28181MediaServer) s.MediaServerMap.Delete(key) } } if mediasvr != nil { if singlePort { mediasvr.CloseConn(StreamName) } else { mediasvr.Dispose() } } return nil } func (s *GB28181Server) CheckSsrc(ssrc uint32) (*mediaserver.MediaInfo, bool) { var isValidSsrc bool var mediaInfo *mediaserver.MediaInfo Devices.Range(func(_, value any) bool { d := value.(*Device) d.channelMap.Range(func(key, value any) bool { ch := value.(*Channel) if ch.MediaInfo.Ssrc == ssrc { isValidSsrc = true mediaInfo = &ch.MediaInfo return false } return true }) if isValidSsrc { return false } return true }) if isValidSsrc { return mediaInfo, true } return nil, false } func (s *GB28181Server) GetMediaInfoByKey(key string) (*mediaserver.MediaInfo, bool) { var isValidMediaInfo bool var mediaInfo *mediaserver.MediaInfo Devices.Range(func(_, value any) bool { d := value.(*Device) d.channelMap.Range(func(_, value any) bool { ch := value.(*Channel) if ch.MediaInfo.MediaKey == key { isValidMediaInfo = true mediaInfo = &ch.MediaInfo return false } return true }) if isValidMediaInfo { return false } return true }) if isValidMediaInfo { return mediaInfo, true } return nil, false } func (s *GB28181Server) NotifyClose(streamName string) { var ok bool Devices.Range(func(_, value any) bool { d := value.(*Device) d.channelMap.Range(func(key, value any) bool { ch := value.(*Channel) if ch.MediaInfo.StreamName == streamName { if ch.MediaInfo.IsInvite { ch.Bye(streamName) } ch.MediaInfo.Clear() ok = true return false } return true }) if ok { return false } return true }) } func (s *GB28181Server) OnRtpPacket(streamName string, mediaKey string) { } func (s *GB28181Server) startJob() { statusTick := time.NewTicker(s.HeartbeatInterval / 2) banTick := time.NewTicker(s.RemoveBanInterval) for { select { case <-banTick.C: if s.conf.Username != "" || s.conf.Password != "" { s.removeBanDevice() } case <-statusTick.C: s.statusCheck() } } } func (s *GB28181Server) removeBanDevice() { DeviceRegisterCount.Range(func(key, value interface{}) bool { if value.(int) > MaxRegisterCount { DeviceRegisterCount.Delete(key) } return true }) } // statusCheck // - 当设备超过 3 倍心跳时间未发送过心跳(通过 UpdateTime 判断), 视为离线 // - 当设备超过注册有效期内为发送过消息,则从设备列表中删除 // UpdateTime 在设备发送心跳之外的消息也会被更新,相对于 LastKeepaliveAt 更能体现出设备最会一次活跃的时间 func (s *GB28181Server) statusCheck() { Devices.Range(func(key, value any) bool { d := value.(*Device) if int(time.Since(d.LastKeepaliveAt).Seconds()) > s.keepaliveInterval*3 { Devices.Delete(key) nazalog.Warn("Device Keepalive timeout, id:", d.ID, " LastKeepaliveAt:", d.LastKeepaliveAt, " updateTime:", d.UpdateTime) } else if time.Since(d.UpdateTime) > s.HeartbeatInterval*3 { d.Status = DeviceOfflineStatus d.channelMap.Range(func(key, value any) bool { ch := value.(*Channel) ch.Status = ChannelOffStatus return true }) nazalog.Warn("Device offline, id:", d.ID, " registerTime:", d.RegisterTime, " updateTime:", d.UpdateTime) } return true }) } func (s *GB28181Server) getDeviceInfos() (deviceInfos *DeviceInfos) { deviceInfos = &DeviceInfos{ DeviceItems: make([]*DeviceItem, 0), } Devices.Range(func(key, value any) bool { d := value.(*Device) d.Status = DeviceOfflineStatus deviceItem := &DeviceItem{ DeviceId: d.ID, Channels: make([]*ChannelItem, 0), } d.channelMap.Range(func(key, value any) bool { ch := value.(*Channel) channel := &ChannelItem{ ChannelId: ch.ChannelId, Name: ch.Name, Manufacturer: ch.Manufacturer, Owner: ch.Owner, CivilCode: ch.CivilCode, Address: ch.Address, Status: ch.Status, Longitude: ch.Longitude, Latitude: ch.Latitude, StreamName: ch.StreamName, } deviceItem.Channels = append(deviceItem.Channels, channel) return true }) deviceInfos.DeviceItems = append(deviceInfos.DeviceItems, deviceItem) return true }) return deviceInfos } func (s *GB28181Server) GetAllSyncChannels() { Devices.Range(func(key, value any) bool { d := value.(*Device) d.syncChannels() return true }) } func (s *GB28181Server) GetSyncChannels(deviceId string) bool { if v, ok := Devices.Load(deviceId); ok { d := v.(*Device) d.syncChannels() return true } else { return false } } func (s *GB28181Server) FindChannel(deviceId string, channelId string) (channel *Channel) { if v, ok := Devices.Load(deviceId); ok { d := v.(*Device) if ch, ok := d.channelMap.Load(channelId); ok { channel = ch.(*Channel) return channel } else { return nil } } else { return nil } } func (s *GB28181Server) OnRegister(req sip.Request, tx sip.ServerTransaction) { from, ok := req.From() if !ok || from.Address == nil { nazalog.Error("OnRegister, no from") return } id := from.Address.User().String() nazalog.Info("OnRegister", " id:", id, " source:", req.Source(), " req:", req.String()) isUnregister := false if exps := req.GetHeaders("Expires"); len(exps) > 0 { exp := exps[0] expSec, err := strconv.ParseInt(exp.Value(), 10, 32) if err != nil { nazalog.Error(err) return } if expSec == 0 { isUnregister = true } } else { nazalog.Error("has no expire header") return } nazalog.Info("OnRegister", " isUnregister:", isUnregister, " id:", id, " source:", req.Source(), " destination:", req.Destination()) if len(id) != 20 { nazalog.Error("invalid id: ", id) return } passAuth := false // 不需要密码情况 if s.conf.Username == "" && s.conf.Password == "" { passAuth = true } else { // 需要密码情况 设备第一次上报,返回401和加密算法 if hdrs := req.GetHeaders("Authorization"); len(hdrs) > 0 { authenticateHeader := hdrs[0].(*sip.GenericHeader) auth := &Authorization{sip.AuthFromValue(authenticateHeader.Contents)} // 有些摄像头没有配置用户名的地方,用户名就是摄像头自己的国标id var username string if auth.Username() == id { username = id } else { username = s.conf.Username } if dc, ok := DeviceRegisterCount.LoadOrStore(id, 1); ok && dc.(int) > MaxRegisterCount { response := sip.NewResponseFromRequest("", req, http.StatusForbidden, "Forbidden", "") tx.Respond(response) return } else { // 设备第二次上报,校验 _nonce, loaded := DeviceNonce.Load(id) if loaded && auth.Verify(username, s.conf.Password, s.conf.Realm, _nonce.(string)) { passAuth = true } else { DeviceRegisterCount.Store(id, dc.(int)+1) } } } } if passAuth { var d *Device if isUnregister { tmpd, ok := Devices.LoadAndDelete(id) if ok { nazalog.Info("Unregister Device, id:", id) d = tmpd.(*Device) } else { return } } else { if v, ok := Devices.Load(id); ok { d = v.(*Device) s.RecoverDevice(d, req) } else { d = s.StoreDevice(id, req) } } DeviceNonce.Delete(id) DeviceRegisterCount.Delete(id) resp := sip.NewResponseFromRequest("", req, http.StatusOK, "OK", "") to, _ := resp.To() resp.ReplaceHeaders("To", []sip.Header{&sip.ToHeader{Address: to.Address, Params: sip.NewParams().Add("tag", sip.String{Str: RandNumString(9)})}}) resp.RemoveHeader("Allow") expires := sip.Expires(3600) resp.AppendHeader(&expires) resp.AppendHeader(&sip.GenericHeader{ HeaderName: "Date", Contents: time.Now().Format(TIME_LAYOUT), }) _ = tx.Respond(resp) if !isUnregister { //订阅设备更新 go d.syncChannels() } } else { nazalog.Info("OnRegister unauthorized, id:", id, " source:", req.Source(), " destination:", req.Destination()) response := sip.NewResponseFromRequest("", req, http.StatusUnauthorized, "Unauthorized", "") _nonce, _ := DeviceNonce.LoadOrStore(id, RandNumString(32)) auth := fmt.Sprintf( `Digest realm="%s",algorithm=%s,nonce="%s"`, s.conf.Realm, "MD5", _nonce.(string), ) response.AppendHeader(&sip.GenericHeader{ HeaderName: "WWW-Authenticate", Contents: auth, }) _ = tx.Respond(response) } } func (s *GB28181Server) OnMessage(req sip.Request, tx sip.ServerTransaction) { from, _ := req.From() id := from.Address.User().String() nazalog.Info("SIP<-OnMessage, id:", id, " source:", req.Source(), " req:", req.String()) temp := &struct { XMLName xml.Name CmdType string SN int // 请求序列号,一般用于对应 request 和 response DeviceID string DeviceName string Manufacturer string Model string Channel string DeviceList []ChannelInfo `xml:"DeviceList>Item"` SumNum int // 录像结果的总数 SumNum,录像结果会按照多条消息返回,可用于判断是否全部返回 }{} decoder := xml.NewDecoder(bytes.NewReader([]byte(req.Body()))) decoder.CharsetReader = charset.NewReaderLabel err := decoder.Decode(temp) if err != nil { err = DecodeGbk(temp, []byte(req.Body())) if err != nil { nazalog.Error("decode catelog err:", err) } } if v, ok := Devices.Load(id); ok { d := v.(*Device) switch d.Status { case DeviceOfflineStatus, DeviceRecoverStatus: s.RecoverDevice(d, req) //go d.syncChannels(s.conf) case DeviceRegisterStatus: d.Status = DeviceOnlineStatus } d.UpdateTime = time.Now() var body string switch temp.CmdType { case "Keepalive": d.LastKeepaliveAt = time.Now() //callID !="" 说明是订阅的事件类型信息 //if d.lastSyncTime.IsZero() { // go d.syncChannels(s.conf) //} case "Catalog": d.UpdateChannels(temp.DeviceList...) case "DeviceInfo": // 主设备信息 d.Name = temp.DeviceName d.Manufacturer = temp.Manufacturer d.Model = temp.Model case "Alarm": d.Status = DeviceAlarmedStatus body = BuildAlarmResponseXML(d.ID) default: nazalog.Warn("Not supported CmdType, CmdType:", temp.CmdType, " body:", req.Body()) response := sip.NewResponseFromRequest("", req, http.StatusBadRequest, "", "") tx.Respond(response) return } tx.Respond(sip.NewResponseFromRequest("", req, http.StatusOK, "OK", body)) } else { if s.conf.QuickLogin { switch temp.CmdType { case "Keepalive": d := s.StoreDevice(id, req) d.LastKeepaliveAt = time.Now() tx.Respond(sip.NewResponseFromRequest("", req, http.StatusOK, "OK", "")) go d.syncChannels() return } } nazalog.Warn("Unauthorized message, device not found, id:", id) tx.Respond(sip.NewResponseFromRequest("", req, http.StatusBadRequest, "device not found", "")) } } func (s *GB28181Server) OnNotify(req sip.Request, tx sip.ServerTransaction) { from, _ := req.From() id := from.Address.User().String() if v, ok := Devices.Load(id); ok { d := v.(*Device) d.UpdateTime = time.Now() temp := &struct { XMLName xml.Name CmdType string DeviceID string Time string //位置订阅-GPS时间 Longitude string //位置订阅-经度 Latitude string //位置订阅-维度 DeviceList []*notifyMessage `xml:"DeviceList>Item"` //目录订阅 }{} decoder := xml.NewDecoder(bytes.NewReader([]byte(req.Body()))) decoder.CharsetReader = charset.NewReaderLabel err := decoder.Decode(temp) if err != nil { err = DecodeGbk(temp, []byte(req.Body())) if err != nil { nazalog.Error("decode catelog failed, err:", err) } } var body string switch temp.CmdType { case "Catalog": //目录状态 d.UpdateChannelStatus(temp.DeviceList, s.conf) case "MobilePosition": //更新channel的坐标 d.UpdateChannelPosition(temp.DeviceID, temp.Time, temp.Longitude, temp.Latitude) default: nazalog.Warn("Not supported CmdType, cmdType:", temp.CmdType, " body:", req.Body()) response := sip.NewResponseFromRequest("", req, http.StatusBadRequest, "", "") tx.Respond(response) return } tx.Respond(sip.NewResponseFromRequest("", req, http.StatusOK, "OK", body)) } else { tx.Respond(sip.NewResponseFromRequest("", req, http.StatusBadRequest, "device not found", "")) } } func (s *GB28181Server) OnBye(req sip.Request, tx sip.ServerTransaction) { callIdStr := "" if callId, ok := req.CallID(); ok { callIdStr = callId.Value() } from, _ := req.From() devId := from.Address.User().String() if _d, ok := Devices.Load(devId); ok { d := _d.(*Device) d.channelMap.Range(func(key, value any) bool { ch := value.(*Channel) if ch.GetCallId() == callIdStr { ch.byeClear() return false } return true }) } tx.Respond(sip.NewResponseFromRequest("", req, http.StatusOK, "OK", "")) } func (s *GB28181Server) StoreDevice(id string, req sip.Request) (d *Device) { from, _ := req.From() deviceAddr := sip.Address{ DisplayName: from.DisplayName, Uri: from.Address, } deviceIp := req.Source() if _d, ok := Devices.Load(id); ok { d = _d.(*Device) d.UpdateTime = time.Now() d.NetAddr = deviceIp d.addr = deviceAddr d.network = strings.ToLower(req.Transport()) if d.network == "udp" { d.sipSvr = s.sipUdpSvr } else { d.sipSvr = s.sipTcpSvr } nazalog.Info("UpdateDevice, netaddr:", d.NetAddr) } else { servIp := req.Recipient().Host() sipIp := s.conf.SipIP mediaIp := s.conf.MediaConfig.MediaIp d = &Device{ ID: id, RegisterTime: time.Now(), UpdateTime: time.Now(), Status: DeviceRegisterStatus, addr: deviceAddr, sipIP: sipIp, mediaIP: mediaIp, NetAddr: deviceIp, conf: s.conf, network: strings.ToLower(req.Transport()), } if d.network == "udp" { d.sipSvr = s.sipUdpSvr } else { d.sipSvr = s.sipTcpSvr } d.WithMediaServer(s) nazalog.Info("StoreDevice, deviceIp:", deviceIp, " serverIp:", servIp, " mediaIp:", mediaIp, " sipIP:", sipIp) Devices.Store(id, d) } return d } func (s *GB28181Server) RecoverDevice(d *Device, req sip.Request) { from, _ := req.From() d.addr = sip.Address{ DisplayName: from.DisplayName, Uri: from.Address, } deviceIp := req.Source() servIp := req.Recipient().Host() sipIp := s.conf.SipIP mediaIp := sipIp d.Status = DeviceRegisterStatus d.sipIP = sipIp d.mediaIP = mediaIp d.NetAddr = deviceIp d.network = strings.ToLower(req.Transport()) if d.network == "udp" { d.sipSvr = s.sipUdpSvr } else { d.sipSvr = s.sipTcpSvr } d.UpdateTime = time.Now() nazalog.Info("RecoverDevice, deviceIp:", deviceIp, " serverIp:", servIp, " mediaIp:", mediaIp, " sipIP:", sipIp) } type notifyMessage struct { ChannelInfo //状态改变事件 ON:上线,OFF:离线,VLOST:视频丢失,DEFECT:故障,ADD:增加,DEL:删除,UPDATE:更新(必选) Event string } ================================================ FILE: gb28181/t_http_api.go ================================================ package gb28181 import ( "net/http" "github.com/gin-gonic/gin" ) type DeviceInfos struct { DeviceItems []*DeviceItem `json:"device_items"` } type DeviceItem struct { DeviceId string `json:"device_id"` // 设备ID Channels []*ChannelItem `json:"channels"` } type ChannelItem struct { ChannelId string `json:"channel_id"` // channel id Name string `json:"name"` // 设备名称 Manufacturer string `json:"manufacturer"` // 制造厂商 Owner string `json:"owner"` // 设备归属 CivilCode string `json:"civilCode"` // 行政区划编码 Address string `json:"address"` // 地址 Status ChannelStatus `json:"status"` // 状态 on 在线 off离线 Longitude string `json:"longitude"` // 经度 Latitude string `json:"latitude"` // 纬度 StreamName string `json:"-"` } type PlayInfo struct { NetWork string `json:"network" form:"network" url:"network"` // 媒体传输类型,tcp/udp,默认udp DeviceId string `json:"device_id" form:"device_id" url:"device_id"` // 设备 Id ChannelId string `json:"channel_id" form:"channel_id" url:"channel_id"` // channel id StreamName string `json:"stream_name" form:"stream_name" url:"stream_name"` // 对应的流名 SinglePort bool `json:"single_port" form:"single_port" url:"single_port"` // 是否单端口 DumpFileName string `json:"dump_file_name" form:"dump_file_name" url:"dump_file_name"` // dump文件路径 } type ReqPlay struct { PlayInfo } type RespPlay struct { StreamName string `json:"stream_name" form:"stream_name" url:"stream_name"` } type ReqStop struct { PlayInfo } type PtzDirection struct { DeviceId string `json:"device_id" form:"device_id" url:"device_id"` // 设备 Id ChannelId string `json:"channel_id" form:"channel_id" url:"channel_id"` // channel id Up bool `json:"up" form:"up" url:"up"` Down bool `json:"down" form:"down" url:"down"` Left bool `json:"left" form:"left" url:"left"` Right bool `json:"right" form:"right" url:"right"` Speed byte `json:"speed" form:"speed" url:"speed"` //0-8 } type PtzZoom struct { DeviceId string `json:"device_id" form:"device_id" url:"device_id"` // 设备 Id ChannelId string `json:"channel_id" form:"channel_id" url:"channel_id"` // channel id ZoomOut bool `json:"zoom_out" form:"zoom_out" url:"zoom_out"` ZoomIn bool `json:"zoom_in" form:"zoom_in" url:"zoom_in"` Speed byte `json:"speed" form:"speed" url:"speed"` //0-8 } type PtzFi struct { DeviceId string `json:"device_id" form:"device_id" url:"device_id"` // 设备 Id ChannelId string `json:"channel_id" form:"channel_id" url:"channel_id"` // channel id IrisIn bool `json:"iris_in" form:"iris_in" url:"iris_in"` IrisOut bool `json:"iris_out" form:"iris_out" url:"iris_out"` FocusNear bool `json:"focus_near" form:"focus_near" url:"focus_near"` FocusFar bool `json:"focus_far" form:"focus_far" url:"focus_far"` Speed byte `json:"speed" form:"speed" url:"speed"` //0-8 } type PresetCmd byte const ( PresetEditPoint PresetCmd = iota PresetDelPoint PresetCallPoint ) type PtzPreset struct { DeviceId string `json:"device_id" form:"device_id" url:"device_id"` // 设备 Id ChannelId string `json:"channel_id" form:"channel_id" url:"channel_id"` // channel id Cmd PresetCmd `json:"cmd" form:"cmd" url:"cmd"` Point byte `json:"point" form:"point" url:"point"` } type PtzStop struct { DeviceId string `json:"device_id" form:"device_id" url:"device_id"` // 设备 Id ChannelId string `json:"channel_id" form:"channel_id" url:"channel_id"` // channel id } type ReqUpdateNotify struct { DeviceId string `json:"device_id" form:"device_id" url:"device_id"` //设备 Id } func ResponseErrorWithMsg(c *gin.Context, code ResCode, msg interface{}) { c.JSON(http.StatusOK, &ResponseData{ Code: code, Msg: msg, Data: nil, }) } func ResponseSuccess(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, &ResponseData{ Code: CodeSuccess, Msg: CodeSuccess.Msg(), Data: data, }) } type ResCode int64 const ( CodeSuccess ResCode = 1000 + iota CodeInvalidParam CodeServerBusy CodeDeviceNotRegister CodeDeviceStopError ) var codeMsgMap = map[ResCode]string{ CodeSuccess: "success", CodeInvalidParam: "请求参数错误", CodeServerBusy: "服务繁忙", CodeDeviceNotRegister: "设备暂时未注册", CodeDeviceStopError: "设备停止播放错误", } const ( SpeedParamError = "speed 范围(0,8]" PointParamError = "point 范围(0,50]" ) func (c ResCode) Msg() string { msg, ok := codeMsgMap[c] if !ok { msg = codeMsgMap[CodeServerBusy] } return msg } type ResponseData struct { Code ResCode `json:"code"` Msg interface{} `json:"msg"` Data interface{} `json:"data,omitempty"` } ================================================ FILE: gb28181/util.go ================================================ package gb28181 import ( "bytes" "encoding/xml" "golang.org/x/net/html/charset" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" "io/ioutil" "math/rand" "time" ) func RandNumString(n int) string { numbers := "0123456789" return randStringBySoure(numbers, n) } func RandString(n int) string { letterBytes := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" return randStringBySoure(letterBytes, n) } // https://github.com/kpbird/golang_random_string func randStringBySoure(src string, n int) string { randomness := make([]byte, n) rand.Seed(time.Now().UnixNano()) _, err := rand.Read(randomness) if err != nil { panic(err) } l := len(src) // fill output output := make([]byte, n) for pos := range output { random := randomness[pos] randomPos := random % uint8(l) output[pos] = src[randomPos] } return string(output) } func DecodeGbk(v interface{}, body []byte) error { bodyBytes, err := GbkToUtf8(body) if err != nil { return err } decoder := xml.NewDecoder(bytes.NewReader(bodyBytes)) decoder.CharsetReader = charset.NewReaderLabel err = decoder.Decode(v) return err } func GbkToUtf8(s []byte) ([]byte, error) { reader := transform.NewReader(bytes.NewReader(s), simplifiedchinese.GBK.NewDecoder()) d, e := ioutil.ReadAll(reader) if e != nil { return s, e } return d, nil } ================================================ FILE: gb28181/xml.go ================================================ package gb28181 import ( "encoding/xml" "fmt" ) var ( // CatalogXML 获取设备列表xml样式 CatalogXML = ` Catalog %d %s ` // RecordInfoXML 获取录像文件列表xml样式 RecordInfoXML = ` RecordInfo %d %s %s %s 0 all ` // DeviceInfoXML 查询设备详情xml样式 DeviceInfoXML = ` DeviceInfo %d %s ` // DevicePositionXML 订阅设备位置 DevicePositionXML = ` MobilePosition %d %s %d ` ) func BuildCatalogXML(sn int, id string) string { return fmt.Sprintf(CatalogXML, sn, id) } // AlarmResponseXML alarm response xml样式 var ( AlarmResponseXML = ` Alarm 17430 %s ` ) // BuildRecordInfoXML 获取录像文件列表指令 func BuildAlarmResponseXML(id string) string { return fmt.Sprintf(AlarmResponseXML, id) } func BuildDeviceInfoXML(sn int, id string) string { return fmt.Sprintf(DeviceInfoXML, sn, id) } func XmlEncode(v interface{}) (string, error) { xmlData, err := xml.MarshalIndent(v, "", " ") if err != nil { return "", err } xml := string(xmlData) xml = `` + "\n" + xml + "\n" return xml, err } ================================================ FILE: go.mod ================================================ module github.com/q191201771/lalmax go 1.23 require ( github.com/abema/go-mp4 v1.2.0 github.com/bluenviron/gohlslib v1.3.0 github.com/bluenviron/gortsplib/v4 v4.8.0 github.com/bluenviron/mediacommon v1.9.2 github.com/datarhei/gosrt v0.5.4 github.com/ghettovoice/gosip v0.0.0-20230802091127-d58873a3fe44 github.com/gin-gonic/gin v1.9.1 github.com/gofrs/uuid v4.4.0+incompatible github.com/pion/ice/v2 v2.3.13 github.com/pion/interceptor v0.1.40 github.com/pion/rtp v1.8.20 github.com/pion/transport/v3 v3.0.7 github.com/pion/webrtc/v3 v3.2.28 github.com/q191201771/lal v0.37.4 github.com/q191201771/naza v0.30.48 github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b github.com/smallnest/chanx v1.2.0 github.com/yapingcat/gomedia v0.0.0-20240316172424-76660eca7389 golang.org/x/net v0.35.0 golang.org/x/text v0.22.0 ) require ( github.com/asticode/go-astikit v0.30.0 // indirect github.com/asticode/go-astits v1.13.0 // indirect github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.1.0-rc.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v2 v2.2.11 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect github.com/pion/sctp v1.8.39 // indirect github.com/pion/sdp/v3 v3.0.14 // indirect github.com/pion/srtp/v2 v2.0.18 // indirect github.com/pion/stun v0.6.1 // indirect github.com/pion/transport/v2 v2.2.5 // indirect github.com/pion/turn/v2 v2.1.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect go.uber.org/goleak v1.3.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect google.golang.org/protobuf v1.36.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/abema/go-mp4 v1.2.0 h1:gi4X8xg/m179N/J15Fn5ugywN9vtI6PLk6iLldHGLAk= github.com/abema/go-mp4 v1.2.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= github.com/asticode/go-astikit v0.30.0 h1:DkBkRQRIxYcknlaU7W7ksNfn4gMFsB0tqMJflxkRsZA= github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c h1:8XZeJrs4+ZYhJeJ2aZxADI2tGADS15AzIF8MQ8XAhT4= github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c/go.mod h1:x1vxHcL/9AVzuk5HOloOEPrtJY0MaalYr78afXZ+pWI= github.com/bluenviron/gohlslib v1.3.0 h1:I9t1Nba6VJKg5rLoXSzQFPkZZYBUwBqCU2Divp0oU2I= github.com/bluenviron/gohlslib v1.3.0/go.mod h1:wD8ysO6HB90d17sxoIQXGHINo2KYj/mZirMnPtKLJZQ= github.com/bluenviron/gortsplib/v4 v4.8.0 h1:nvFp6rHALcSep3G9uBFI0uogS9stVZLNq/92TzGZdQg= github.com/bluenviron/gortsplib/v4 v4.8.0/go.mod h1:+d+veuyvhvikUNp0GRQkk6fEbd/DtcXNidMRm7FQRaA= github.com/bluenviron/mediacommon v1.9.2 h1:EHcvoC5YMXRcFE010bTNf07ZiSlB/e/AdZyG7GsEYN0= github.com/bluenviron/mediacommon v1.9.2/go.mod h1:lt8V+wMyPw8C69HAqDWV5tsAwzN9u2Z+ca8B6C//+n0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/datarhei/gosrt v0.5.4 h1:dE3mmSB+n1GeviGM8xQAW3+UD3mKeFmd84iefDul5Vs= github.com/datarhei/gosrt v0.5.4/go.mod h1:MiUCwCG+LzFMzLM/kTA+3wiTtlnkVvGbW/F0XzyhtG8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca h1:cTTdXpkQ1aVbOOmHwdwtYuwUZcQtcMrleD1UXLWhAq8= github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca/go.mod h1:W+3LQaEkN8qAwwcw0KC546sUEnX86GIT8CcMLZC4mG0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/ghettovoice/gosip v0.0.0-20230802091127-d58873a3fe44 h1:m4/46V6uAJ95CLimMRHJjiH5psW1JuL+iLeUBzF2r70= github.com/ghettovoice/gosip v0.0.0-20230802091127-d58873a3fe44/go.mod h1:rlD1yLOErWYohWTryG/2bTTpmzB79p52ntLA/uIFXeI= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.1.0-rc.1 h1:VK3aeRXMI8osaS6YCDKNZhU6RKtcP3B2wzqxOogNDz8= github.com/gobwas/ws v1.1.0-rc.1/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.5/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ= github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.11 h1:9U/dpCYl1ySttROPWJgqWKEylUdT0fXp/xst6JwY5Ks= github.com/pion/dtls/v2 v2.2.11/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= github.com/pion/ice/v2 v2.3.13 h1:xOxP+4V9nSDlUaGFRf/LvAuGHDXRcjIdsbbXPK/w7c8= github.com/pion/ice/v2 v2.3.13/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y= github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI= github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0= github.com/pion/sctp v1.8.12/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI= github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw= github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo= github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc= github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v2 v2.2.5 h1:iyi25i/21gQck4hfRhomF6SktmUQjRsRW4WJdhfc3Kc= github.com/pion/transport/v2 v2.2.5/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/webrtc/v3 v3.2.28 h1:ienStxZ6HcjtH2UlmnFpMM0loENiYjaX437uIUpQSKo= github.com/pion/webrtc/v3 v3.2.28/go.mod h1:PNRCEuQlibrmuBhOTnol9j6KkIbUG11aHLEfNpUYey0= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/q191201771/lal v0.37.4 h1:yq1PuuHfyzOjGLsZOZIorI+FcmgKSJKEitG9rYAMpYk= github.com/q191201771/lal v0.37.4/go.mod h1:DNDsCng/5dZOira1v6Z/yR45l5K4+EmPC4BqciZGdgQ= github.com/q191201771/naza v0.30.48 h1:lbYUaa7A15kJKYwOiU4AbFS1Zo8oQwppl2tLEbJTqnw= github.com/q191201771/naza v0.30.48/go.mod h1:n+dpJjQSh90PxBwxBNuifOwQttywvSIN5TkWSSYCeBk= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smallnest/chanx v1.2.0 h1:RLyldZBbQZ4O0dSvdkTMHo4+mDw20Bc1jXXTHf+ymZo= github.com/smallnest/chanx v1.2.0/go.mod h1:+4nWMF0+CqEcU74SnX2NxaGqZ8zX4pcQ8Jcs77DbX5A= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 h1:hNna6Fi0eP1f2sMBe/rJicDmaHmoXGe1Ta84FPYHLuE= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5/go.mod h1:f1SCnEOt6sc3fOJfPQDRDzHOtSXuTtnz0ImG9kPRDV0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/yapingcat/gomedia v0.0.0-20240316172424-76660eca7389 h1:L33BsOOJZx9Fe97IJHQWeQTecAPKnoCcX7nOtJ3tGoE= github.com/yapingcat/gomedia v0.0.0-20240316172424-76660eca7389/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= ================================================ FILE: logic/gop_cache.go ================================================ package logic import ( "bytes" "github.com/q191201771/lal/pkg/base" ) type GopCache struct { videoheader *base.RtmpMsg audioheader *base.RtmpMsg gopSize int singleGopMaxFrameNum int data []Gop first int last int } // gopSize 为 0 时只保存音视频头,不缓存 GOP。 func NewGopCache(gopSize, singleGopMaxFrameNum int) *GopCache { if gopSize < 0 { gopSize = 0 } if singleGopMaxFrameNum < 0 { singleGopMaxFrameNum = 0 } num := gopSize + 1 return &GopCache{ data: make([]Gop, num), gopSize: num, singleGopMaxFrameNum: singleGopMaxFrameNum, } } func (c *GopCache) Feed(msg base.RtmpMsg) { switch msg.Header.MsgTypeId { case base.RtmpTypeIdMetadata: return case base.RtmpTypeIdAudio: if msg.IsAacSeqHeader() { if c.audioheader == nil || !bytes.Equal(c.audioheader.Payload, msg.Payload) { c.Clear() } m := msg.Clone() c.audioheader = &m return } if msg.AudioCodecId() == base.RtmpSoundFormatG711A || msg.AudioCodecId() == base.RtmpSoundFormatG711U || msg.AudioCodecId() == base.RtmpSoundFormatOpus { if c.audioheader == nil || c.audioheader.AudioCodecId() != msg.AudioCodecId() { m := msg.Clone() c.audioheader = &m } } case base.RtmpTypeIdVideo: if msg.IsVideoKeySeqHeader() { if c.videoheader == nil || !bytes.Equal(c.videoheader.Payload, msg.Payload) { c.Clear() } m := msg.Clone() c.videoheader = &m return } } if c.gopSize > 1 { if msg.IsVideoKeyNalu() { c.feedNewGop(msg) } else { c.feedLastGop(msg) } } } func (c *GopCache) feedNewGop(msg base.RtmpMsg) { if c.isGopRingFull() { c.first = (c.first + 1) % c.gopSize } c.data[c.last].clear() c.data[c.last].feed(msg) c.last = (c.last + 1) % c.gopSize } func (c *GopCache) feedLastGop(msg base.RtmpMsg) { if c.isGopRingEmpty() { return } idx := (c.last - 1 + c.gopSize) % c.gopSize if c.singleGopMaxFrameNum == 0 || c.data[idx].size() < c.singleGopMaxFrameNum { c.data[idx].feed(msg) } } func (c *GopCache) isGopRingFull() bool { return (c.last+1)%c.gopSize == c.first } func (c *GopCache) isGopRingEmpty() bool { return c.first == c.last } func (c *GopCache) Clear() { for i := range c.data { c.data[i].release() } c.last = 0 c.first = 0 } func (c *GopCache) GetGopCount() int { return (c.last + c.gopSize - c.first) % c.gopSize } func (c *GopCache) GetGopDataAt(pos int) []base.RtmpMsg { if pos >= c.GetGopCount() || pos < 0 { return nil } return c.data[(c.first+pos)%c.gopSize].data } // clear 保留底层容量用于复用;release 用于码流头变化时释放旧 payload。 type Gop struct { data []base.RtmpMsg } func (g *Gop) feed(msg base.RtmpMsg) { g.data = append(g.data, msg.Clone()) } func (g *Gop) clear() { if len(g.data) == 0 { return } for i := range g.data { g.data[i] = base.RtmpMsg{} } g.data = g.data[:0] } func (g *Gop) release() { g.data = nil } func (g *Gop) size() int { return len(g.data) } ================================================ FILE: logic/group.go ================================================ package logic import ( "sync" "sync/atomic" "time" "github.com/q191201771/lalmax/fmp4/hls" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/naza/pkg/nazalog" ) var _ base.ISession = (*subscriberState)(nil) const ( SubscriberProtocolLalmax = "LALMAX" SubscriberProtocolWHEP = "WHEP" SubscriberProtocolJessibuca = "JESSIBUCA" SubscriberProtocolHTTPFMP4 = "HTTP-FMP4" SubscriberProtocolSRT = "SRT" ) type Subscriber interface { OnMsg(msg base.RtmpMsg) OnStop() } // 可选接口:订阅者需要区分 GOP 回放和实时帧时实现。 type ReplaySubscriber interface { OnReplayStart() OnReplayStop() } type SubscriberInfo struct { SubscriberID string Protocol string RemoteAddr string } // Group 只维护 lalmax 侧订阅者和回放缓存,推流状态仍以 lal 为准。 type Group struct { uniqueKey string key StreamKey consumers sync.Map hlssvr *hls.HlsServer manager *ComplexGroupManager hookMux sync.RWMutex activeHookKey StreamKey onActiveHook func(StreamKey) stopHookKey StreamKey onStopHook func(StreamKey) gopCache *GopCache gopCacheMux sync.RWMutex lifecycleMux sync.RWMutex stopOnce sync.Once msgMux sync.Mutex activeHookSent bool hasVideo bool closed atomic.Bool } type subscriberState struct { key StreamKey subscriber Subscriber statProvider SubscriberStatProvider hasSendVideo bool replayCache bool writeMux sync.Mutex statMux sync.Mutex stopped atomic.Bool lastStatAt time.Time prevReadBytesSum uint64 prevWroteBytesSum uint64 base.StatSession } func (s *subscriberState) AppName() string { return s.key.AppName } func (s *subscriberState) GetStat() base.StatSession { if s == nil { return base.StatSession{} } return s.refreshStat(0) } func (s *subscriberState) IsAlive() (readAlive bool, writeAlive bool) { return true, true } func (s *subscriberState) RawQuery() string { return "" } func (s *subscriberState) StreamName() string { return s.key.StreamName } func (s *subscriberState) UniqueKey() string { return s.SessionId } func (s *subscriberState) UpdateStat(intervalSec uint32) { if s == nil { return } s.refreshStat(float64(intervalSec)) } func (s *subscriberState) Url() string { return s.key.String() } func newGroup(manager *ComplexGroupManager, uniqueKey string, key StreamKey, hlssvr *hls.HlsServer, gopNum, singleGopMaxFrameNum int) *Group { group := &Group{ uniqueKey: uniqueKey, key: key, hlssvr: hlssvr, manager: manager, gopCache: NewGopCache(gopNum, singleGopMaxFrameNum), } nazalog.Infof("create group, uniqueKey:%s, streamKey:%s", uniqueKey, key.String()) return group } func (group *Group) initHlsSession() { if group != nil && group.hlssvr != nil { group.hlssvr.NewHlsSessionWithAppName(group.key.AppName, group.key.StreamName) } } func (group *Group) waitLifecycleIdle() { if group == nil { return } group.lifecycleMux.RLock() group.lifecycleMux.RUnlock() } func (group *Group) Key() StreamKey { return group.key } func (group *Group) UniqueKey() string { return group.uniqueKey } func (group *Group) BindStopHook(key StreamKey, onStop func(StreamKey)) { if group == nil { return } group.hookMux.Lock() group.stopHookKey = key group.onStopHook = onStop group.hookMux.Unlock() } func (group *Group) BindActiveHook(key StreamKey, onActive func(StreamKey)) { if group == nil { return } group.hookMux.Lock() group.activeHookKey = key group.onActiveHook = onActive group.hookMux.Unlock() } func (group *Group) OnMsg(msg base.RtmpMsg) { group.lifecycleMux.RLock() if group.closed.Load() { group.lifecycleMux.RUnlock() return } defer group.lifecycleMux.RUnlock() if group.hlssvr != nil { group.hlssvr.OnMsgWithAppName(group.key.AppName, group.key.StreamName, msg) } group.msgMux.Lock() hasVideo := group.hasVideo shouldNotifyActive := false consumers := make([]*subscriberState, 0) group.consumers.Range(func(key, value interface{}) bool { if c, ok := value.(*subscriberState); ok { consumers = append(consumers, c) } return true }) if !group.hasVideo && msg.IsVideoKeyNalu() { group.hasVideo = true } if !group.activeHookSent && isActiveMediaMsg(msg) { group.activeHookSent = true shouldNotifyActive = true } group.gopCacheMux.Lock() group.gopCache.Feed(msg) group.gopCacheMux.Unlock() group.msgMux.Unlock() if shouldNotifyActive { group.hookMux.RLock() activeHookKey := group.activeHookKey onActiveHook := group.onActiveHook group.hookMux.RUnlock() if onActiveHook != nil { onActiveHook(activeHookKey) } } for _, c := range consumers { group.handleSubscriberMsg(c, msg, hasVideo) } } func isActiveMediaMsg(msg base.RtmpMsg) bool { switch msg.Header.MsgTypeId { case base.RtmpTypeIdAudio: return !msg.IsAacSeqHeader() case base.RtmpTypeIdVideo: return !msg.IsVideoKeySeqHeader() default: return false } } func (group *Group) OnStop() { group.stopOnce.Do(func() { group.lifecycleMux.Lock() group.closed.Store(true) if group.hlssvr != nil { group.hlssvr.OnStopWithAppName(group.key.AppName, group.key.StreamName) } consumers := make([]*subscriberState, 0) group.consumers.Range(func(key, value interface{}) bool { c, ok := value.(*subscriberState) if ok { consumers = append(consumers, c) } group.consumers.Delete(key) return true }) group.lifecycleMux.Unlock() nazalog.Debugf("OnStop, uniqueKey:%s, streamKey:%s", group.uniqueKey, group.key.String()) for _, c := range consumers { c.stopWithNotify() } if group.manager != nil { group.manager.RemoveGroupIfMatch(group.key, group) } group.hookMux.RLock() stopHookKey := group.stopHookKey onStopHook := group.onStopHook group.hookMux.RUnlock() if onStopHook != nil { onStopHook(stopHookKey) } }) } func (group *Group) AddSubscriber(info SubscriberInfo, subscriber Subscriber) { group.AddSubscriberWithReplay(info, subscriber, true) } func (group *Group) AddSubscriberWithReplay(info SubscriberInfo, subscriber Subscriber, replayCache bool) { if info.SubscriberID == "" { nazalog.Warn("AddSubscriber skipped, subscriber id is empty") return } if info.Protocol == "" { info.Protocol = SubscriberProtocolLalmax } group.lifecycleMux.RLock() if group.closed.Load() { group.lifecycleMux.RUnlock() nazalog.Warnf("AddSubscriber skipped, group is closed, streamKey:%s, subscriberId:%s", group.key.String(), info.SubscriberID) return } defer group.lifecycleMux.RUnlock() state := &subscriberState{ key: group.key, subscriber: subscriber, replayCache: replayCache, lastStatAt: time.Now(), statProvider: nil, StatSession: base.StatSession{ SessionId: info.SubscriberID, Protocol: info.Protocol, BaseType: base.SessionBaseTypeSubStr, RemoteAddr: info.RemoteAddr, StartTime: time.Now().Format(time.DateTime), }, } if provider, ok := subscriber.(SubscriberStatProvider); ok { state.statProvider = provider } nazalog.Infof("AddSubscriber, streamKey:%s, subscriberId:%s, protocol:%s", group.key.String(), info.SubscriberID, info.Protocol) if replayCache { // 保证该订阅者先收到缓存 GOP,再收到实时帧。 state.writeMux.Lock() } var replayMsgs []base.RtmpMsg group.msgMux.Lock() if _, loaded := group.consumers.Load(info.SubscriberID); loaded { group.msgMux.Unlock() if replayCache { state.writeMux.Unlock() } nazalog.Warnf("AddSubscriber skipped, subscriber already exists, streamKey:%s, subscriberId:%s", group.key.String(), info.SubscriberID) return } group.consumers.Store(info.SubscriberID, state) if replayCache { replayMsgs = group.getGopReplayMessages() } group.msgMux.Unlock() if replayCache { group.replayGopMessagesLocked(state, replayMsgs) state.writeMux.Unlock() } } func (group *Group) AddConsumer(consumerID string, subscriber Subscriber) { group.AddSubscriber(SubscriberInfo{SubscriberID: consumerID}, subscriber) } func (group *Group) AddConsumerWithReplay(consumerID string, subscriber Subscriber, replayCache bool) { group.AddSubscriberWithReplay(SubscriberInfo{SubscriberID: consumerID}, subscriber, replayCache) } func (group *Group) StatSubscribers() []base.StatSub { out := make([]base.StatSub, 0, 10) group.consumers.Range(func(key, value any) bool { v, ok := value.(*subscriberState) if ok { out = append(out, base.Session2StatSub(v)) } return true }) return out } func (group *Group) GetAllConsumer() []base.StatSub { return group.StatSubscribers() } func (group *Group) RemoveSubscriber(subscriberID string) { value, ok := group.consumers.LoadAndDelete(subscriberID) if ok { nazalog.Infof("RemoveSubscriber, streamKey:%s, subscriberId:%s", group.key.String(), subscriberID) if c, ok := value.(*subscriberState); ok { c.stopWithoutNotify() } } } func (group *Group) RemoveConsumer(consumerID string) { group.RemoveSubscriber(consumerID) } func (group *Group) GetVideoSeqHeaderMsg() *base.RtmpMsg { group.gopCacheMux.RLock() defer group.gopCacheMux.RUnlock() if group.gopCache.videoheader == nil { return nil } m := group.gopCache.videoheader.Clone() return &m } func (group *Group) GetAudioSeqHeaderMsg() *base.RtmpMsg { group.gopCacheMux.RLock() defer group.gopCacheMux.RUnlock() if group.gopCache.audioheader == nil { return nil } m := group.gopCache.audioheader.Clone() return &m } func (group *Group) handleSubscriberMsg(c *subscriberState, msg base.RtmpMsg, hasVideo bool) { if c == nil { return } c.writeMux.Lock() defer c.writeMux.Unlock() if c.stopped.Load() || c.subscriber == nil { return } if msg.Header.MsgTypeId == base.RtmpTypeIdVideo { if !c.hasSendVideo { if !msg.IsVideoKeyNalu() { return } if v := group.GetVideoSeqHeaderMsg(); v != nil { if !c.deliverMsg(*v) { return } } if v := group.GetAudioSeqHeaderMsg(); v != nil && v.IsAacSeqHeader() { if !c.deliverMsg(*v) { return } } c.hasSendVideo = true } c.deliverMsg(msg) } else if msg.Header.MsgTypeId == base.RtmpTypeIdAudio { if !hasVideo || c.hasSendVideo { c.deliverMsg(msg) } } } func (group *Group) replayGopMessagesLocked(c *subscriberState, msgs []base.RtmpMsg) { if c == nil || c.subscriber == nil || c.stopped.Load() || c.hasSendVideo || !c.replayCache { return } if len(msgs) == 0 { return } if replaySubscriber, ok := c.subscriber.(ReplaySubscriber); ok { replaySubscriber.OnReplayStart() defer replaySubscriber.OnReplayStop() } for _, msg := range msgs { if !c.deliverMsg(msg) { return } } c.hasSendVideo = true } func (s *subscriberState) deliverMsg(msg base.RtmpMsg) bool { if s == nil || s.stopped.Load() || s.subscriber == nil { return false } s.subscriber.OnMsg(msg) return !s.stopped.Load() && s.subscriber != nil } func (s *subscriberState) refreshStat(intervalSec float64) base.StatSession { s.statMux.Lock() defer s.statMux.Unlock() s.refreshStatSnapshotLocked() if intervalSec <= 0 { if s.lastStatAt.IsZero() { s.lastStatAt = time.Now() return s.StatSession } intervalSec = time.Since(s.lastStatAt).Seconds() if intervalSec < 1 { return s.StatSession } } s.updateBitrateLocked(intervalSec) s.lastStatAt = time.Now() return s.StatSession } func (s *subscriberState) refreshStatSnapshotLocked() { if s.statProvider == nil { return } stat := s.statProvider.GetSubscriberStat() if stat.RemoteAddr != "" { s.StatSession.RemoteAddr = stat.RemoteAddr } s.StatSession.ReadBytesSum = stat.ReadBytesSum s.StatSession.WroteBytesSum = stat.WroteBytesSum } func (s *subscriberState) updateBitrateLocked(intervalSec float64) { if intervalSec <= 0 { return } readDiff := diffUint64(s.StatSession.ReadBytesSum, s.prevReadBytesSum) writeDiff := diffUint64(s.StatSession.WroteBytesSum, s.prevWroteBytesSum) s.StatSession.ReadBitrateKbits = bitrateFromBytes(readDiff, intervalSec) s.StatSession.WriteBitrateKbits = bitrateFromBytes(writeDiff, intervalSec) s.StatSession.BitrateKbits = s.StatSession.WriteBitrateKbits s.prevReadBytesSum = s.StatSession.ReadBytesSum s.prevWroteBytesSum = s.StatSession.WroteBytesSum } func bitrateFromBytes(bytes uint64, intervalSec float64) int { return int(float64(bytes) * 8 / 1024 / intervalSec) } func diffUint64(curr, prev uint64) uint64 { if curr < prev { return curr } return curr - prev } func (s *subscriberState) stopWithNotify() { if s == nil { return } s.writeMux.Lock() defer s.writeMux.Unlock() if s.stopped.Swap(true) { return } if s.subscriber != nil { s.subscriber.OnStop() s.subscriber = nil } } func (s *subscriberState) stopWithoutNotify() { if s == nil { return } // 不能在这里获取 writeMux:部分订阅者会在 OnMsg 调用栈内主动移除自己。 // 只标记停止,避免后续投递;订阅者对象随 state 一起释放。 s.stopped.Store(true) } func (group *Group) getGopReplayMessages() []base.RtmpMsg { group.gopCacheMux.RLock() defer group.gopCacheMux.RUnlock() gopCount := group.gopCache.GetGopCount() if gopCount == 0 { return nil } msgs := make([]base.RtmpMsg, 0, gopCount) if v := group.gopCache.videoheader; v != nil { msgs = append(msgs, v.Clone()) } if v := group.gopCache.audioheader; v != nil && v.IsAacSeqHeader() { msgs = append(msgs, v.Clone()) } for i := 0; i < gopCount; i++ { for _, item := range group.gopCache.GetGopDataAt(i) { msgs = append(msgs, item.Clone()) } } return msgs } ================================================ FILE: logic/group_manager.go ================================================ package logic import ( "sync" "time" "github.com/q191201771/lalmax/fmp4/hls" "github.com/q191201771/naza/pkg/nazalog" ) type IGroupManager interface { GetOrCreateGroup(key StreamKey, uniqueKey string, hlssvr *hls.HlsServer, gopNum, singleGopMaxFrameNum int) (*Group, bool) RemoveGroup(key StreamKey) RemoveGroupIfMatch(key StreamKey, group *Group) GetGroup(key StreamKey) (bool, *Group) Iterate(onIterateGroup func(key StreamKey, group *Group) bool) Len() int } type ComplexGroupManager struct { mutex sync.RWMutex onlyStreamNameGroups map[string]*Group appNameStreamNameGroups map[string]map[string]*Group } // 同时支持新路径 app/stream 和旧路径 stream 的查找方式。 func NewComplexGroupManager() *ComplexGroupManager { return &ComplexGroupManager{ onlyStreamNameGroups: make(map[string]*Group), appNameStreamNameGroups: make(map[string]map[string]*Group), } } var ( defaultGroupManager *ComplexGroupManager groupManagerOnce sync.Once ) func GetGroupManagerInstance() *ComplexGroupManager { groupManagerOnce.Do(func() { defaultGroupManager = NewComplexGroupManager() }) return defaultGroupManager } func (m *ComplexGroupManager) GetOrCreateGroup(key StreamKey, uniqueKey string, hlssvr *hls.HlsServer, gopNum, singleGopMaxFrameNum int) (*Group, bool) { if m == nil || !key.Valid() { return nil, false } for { m.mutex.Lock() ok, existing := m.getGroupLocked(key) if !ok { break } if !existing.closed.Load() { m.mutex.Unlock() return existing, false } m.mutex.Unlock() // 等旧 group 完成 HLS 清理后再发布替换 group, // 否则旧 group 的 OnStop 可能删掉新的 HLS session。 existing.waitLifecycleIdle() m.mutex.Lock() ok, current := m.getGroupLocked(key) if !ok { break } if current == existing { break } if !current.closed.Load() { m.mutex.Unlock() return current, false } m.mutex.Unlock() } group := newGroup(m, uniqueKey, key, hlssvr, gopNum, singleGopMaxFrameNum) group.initHlsSession() m.setGroupLocked(key, group) m.mutex.Unlock() return group, true } func (m *ComplexGroupManager) GetOrCreateGroupByStreamName(uniqueKey, streamName string, hlssvr *hls.HlsServer, gopNum, singleGopMaxFrameNum int) (*Group, bool) { return m.GetOrCreateGroup(StreamKeyFromStreamName(streamName), uniqueKey, hlssvr, gopNum, singleGopMaxFrameNum) } func (m *ComplexGroupManager) setGroup(key StreamKey, group *Group) { if m == nil || !key.Valid() || group == nil { return } m.mutex.Lock() defer m.mutex.Unlock() m.setGroupLocked(key, group) } func (m *ComplexGroupManager) setGroupLocked(key StreamKey, group *Group) { nazalog.Info("SetGroup, streamKey:", key.String()) group.manager = m if key.AppName == "" { m.onlyStreamNameGroups[key.StreamName] = group return } groups, ok := m.appNameStreamNameGroups[key.AppName] if !ok { groups = make(map[string]*Group) m.appNameStreamNameGroups[key.AppName] = groups } groups[key.StreamName] = group } func (m *ComplexGroupManager) setGroupByStreamName(streamName string, group *Group) { m.setGroup(StreamKeyFromStreamName(streamName), group) } func (m *ComplexGroupManager) RemoveGroup(key StreamKey) { m.removeGroup(key, nil, false) } // 避免旧流晚到的 OnStop 或遍历删除误删同 key 的新流。 func (m *ComplexGroupManager) RemoveGroupIfMatch(key StreamKey, group *Group) { m.removeGroup(key, group, true) } func (m *ComplexGroupManager) removeGroup(key StreamKey, group *Group, shouldMatch bool) { if m == nil || !key.Valid() { return } nazalog.Info("RemoveGroup, streamKey:", key.String()) m.mutex.Lock() defer m.mutex.Unlock() if key.AppName == "" { if shouldMatch && m.onlyStreamNameGroups[key.StreamName] != group { return } delete(m.onlyStreamNameGroups, key.StreamName) return } deleted := false if groups, ok := m.appNameStreamNameGroups[key.AppName]; ok { if current, ok := groups[key.StreamName]; ok { if shouldMatch && current != group { return } delete(groups, key.StreamName) deleted = true } if len(groups) == 0 { delete(m.appNameStreamNameGroups, key.AppName) } } if !deleted { if shouldMatch && m.onlyStreamNameGroups[key.StreamName] != group { return } delete(m.onlyStreamNameGroups, key.StreamName) } } func (m *ComplexGroupManager) RemoveGroupByStreamName(streamName string) { m.RemoveGroup(StreamKeyFromStreamName(streamName)) } func (m *ComplexGroupManager) GetGroup(key StreamKey) (bool, *Group) { if m == nil || !key.Valid() { return false, nil } m.mutex.RLock() defer m.mutex.RUnlock() return m.getGroupLocked(key) } func (m *ComplexGroupManager) getGroupLocked(key StreamKey) (bool, *Group) { if key.AppName == "" { if group, ok := m.onlyStreamNameGroups[key.StreamName]; ok { return true, group } return m.getGroupByOnlyStreamNameLocked(key.StreamName) } if groups, ok := m.appNameStreamNameGroups[key.AppName]; ok { if group, ok := groups[key.StreamName]; ok { return true, group } } if group, ok := m.onlyStreamNameGroups[key.StreamName]; ok { return true, group } return false, nil } func (m *ComplexGroupManager) GetGroupByStreamName(streamName string) (bool, *Group) { return m.GetGroup(StreamKeyFromStreamName(streamName)) } // WaitGroup 等待流就绪,轮询 interval 间隔,总超时 timeout // 为什么:GB28181 设备推流有延迟,播放端先于推流端到达,需短暂等待 func (m *ComplexGroupManager) WaitGroup(key StreamKey, interval, timeout time.Duration) (bool, *Group) { deadline := time.Now().Add(timeout) for { if ok, g := m.GetGroup(key); ok { return true, g } if time.Now().After(deadline) { return false, nil } time.Sleep(interval) } } // streamName 单独查找只在匹配唯一 appName 时成功,避免跨 app 串流。 func (m *ComplexGroupManager) getGroupByOnlyStreamNameLocked(streamName string) (bool, *Group) { var found *Group matchCount := 0 for _, groups := range m.appNameStreamNameGroups { if group, ok := groups[streamName]; ok { found = group matchCount++ if matchCount > 1 { nazalog.Warn("streamName matched multiple appName groups, streamName:", streamName) return false, nil } } } return matchCount == 1, found } func (m *ComplexGroupManager) Iterate(onIterateGroup func(key StreamKey, group *Group) bool) { if m == nil || onIterateGroup == nil { return } type entry struct { key StreamKey group *Group } entries := make([]entry, 0, m.Len()) m.mutex.RLock() for streamName, group := range m.onlyStreamNameGroups { entries = append(entries, entry{key: StreamKeyFromStreamName(streamName), group: group}) } for appName, groups := range m.appNameStreamNameGroups { for streamName, group := range groups { entries = append(entries, entry{key: NewStreamKey(appName, streamName), group: group}) } } m.mutex.RUnlock() for _, item := range entries { if !onIterateGroup(item.key, item.group) { m.RemoveGroupIfMatch(item.key, item.group) } } } func (m *ComplexGroupManager) Len() int { if m == nil { return 0 } m.mutex.RLock() defer m.mutex.RUnlock() count := len(m.onlyStreamNameGroups) for _, groups := range m.appNameStreamNameGroups { count += len(groups) } return count } ================================================ FILE: logic/group_test.go ================================================ package logic import ( "sync" "testing" "time" "github.com/q191201771/lal/pkg/base" ) type recordSubscriber struct { mu sync.Mutex msgs []base.RtmpMsg stopCount int } func (s *recordSubscriber) OnMsg(msg base.RtmpMsg) { s.mu.Lock() defer s.mu.Unlock() s.msgs = append(s.msgs, msg.Clone()) } func (s *recordSubscriber) OnStop() { s.mu.Lock() defer s.mu.Unlock() s.stopCount++ } func (s *recordSubscriber) len() int { s.mu.Lock() defer s.mu.Unlock() return len(s.msgs) } func (s *recordSubscriber) markerAt(idx int) byte { s.mu.Lock() defer s.mu.Unlock() return payloadMarker(s.msgs[idx]) } func (s *recordSubscriber) stopCountValue() int { s.mu.Lock() defer s.mu.Unlock() return s.stopCount } type blockingSubscriber struct { mu sync.Mutex msgs []base.RtmpMsg blocked chan struct{} release chan struct{} replaying bool blockOnce sync.Once } func newBlockingSubscriber() *blockingSubscriber { return &blockingSubscriber{ blocked: make(chan struct{}), release: make(chan struct{}), } } func (s *blockingSubscriber) OnMsg(msg base.RtmpMsg) { s.mu.Lock() s.msgs = append(s.msgs, msg.Clone()) shouldBlock := s.replaying s.mu.Unlock() if shouldBlock { s.blockOnce.Do(func() { close(s.blocked) <-s.release }) } } func (s *blockingSubscriber) OnStop() {} func (s *blockingSubscriber) OnReplayStart() { s.mu.Lock() s.replaying = true s.mu.Unlock() } func (s *blockingSubscriber) OnReplayStop() { s.mu.Lock() s.replaying = false s.mu.Unlock() } func (s *blockingSubscriber) markers() []byte { s.mu.Lock() defer s.mu.Unlock() out := make([]byte, 0, len(s.msgs)) for _, msg := range s.msgs { out = append(out, payloadMarker(msg)) } return out } type selfRemovingSubscriber struct { group *Group id string mu sync.Mutex msgs []base.RtmpMsg } func (s *selfRemovingSubscriber) OnMsg(msg base.RtmpMsg) { s.mu.Lock() s.msgs = append(s.msgs, msg.Clone()) shouldRemove := len(s.msgs) == 1 s.mu.Unlock() if shouldRemove { s.group.RemoveConsumer(s.id) } } func (s *selfRemovingSubscriber) OnStop() {} func (s *selfRemovingSubscriber) len() int { s.mu.Lock() defer s.mu.Unlock() return len(s.msgs) } func (s *selfRemovingSubscriber) markerAt(idx int) byte { s.mu.Lock() defer s.mu.Unlock() return payloadMarker(s.msgs[idx]) } type statSubscriber struct { mu sync.Mutex stat SubscriberStat } func (s *statSubscriber) OnMsg(msg base.RtmpMsg) {} func (s *statSubscriber) OnStop() {} func (s *statSubscriber) GetSubscriberStat() SubscriberStat { s.mu.Lock() defer s.mu.Unlock() return s.stat } func (s *statSubscriber) setStat(stat SubscriberStat) { s.mu.Lock() defer s.mu.Unlock() s.stat = stat } func videoSeqHeader(marker byte) base.RtmpMsg { return base.RtmpMsg{ Header: base.RtmpHeader{MsgTypeId: base.RtmpTypeIdVideo}, Payload: []byte{ base.RtmpAvcKeyFrame, base.RtmpAvcPacketTypeSeqHeader, 0, 0, 0, marker, }, } } func videoKeyNalu(marker byte) base.RtmpMsg { return base.RtmpMsg{ Header: base.RtmpHeader{MsgTypeId: base.RtmpTypeIdVideo}, Payload: []byte{ base.RtmpAvcKeyFrame, base.RtmpAvcPacketTypeNalu, 0, 0, 0, marker, }, } } func videoInterNalu(marker byte) base.RtmpMsg { return base.RtmpMsg{ Header: base.RtmpHeader{MsgTypeId: base.RtmpTypeIdVideo}, Payload: []byte{ base.RtmpAvcInterFrame, base.RtmpAvcPacketTypeNalu, 0, 0, 0, marker, }, } } func aacSeqHeader(marker byte) base.RtmpMsg { return base.RtmpMsg{ Header: base.RtmpHeader{MsgTypeId: base.RtmpTypeIdAudio}, Payload: []byte{ base.RtmpSoundFormatAac << 4, base.RtmpAacPacketTypeSeqHeader, marker, }, } } func aacRaw(marker byte) base.RtmpMsg { return base.RtmpMsg{ Header: base.RtmpHeader{MsgTypeId: base.RtmpTypeIdAudio}, Payload: []byte{ base.RtmpSoundFormatAac << 4, base.RtmpAacPacketTypeRaw, marker, }, } } func g711aAudio(marker byte) base.RtmpMsg { return base.RtmpMsg{ Header: base.RtmpHeader{MsgTypeId: base.RtmpTypeIdAudio}, Payload: []byte{base.RtmpSoundFormatG711A<<4 | marker}, } } func payloadMarker(msg base.RtmpMsg) byte { return msg.Payload[len(msg.Payload)-1] } func newTestGroup(streamName string) *Group { group, _ := GetGroupManagerInstance().GetOrCreateGroupByStreamName(streamName, streamName, nil, 1, 0) return group } func testSubscriberState(t *testing.T, group *Group, subscriberID string) *subscriberState { t.Helper() value, ok := group.consumers.Load(subscriberID) if !ok { t.Fatalf("subscriber %s not found", subscriberID) } state, ok := value.(*subscriberState) if !ok { t.Fatalf("subscriber %s has unexpected type %T", subscriberID, value) } return state } func TestAddConsumerReplaysCachedGopImmediately(t *testing.T) { group := newTestGroup("test-replay") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-replay") group.OnMsg(videoSeqHeader(1)) group.OnMsg(aacSeqHeader(2)) group.OnMsg(videoKeyNalu(3)) group.OnMsg(aacRaw(4)) group.OnMsg(videoInterNalu(5)) sub := &recordSubscriber{} group.AddConsumer("consumer", sub) if sub.len() != 5 { t.Fatalf("expected 5 replay messages, got %d", sub.len()) } wantMarkers := []byte{1, 2, 3, 4, 5} for i, want := range wantMarkers { if got := sub.markerAt(i); got != want { t.Fatalf("message %d marker = %d, want %d", i, got, want) } } } func TestVideoSeqHeaderChangeClearsStaleGop(t *testing.T) { group := newTestGroup("test-clear") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-clear") group.OnMsg(videoSeqHeader(1)) group.OnMsg(videoKeyNalu(2)) group.OnMsg(videoInterNalu(3)) group.OnMsg(videoSeqHeader(4)) sub := &recordSubscriber{} group.AddConsumer("consumer", sub) if sub.len() != 0 { t.Fatalf("expected no stale GOP replay after sequence header change, got %d messages", sub.len()) } group.OnMsg(videoKeyNalu(5)) if sub.len() != 2 { t.Fatalf("expected new header and current key frame, got %d messages", sub.len()) } if got := sub.markerAt(0); got != 4 { t.Fatalf("header marker = %d, want 4", got) } if got := sub.markerAt(1); got != 5 { t.Fatalf("key frame marker = %d, want 5", got) } } func TestNonAacAudioIsNotReplayedAsHeader(t *testing.T) { group := newTestGroup("test-g711") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-g711") group.OnMsg(videoSeqHeader(1)) group.OnMsg(videoKeyNalu(2)) group.OnMsg(g711aAudio(3)) sub := &recordSubscriber{} group.AddConsumer("consumer", sub) if sub.len() != 3 { t.Fatalf("expected video header, key frame and one G711 packet, got %d messages", sub.len()) } wantMarkers := []byte{1, 2, base.RtmpSoundFormatG711A<<4 | 3} for i, want := range wantMarkers { if got := sub.markerAt(i); got != want { t.Fatalf("message %d marker = %d, want %d", i, got, want) } } } func TestAddConsumerWithReplayDisabledDoesNotReplayCachedGop(t *testing.T) { group := newTestGroup("test-no-replay") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-no-replay") group.OnMsg(videoSeqHeader(1)) group.OnMsg(videoKeyNalu(2)) group.OnMsg(videoInterNalu(3)) sub := &recordSubscriber{} group.AddConsumerWithReplay("consumer", sub, false) if sub.len() != 0 { t.Fatalf("expected no cached messages when replay is disabled, got %d messages", sub.len()) } group.OnMsg(videoInterNalu(4)) if sub.len() != 0 { t.Fatalf("expected to wait for next key frame, got %d messages", sub.len()) } group.OnMsg(videoKeyNalu(5)) if sub.len() != 2 { t.Fatalf("expected header and current key frame, got %d messages", sub.len()) } if got := sub.markerAt(0); got != 1 { t.Fatalf("header marker = %d, want 1", got) } if got := sub.markerAt(1); got != 5 { t.Fatalf("key frame marker = %d, want 5", got) } } func TestAddConsumerReplayDoesNotInterleaveWithLiveKeyFrame(t *testing.T) { group := newTestGroup("test-replay-order") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-replay-order") group.OnMsg(videoSeqHeader(1)) group.OnMsg(videoKeyNalu(2)) group.OnMsg(videoInterNalu(3)) sub := newBlockingSubscriber() addDone := make(chan struct{}) go func() { group.AddConsumer("consumer", sub) close(addDone) }() <-sub.blocked liveDone := make(chan struct{}) go func() { group.OnMsg(videoKeyNalu(4)) close(liveDone) }() select { case <-liveDone: t.Fatal("live key frame should not be delivered before cached GOP replay finishes") case <-time.After(50 * time.Millisecond): } close(sub.release) <-addDone <-liveDone wantMarkers := []byte{1, 2, 3, 4} gotMarkers := sub.markers() if len(gotMarkers) != len(wantMarkers) { t.Fatalf("markers = %v, want %v", gotMarkers, wantMarkers) } for i, want := range wantMarkers { if got := gotMarkers[i]; got != want { t.Fatalf("message %d marker = %d, want %d, all=%v", i, got, want, gotMarkers) } } } func TestSubscriberRemovingItselfStopsReplayDelivery(t *testing.T) { group := newTestGroup("test-self-remove-replay") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-self-remove-replay") group.OnMsg(videoSeqHeader(1)) group.OnMsg(videoKeyNalu(2)) group.OnMsg(videoInterNalu(3)) sub := &selfRemovingSubscriber{group: group, id: "consumer"} group.AddConsumer(sub.id, sub) if sub.len() != 1 { t.Fatalf("messages after self remove = %d, want 1", sub.len()) } if got := sub.markerAt(0); got != 1 { t.Fatalf("first marker = %d, want 1", got) } group.OnMsg(videoKeyNalu(4)) if sub.len() != 1 { t.Fatalf("messages after live frame = %d, want 1", sub.len()) } } func TestSubscriberRemovingItselfStopsHeaderAndLiveDelivery(t *testing.T) { group := newTestGroup("test-self-remove-live") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-self-remove-live") group.OnMsg(videoSeqHeader(1)) group.OnMsg(aacSeqHeader(2)) sub := &selfRemovingSubscriber{group: group, id: "consumer"} group.AddConsumerWithReplay(sub.id, sub, false) group.OnMsg(videoKeyNalu(3)) if sub.len() != 1 { t.Fatalf("messages after self remove = %d, want 1", sub.len()) } if got := sub.markerAt(0); got != 1 { t.Fatalf("first marker = %d, want 1", got) } } func TestGroupManagerSupportsAppNameAndStreamName(t *testing.T) { manager := NewComplexGroupManager() group := &Group{key: NewStreamKey("live", "camera")} manager.setGroup(group.Key(), group) ok, got := manager.GetGroup(NewStreamKey("live", "camera")) if !ok || got != group { t.Fatal("expected exact appName and streamName lookup") } ok, got = manager.GetGroup(StreamKeyFromStreamName("camera")) if !ok || got != group { t.Fatal("expected streamName-only lookup to find the unique appName group") } } func TestGroupManagerStreamNameFallbackRejectsAmbiguousAppName(t *testing.T) { manager := NewComplexGroupManager() manager.setGroup(NewStreamKey("app1", "camera"), &Group{key: NewStreamKey("app1", "camera")}) manager.setGroup(NewStreamKey("app2", "camera"), &Group{key: NewStreamKey("app2", "camera")}) ok, got := manager.GetGroup(StreamKeyFromStreamName("camera")) if ok || got != nil { t.Fatal("expected ambiguous streamName-only lookup to fail") } } func TestGroupManagerGetOrCreateGroupReturnsExisting(t *testing.T) { manager := NewComplexGroupManager() key := NewStreamKey("live", "camera") group, created := manager.GetOrCreateGroup(key, "first", nil, 1, 0) if !created || group == nil { t.Fatal("expected group to be created") } got, created := manager.GetOrCreateGroup(key, "second", nil, 1, 0) if created || got != group { t.Fatal("expected existing group to be returned") } if got.UniqueKey() != "first" { t.Fatalf("unique key = %s, want first", got.UniqueKey()) } } func TestGroupManagerGetOrCreateWaitsForClosedGroupCleanup(t *testing.T) { manager := NewComplexGroupManager() key := StreamKeyFromStreamName("camera") oldGroup := &Group{key: key} oldGroup.closed.Store(true) manager.setGroup(key, oldGroup) oldGroup.lifecycleMux.Lock() done := make(chan struct { group *Group created bool }) go func() { group, created := manager.GetOrCreateGroup(key, "new", nil, 1, 0) done <- struct { group *Group created bool }{group: group, created: created} }() select { case <-done: t.Fatal("new group should wait for old group cleanup") case <-time.After(50 * time.Millisecond): } oldGroup.lifecycleMux.Unlock() select { case result := <-done: if !result.created || result.group == nil || result.group == oldGroup { t.Fatalf("unexpected group result: group=%p created=%v", result.group, result.created) } case <-time.After(time.Second): t.Fatal("new group was not created after old group cleanup") } } func TestGroupManagerGetOrCreateReturnsReplacementAfterWaitingClosedGroup(t *testing.T) { manager := NewComplexGroupManager() key := StreamKeyFromStreamName("camera") oldGroup := &Group{key: key} replacement := &Group{key: key} oldGroup.closed.Store(true) manager.setGroup(key, oldGroup) oldGroup.lifecycleMux.Lock() done := make(chan struct { group *Group created bool }) go func() { group, created := manager.GetOrCreateGroup(key, "new", nil, 1, 0) done <- struct { group *Group created bool }{group: group, created: created} }() time.Sleep(50 * time.Millisecond) manager.setGroup(key, replacement) oldGroup.lifecycleMux.Unlock() select { case result := <-done: if result.created || result.group != replacement { t.Fatalf("unexpected group result: group=%p replacement=%p created=%v", result.group, replacement, result.created) } case <-time.After(time.Second): t.Fatal("replacement group was not returned after old group cleanup") } } func TestGroupManagerRemoveGroupIfMatchDoesNotRemoveNewGroup(t *testing.T) { manager := NewComplexGroupManager() key := StreamKeyFromStreamName("camera") oldGroup := &Group{key: key} newGroup := &Group{key: key} manager.setGroup(key, oldGroup) manager.setGroup(key, newGroup) manager.RemoveGroupIfMatch(key, oldGroup) ok, got := manager.GetGroup(key) if !ok || got != newGroup { t.Fatal("old group stop should not remove new group") } } func TestGroupManagerIterateRemoveDoesNotRemoveReplacement(t *testing.T) { manager := NewComplexGroupManager() key := StreamKeyFromStreamName("camera") oldGroup := &Group{key: key} newGroup := &Group{key: key} manager.setGroup(key, oldGroup) manager.Iterate(func(iterKey StreamKey, group *Group) bool { if iterKey != key || group != oldGroup { t.Fatalf("unexpected iterate entry, key=%v group=%p", iterKey, group) } manager.setGroup(key, newGroup) return false }) ok, got := manager.GetGroup(key) if !ok || got != newGroup { t.Fatal("iterate removal should not remove a replacement group") } } func TestGopCacheClearReleasesStaleGopPayloads(t *testing.T) { cache := NewGopCache(1, 0) cache.Feed(videoKeyNalu(1)) cache.Feed(videoInterNalu(2)) cache.Clear() if cache.GetGopCount() != 0 { t.Fatalf("gop count = %d, want 0", cache.GetGopCount()) } for i, gop := range cache.data { if gop.data != nil { t.Fatalf("gop %d data was not released", i) } } } func TestGopCacheNegativeFrameLimitMeansUnlimited(t *testing.T) { cache := NewGopCache(1, -1) cache.Feed(videoKeyNalu(1)) cache.Feed(videoInterNalu(2)) msgs := cache.GetGopDataAt(0) if len(msgs) != 2 { t.Fatalf("cached messages = %d, want 2", len(msgs)) } } func TestOnStopIsIdempotentAndClosesSubscribers(t *testing.T) { group := newTestGroup("test-stop") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-stop") sub := &recordSubscriber{} group.AddConsumer("consumer", sub) group.OnStop() group.OnStop() if sub.stopCountValue() != 1 { t.Fatalf("stop count = %d, want 1", sub.stopCountValue()) } group.OnMsg(videoKeyNalu(1)) if sub.len() != 0 { t.Fatalf("expected no messages after stop, got %d", sub.len()) } } func TestOnMsgTriggersActiveHookOnceOnFirstMediaPacket(t *testing.T) { group := newTestGroup("test-active-hook") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-active-hook") key := StreamKeyFromStreamName("test-active-hook") var got []StreamKey group.BindActiveHook(key, func(k StreamKey) { got = append(got, k) }) group.OnMsg(videoSeqHeader(1)) group.OnMsg(aacSeqHeader(2)) if len(got) != 0 { t.Fatalf("active hook count after seq header = %d, want 0", len(got)) } group.OnMsg(videoKeyNalu(3)) group.OnMsg(aacRaw(4)) if len(got) != 1 { t.Fatalf("active hook count = %d, want 1", len(got)) } if got[0] != key { t.Fatalf("active hook key = %+v, want %+v", got[0], key) } } func TestAddSubscriberAfterStopIsIgnored(t *testing.T) { group := newTestGroup("test-add-after-stop") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-add-after-stop") group.OnStop() sub := &recordSubscriber{} group.AddConsumer("consumer", sub) group.OnMsg(videoKeyNalu(1)) if sub.len() != 0 { t.Fatalf("expected no messages after adding to stopped group, got %d", sub.len()) } if len(group.StatSubscribers()) != 0 { t.Fatalf("expected no subscribers after adding to stopped group, got %d", len(group.StatSubscribers())) } } func TestDuplicateSubscriberIDIsIgnored(t *testing.T) { group := newTestGroup("test-duplicate") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-duplicate") first := &recordSubscriber{} second := &recordSubscriber{} group.AddConsumer("consumer", first) group.AddConsumer("consumer", second) group.OnMsg(videoKeyNalu(1)) if first.len() != 1 { t.Fatalf("first subscriber messages = %d, want 1", first.len()) } if second.len() != 0 { t.Fatalf("duplicate subscriber messages = %d, want 0", second.len()) } } func TestStatSubscribersRefreshRuntimeStats(t *testing.T) { group := newTestGroup("test-stat-refresh") defer GetGroupManagerInstance().RemoveGroupByStreamName("test-stat-refresh") sub := &statSubscriber{} sub.setStat(SubscriberStat{ RemoteAddr: "127.0.0.1:9000", ReadBytesSum: 1024, WroteBytesSum: 2048, }) group.AddSubscriber(SubscriberInfo{ SubscriberID: "stat-sub", Protocol: SubscriberProtocolWHEP, }, sub) state := testSubscriberState(t, group, "stat-sub") state.UpdateStat(2) subs := group.StatSubscribers() if len(subs) != 1 { t.Fatalf("subscriber count = %d, want 1", len(subs)) } stat := subs[0] if stat.RemoteAddr != "127.0.0.1:9000" { t.Fatalf("remote addr = %s, want 127.0.0.1:9000", stat.RemoteAddr) } if stat.ReadBytesSum != 1024 { t.Fatalf("read bytes = %d, want 1024", stat.ReadBytesSum) } if stat.WroteBytesSum != 2048 { t.Fatalf("wrote bytes = %d, want 2048", stat.WroteBytesSum) } if stat.ReadBitrateKbits != 4 { t.Fatalf("read bitrate = %d, want 4", stat.ReadBitrateKbits) } if stat.WriteBitrateKbits != 8 { t.Fatalf("write bitrate = %d, want 8", stat.WriteBitrateKbits) } if stat.BitrateKbits != 8 { t.Fatalf("bitrate = %d, want 8", stat.BitrateKbits) } sub.setStat(SubscriberStat{ RemoteAddr: "127.0.0.1:9001", ReadBytesSum: 1536, WroteBytesSum: 3072, }) state.UpdateStat(1) subs = group.StatSubscribers() stat = subs[0] if stat.RemoteAddr != "127.0.0.1:9001" { t.Fatalf("remote addr = %s, want 127.0.0.1:9001", stat.RemoteAddr) } if stat.ReadBitrateKbits != 4 { t.Fatalf("read bitrate after increment = %d, want 4", stat.ReadBitrateKbits) } if stat.WriteBitrateKbits != 8 { t.Fatalf("write bitrate after increment = %d, want 8", stat.WriteBitrateKbits) } } ================================================ FILE: logic/stat_aggregator.go ================================================ package logic import "github.com/q191201771/lal/pkg/base" // StatAggregator merges lal native group state with lalmax extension subscribers. type StatAggregator struct { groupManager IGroupManager } type StatGroupView struct { Group base.StatGroup ExtSubs []base.StatSub } func NewStatAggregator(groupManager IGroupManager) *StatAggregator { if groupManager == nil { groupManager = GetGroupManagerInstance() } return &StatAggregator{groupManager: groupManager} } func (a *StatAggregator) ExtSubscribers(key StreamKey) []base.StatSub { if a == nil || a.groupManager == nil || !key.Valid() { return nil } exist, extGroup := a.groupManager.GetGroup(key) if !exist || extGroup == nil { return nil } extSubs := extGroup.StatSubscribers() if len(extSubs) == 0 { return nil } out := make([]base.StatSub, len(extSubs)) copy(out, extSubs) return out } func (a *StatAggregator) BuildGroupView(group base.StatGroup) StatGroupView { extSubs := a.ExtSubscribers(NewStreamKey(group.AppName, group.StreamName)) if len(extSubs) != 0 { group.StatSubs = append(group.StatSubs, extSubs...) } else { extSubs = make([]base.StatSub, 0) } return StatGroupView{ Group: group, ExtSubs: extSubs, } } func (a *StatAggregator) BuildGroupsView(groups []base.StatGroup) []StatGroupView { if len(groups) == 0 { return nil } out := make([]StatGroupView, len(groups)) for i, group := range groups { out[i] = a.BuildGroupView(group) } return out } func (a *StatAggregator) MergeGroup(group base.StatGroup) base.StatGroup { return a.BuildGroupView(group).Group } func (a *StatAggregator) MergeGroups(groups []base.StatGroup) []base.StatGroup { if len(groups) == 0 { return groups } out := make([]base.StatGroup, len(groups)) for i, group := range groups { out[i] = a.MergeGroup(group) } return out } func (a *StatAggregator) FindGroupView(groups []base.StatGroup, key StreamKey) *StatGroupView { if !key.Valid() { return nil } var matched *StatGroupView for i := range groups { group := groups[i] if group.StreamName != key.StreamName { continue } if key.AppName != "" { if group.AppName != key.AppName { continue } view := a.BuildGroupView(group) return &view } if matched != nil { return nil } view := a.BuildGroupView(group) matched = &view } return matched } func (a *StatAggregator) FindGroup(groups []base.StatGroup, key StreamKey) *base.StatGroup { view := a.FindGroupView(groups, key) if view == nil { return nil } return &view.Group } ================================================ FILE: logic/stream_key.go ================================================ package logic type StreamKey struct { // AppName 为空表示兼容历史的 streamName 单键查找。 AppName string StreamName string } func NewStreamKey(appName, streamName string) StreamKey { return StreamKey{ AppName: appName, StreamName: streamName, } } func StreamKeyFromStreamName(streamName string) StreamKey { return NewStreamKey("", streamName) } func (key StreamKey) Valid() bool { return key.StreamName != "" } func (key StreamKey) String() string { if key.AppName == "" { return key.StreamName } return key.AppName + "/" + key.StreamName } ================================================ FILE: logic/subscriber_stat.go ================================================ package logic // SubscriberStat is the runtime traffic snapshot for a lalmax external subscriber. type SubscriberStat struct { RemoteAddr string ReadBytesSum uint64 WroteBytesSum uint64 } // SubscriberStatProvider exposes runtime traffic stats for ext_subs sessions. type SubscriberStatProvider interface { GetSubscriberStat() SubscriberStat } ================================================ FILE: main.go ================================================ package main import ( "flag" "fmt" "os" "path/filepath" "github.com/q191201771/lalmax/server" "github.com/q191201771/naza/pkg/nazalog" "github.com/q191201771/lal/pkg/base" config "github.com/q191201771/lalmax/config" "github.com/q191201771/naza/pkg/bininfo" ) func main() { defer nazalog.Sync() confFilename := parseFlag() err := config.Open(confFilename) if err != nil { nazalog.Errorf("open config failed, configname:%+v", confFilename) return } maxConf := config.GetConfig() maxConf.ConfFilePath = confFilename svr, err := server.NewLalMaxServer(maxConf) if err != nil { nazalog.Fatalf("create lalmax server failed. err=%+v", err) } if err = svr.Run(); err != nil { nazalog.Infof("server manager done. err=%+v", err) } } func parseFlag() string { binInfoFlag := flag.Bool("v", false, "show bin info") cf := flag.String("c", "", "specify conf file") p := flag.String("p", "", "specify current work directory") flag.Parse() if *binInfoFlag { _, _ = fmt.Fprint(os.Stderr, bininfo.StringifyMultiLine()) _, _ = fmt.Fprintln(os.Stderr, base.LalFullInfo) os.Exit(0) } if *p != "" { os.Chdir(*p) } if *cf != "" { return *cf } nazalog.Warnf("config file did not specify in the command line, try to load it in the usual path.") defaultConfigFileList := []string{ filepath.FromSlash("lalmax.conf.json"), filepath.FromSlash("./conf/lalmax.conf.json"), filepath.FromSlash("../conf/lalmax.conf.json"), } for _, dcf := range defaultConfigFileList { fi, err := os.Stat(dcf) if err == nil && fi.Size() > 0 && !fi.IsDir() { nazalog.Warnf("%s exist. using it as config file.", dcf) return dcf } else { nazalog.Warnf("%s not exist.", dcf) } } // 默认位置都没有,退出程序 flag.Usage() _, _ = fmt.Fprintf(os.Stderr, ` Example: %s -c %s `, os.Args[0], filepath.FromSlash("./conf/lalmax.conf.json")) base.OsExitAndWaitPressIfWindows(1) return *cf } ================================================ FILE: rtc/jessibucasession.go ================================================ package rtc import ( "context" "math" "sync" "sync/atomic" "github.com/gofrs/uuid" "github.com/pion/webrtc/v3" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/httpflv" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/lal/pkg/remux" maxlogic "github.com/q191201771/lalmax/logic" "github.com/q191201771/naza/pkg/nazalog" "github.com/smallnest/chanx" ) type jessibucaSession struct { group *maxlogic.Group pc *peerConnection subscriberId string lalServer logic.ILalServer videoTrack *webrtc.TrackLocalStaticRTP audioTrack *webrtc.TrackLocalStaticRTP videopacker *Packer audiopacker *Packer msgChan *chanx.UnboundedChan[base.RtmpMsg] closeChan chan bool remoteSafari bool DC *webrtc.DataChannel streamId string cancel context.CancelFunc stopOne sync.Once wroteBytes atomic.Uint64 remoteAddr atomic.Value } func NewJessibucaSession(appName, streamid string, writeChanSize int, pc *peerConnection, lalServer logic.ILalServer) *jessibucaSession { ok, group := maxlogic.GetGroupManagerInstance().GetGroup(maxlogic.NewStreamKey(appName, streamid)) if !ok { nazalog.Errorf("not found stream, appName:%s, streamid:%s", appName, streamid) return nil } u, _ := uuid.NewV4() ctx, cancel := context.WithCancel(context.Background()) return &jessibucaSession{ group: group, pc: pc, lalServer: lalServer, subscriberId: u.String(), streamId: streamid, cancel: cancel, msgChan: chanx.NewUnboundedChan[base.RtmpMsg](ctx, writeChanSize), closeChan: make(chan bool, 1), } } func (conn *jessibucaSession) createDataChannel() (err error) { if conn.DC != nil { return nil } conn.DC, err = conn.pc.CreateDataChannel(conn.streamId, nil) return } func (conn *jessibucaSession) GetAnswerSDP(offer string) (sdp string) { var err error err = conn.createDataChannel() if err != nil { nazalog.Error(err) return } gatherComplete := webrtc.GatheringCompletePromise(conn.pc.PeerConnection) conn.pc.SetRemoteDescription(webrtc.SessionDescription{ Type: webrtc.SDPTypeOffer, SDP: string(offer), }) answer, err := conn.pc.CreateAnswer(nil) if err != nil { nazalog.Error(err) return } err = conn.pc.SetLocalDescription(answer) if err != nil { nazalog.Error(err) return } <-gatherComplete sdp = conn.pc.LocalDescription().SDP return } func (conn *jessibucaSession) Run() { ok, _ := maxlogic.GetGroupManagerInstance().GetGroup(conn.group.Key()) if ok { conn.group.AddSubscriber(maxlogic.SubscriberInfo{ SubscriberID: conn.subscriberId, Protocol: maxlogic.SubscriberProtocolJessibuca, }, conn) conn.pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { nazalog.Info("peer connection state: ", state.String()) switch state { case webrtc.PeerConnectionStateConnected: case webrtc.PeerConnectionStateDisconnected: fallthrough case webrtc.PeerConnectionStateFailed: fallthrough case webrtc.PeerConnectionStateClosed: conn.closeChan <- true } }) if conn.DC != nil { conn.DC.OnOpen(func() { if err := conn.DC.Send(httpflv.FlvHeader); err != nil { nazalog.Warnf(" stream write videoHeader err:%s", err.Error()) return } conn.wroteBytes.Add(uint64(len(httpflv.FlvHeader))) conn.refreshRemoteAddr() defer func() { nazalog.Info("RemoveConsumer, connid:", conn.subscriberId) conn.group.RemoveSubscriber(conn.subscriberId) conn.DC.Close() conn.pc.Close() conn.DC = nil conn.cancel() }() for { select { case msg := <-conn.msgChan.Out: lazyRtmpMsg2FlvTag := remux.LazyRtmpMsg2FlvTag{} lazyRtmpMsg2FlvTag.Init(msg) buf := lazyRtmpMsg2FlvTag.GetEnsureWithoutSdf() sendBuf := chunkSlice(buf, math.MaxUint16) for _, v := range sendBuf { if err := conn.DC.Send(v); err != nil { nazalog.Warnf(" stream write msg err:%s", err.Error()) return } conn.wroteBytes.Add(uint64(len(v))) } case <-conn.closeChan: return } } }) } } } func chunkSlice(slice []byte, size int) [][]byte { var chunks [][]byte for i := 0; i < len(slice); i += size { end := i + size if end > len(slice) { end = len(slice) } chunks = append(chunks, slice[i:end]) } return chunks } func (conn *jessibucaSession) OnMsg(msg base.RtmpMsg) { switch msg.Header.MsgTypeId { case base.RtmpTypeIdMetadata: return case base.RtmpTypeIdAudio: if conn.DC != nil { conn.msgChan.In <- msg } case base.RtmpTypeIdVideo: if conn.DC != nil { conn.msgChan.In <- msg } } } func (conn *jessibucaSession) OnStop() { conn.stopOne.Do(func() { conn.closeChan <- true }) } func (conn *jessibucaSession) Close() { if conn.DC != nil { conn.DC.Close() } if conn.pc != nil { conn.pc.Close() } } func (conn *jessibucaSession) GetSubscriberStat() maxlogic.SubscriberStat { conn.refreshRemoteAddr() return maxlogic.SubscriberStat{ RemoteAddr: conn.loadRemoteAddr(), WroteBytesSum: conn.wroteBytes.Load(), } } func (conn *jessibucaSession) refreshRemoteAddr() { if remoteAddr := conn.currentRemoteAddr(); remoteAddr != "" { conn.remoteAddr.Store(remoteAddr) } } func (conn *jessibucaSession) currentRemoteAddr() string { if conn.DC != nil && conn.DC.Transport() != nil { if dtls := conn.DC.Transport().Transport(); dtls != nil { if remoteAddr := remoteAddrFromDTLSTransport(dtls); remoteAddr != "" { return remoteAddr } } } if sctp := conn.pc.SCTP(); sctp != nil { return remoteAddrFromDTLSTransport(sctp.Transport()) } return "" } func (conn *jessibucaSession) loadRemoteAddr() string { v := conn.remoteAddr.Load() addr, _ := v.(string) return addr } ================================================ FILE: rtc/packer.go ================================================ package rtc import ( "fmt" "github.com/pion/rtp" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/hevc" "github.com/q191201771/lal/pkg/rtprtcp" "github.com/q191201771/naza/pkg/nazalog" ) const ( PacketH264 = "H264" PacketHEVC = "HEVC" PacketPCMA = "PCMA" PacketPCMU = "PCMU" PacketOPUS = "OPUS" ) type Packer struct { enc IRtpEncoder } func NewPacker(mimeType string, codec []byte) *Packer { p := &Packer{} switch mimeType { case PacketH264: p.enc = NewH264RtpEncoder(codec) case PacketPCMA: p.enc = NewG711RtpEncoder(8) case PacketPCMU: p.enc = NewG711RtpEncoder(0) case PacketHEVC: p.enc = NewHevcRtpEncoder(codec) case PacketOPUS: p.enc = NewOpusRtpEncoder(111) } return p } func (p *Packer) Encode(msg base.RtmpMsg) ([]*rtp.Packet, error) { if p == nil || p.enc == nil { return nil, fmt.Errorf("packer encoder is nil") } return p.enc.Encode(msg) } func (p *Packer) UpdateVideoCodec(vps, sps, pps []byte) { if p == nil || p.enc == nil { return } if h264Encoder, ok := p.enc.(*H264RtpEncoder); ok { h264Encoder.UpdateVideoCodec(vps, sps, pps) return } if hevcEncoder, ok := p.enc.(*HevcRtpEncoder); ok { hevcEncoder.UpdateVideoCodec(vps, sps, pps) } } type IRtpEncoder interface { Encode(msg base.RtmpMsg) ([]*rtp.Packet, error) } type H264RtpEncoder struct { IRtpEncoder sps []byte pps []byte rtpPacker *rtprtcp.RtpPacker } func NewH264RtpEncoder(codec []byte) *H264RtpEncoder { sps, pps, err := avc.ParseSpsPpsFromSeqHeader(codec) if err != nil { nazalog.Error(err) return nil } pp := rtprtcp.NewRtpPackerPayloadAvc(func(option *rtprtcp.RtpPackerPayloadAvcHevcOption) { option.Typ = rtprtcp.RtpPackerPayloadAvcHevcTypeAnnexb }) return &H264RtpEncoder{ sps: sps, pps: pps, rtpPacker: rtprtcp.NewRtpPacker(pp, 90000, 0), } } func (enc *H264RtpEncoder) Encode(msg base.RtmpMsg) ([]*rtp.Packet, error) { var out []byte err := avc.IterateNaluAvcc(msg.Payload[5:], func(nal []byte) { t := avc.ParseNaluType(nal[0]) if t == avc.NaluTypeSei { return } if t == avc.NaluTypeIdrSlice { out = append(out, avc.NaluStartCode3...) out = append(out, enc.sps...) out = append(out, avc.NaluStartCode3...) out = append(out, enc.pps...) } out = append(out, avc.NaluStartCode3...) out = append(out, nal...) }) if err != nil { return nil, fmt.Errorf("Packetize failed") } if len(out) == 0 { return nil, fmt.Errorf("Packetize failed") } avpacket := base.AvPacket{ Timestamp: int64(msg.Dts()), Payload: out, } var pkts []*rtp.Packet rtpPkts := enc.rtpPacker.Pack(avpacket) for _, pkt := range rtpPkts { var newRtpPkt rtp.Packet err := newRtpPkt.Unmarshal(pkt.Raw) if err != nil { nazalog.Error(err) continue } pkts = append(pkts, &newRtpPkt) } if len(pkts) == 0 { return nil, fmt.Errorf("Packetize failed") } return pkts, nil } func (enc *H264RtpEncoder) UpdateVideoCodec(_ []byte, sps, pps []byte) { enc.sps = sps enc.pps = pps } type G711RtpEncoder struct { IRtpEncoder rtpPacker *rtprtcp.RtpPacker } func NewG711RtpEncoder(pt uint8) *G711RtpEncoder { // TODO 暂时采样率设置为8000 pp := rtprtcp.NewRtpPackerPayloadPcm() return &G711RtpEncoder{ rtpPacker: rtprtcp.NewRtpPacker(pp, 8000, 0), } } func (enc *G711RtpEncoder) Encode(msg base.RtmpMsg) ([]*rtp.Packet, error) { avpacket := base.AvPacket{ Timestamp: int64(msg.Dts()), Payload: msg.Payload[1:], } var pkts []*rtp.Packet rtpPkts := enc.rtpPacker.Pack(avpacket) for _, pkt := range rtpPkts { var newRtpPkt rtp.Packet err := newRtpPkt.Unmarshal(pkt.Raw) if err != nil { nazalog.Error(err) continue } pkts = append(pkts, &newRtpPkt) } if len(pkts) == 0 { return nil, fmt.Errorf("Packetize failed") } return pkts, nil } type HevcRtpEncoder struct { IRtpEncoder vps []byte sps []byte pps []byte rtpPacker *rtprtcp.RtpPacker } func NewHevcRtpEncoder(codec []byte) *HevcRtpEncoder { vps, sps, pps, err := hevc.ParseVpsSpsPpsFromSeqHeader(codec) if err != nil { nazalog.Error(err) return nil } pp := rtprtcp.NewRtpPackerPayloadHevc(func(option *rtprtcp.RtpPackerPayloadAvcHevcOption) { option.Typ = rtprtcp.RtpPackerPayloadAvcHevcTypeAnnexb }) return &HevcRtpEncoder{ vps: vps, sps: sps, pps: pps, rtpPacker: rtprtcp.NewRtpPacker(pp, 90000, 0), } } func (enc *HevcRtpEncoder) Encode(msg base.RtmpMsg) ([]*rtp.Packet, error) { var out []byte err := avc.IterateNaluAvcc(msg.Payload[5:], func(nal []byte) { t := hevc.ParseNaluType(nal[0]) if t == hevc.NaluTypeSei || t == hevc.NaluTypeSeiSuffix { return } if hevc.IsIrapNalu(t) { out = append(out, avc.NaluStartCode3...) out = append(out, enc.vps...) out = append(out, avc.NaluStartCode3...) out = append(out, enc.sps...) out = append(out, avc.NaluStartCode3...) out = append(out, enc.pps...) } out = append(out, avc.NaluStartCode3...) out = append(out, nal...) }) if err != nil { return nil, fmt.Errorf("Packetize failed") } if len(out) == 0 { return nil, fmt.Errorf("Packetize failed") } avpacket := base.AvPacket{ Timestamp: int64(msg.Dts()), Payload: out, } var pkts []*rtp.Packet rtpPkts := enc.rtpPacker.Pack(avpacket) for _, pkt := range rtpPkts { var newRtpPkt rtp.Packet err := newRtpPkt.Unmarshal(pkt.Raw) if err != nil { nazalog.Error(err) continue } pkts = append(pkts, &newRtpPkt) } if len(pkts) == 0 { return nil, fmt.Errorf("Packetize failed") } return pkts, nil } func (enc *HevcRtpEncoder) UpdateVideoCodec(vps, sps, pps []byte) { enc.vps = vps enc.sps = sps enc.pps = pps } type OpusRtpEncoder struct { IRtpEncoder rtpPacker *rtprtcp.RtpPacker } func NewOpusRtpEncoder(pt uint8) *OpusRtpEncoder { pp := rtprtcp.NewRtpPackerPayloadOpus() return &OpusRtpEncoder{ rtpPacker: rtprtcp.NewRtpPacker(pp, 48000, 0), } } func (enc *OpusRtpEncoder) Encode(msg base.RtmpMsg) ([]*rtp.Packet, error) { avpacket := base.AvPacket{ Timestamp: int64(msg.Dts()), Payload: msg.Payload[1:], } var pkts []*rtp.Packet rtpPkts := enc.rtpPacker.Pack(avpacket) for _, pkt := range rtpPkts { var newRtpPkt rtp.Packet err := newRtpPkt.Unmarshal(pkt.Raw) if err != nil { nazalog.Error(err) continue } pkts = append(pkts, &newRtpPkt) } if len(pkts) == 0 { return nil, fmt.Errorf("Packetize failed") } return pkts, nil } ================================================ FILE: rtc/peerConnection.go ================================================ package rtc import ( "github.com/pion/ice/v2" "github.com/pion/interceptor" "github.com/pion/webrtc/v3" "github.com/q191201771/naza/pkg/nazalog" ) type peerConnection struct { *webrtc.PeerConnection } func newPeerConnection(ips []string, iceUDPMux ice.UDPMux, iceTCPMux ice.TCPMux) (conn *peerConnection, err error) { configuration := webrtc.Configuration{} settingsEngine := webrtc.SettingEngine{} if len(ips) != 0 { settingsEngine.SetNAT1To1IPs(ips, webrtc.ICECandidateTypeHost) } else { configuration.ICEServers = []webrtc.ICEServer{ { URLs: []string{"stun:stun.l.google.com:19302"}, }, } } if iceUDPMux != nil { settingsEngine.SetICEUDPMux(iceUDPMux) } if iceTCPMux != nil { settingsEngine.SetICETCPMux(iceTCPMux) settingsEngine.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeTCP4}) } mediaEngine := &webrtc.MediaEngine{} err = mediaEngine.RegisterCodec( webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypeH264, ClockRate: 90000, SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", }, PayloadType: 96, }, webrtc.RTPCodecTypeVideo) if err != nil { nazalog.Error(err) return } err = mediaEngine.RegisterCodec( webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypeH264, ClockRate: 90000, }, PayloadType: 103, }, webrtc.RTPCodecTypeVideo) if err != nil { nazalog.Error(err) return } err = mediaEngine.RegisterCodec( webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypeH265, ClockRate: 90000, }, PayloadType: 102, }, webrtc.RTPCodecTypeVideo) if err != nil { nazalog.Error(err) return } // opus err = mediaEngine.RegisterCodec( webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, }, PayloadType: 111, }, webrtc.RTPCodecTypeAudio) if err != nil { nazalog.Error(err) return } // PCMU err = mediaEngine.RegisterCodec( webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypePCMU, ClockRate: 8000, }, PayloadType: 0, }, webrtc.RTPCodecTypeAudio) if err != nil { nazalog.Error(err) return } // PCMA err = mediaEngine.RegisterCodec( webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypePCMA, ClockRate: 8000, }, PayloadType: 8, }, webrtc.RTPCodecTypeAudio) if err != nil { nazalog.Error(err) return } interceptorRegistry := &interceptor.Registry{} if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil { return nil, err } api := webrtc.NewAPI( webrtc.WithSettingEngine(settingsEngine), webrtc.WithMediaEngine(mediaEngine), webrtc.WithInterceptorRegistry(interceptorRegistry)) pc, err := api.NewPeerConnection(configuration) if err != nil { nazalog.Error(err) return nil, err } conn = &peerConnection{ PeerConnection: pc, } return } ================================================ FILE: rtc/server.go ================================================ package rtc import ( "fmt" "net" "net/http" "time" config "github.com/q191201771/lalmax/config" maxlogic "github.com/q191201771/lalmax/logic" "github.com/gin-gonic/gin" "github.com/pion/ice/v2" "github.com/pion/webrtc/v3" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) // StreamNotFoundFn 流不存在时的回调,触发 on_stream_not_found 通知上层拉流 type StreamNotFoundFn func(app, stream, schema string) type RtcServer struct { config config.RtcConfig lalServer logic.ILalServer udpMux ice.UDPMux tcpMux ice.TCPMux streamNotFoundFn StreamNotFoundFn } // SetStreamNotFoundFn 注入流不存在回调 func (s *RtcServer) SetStreamNotFoundFn(fn StreamNotFoundFn) { s.streamNotFoundFn = fn } // waitStreamReady 触发 on_stream_not_found 后轮询等待流就绪 // 为什么:WebRTC 播放请求先于 GB28181 设备推流到达,需通知上层拉流后等待 func (s *RtcServer) waitStreamReady(appName, streamid, schema string) bool { key := maxlogic.NewStreamKey(appName, streamid) if ok, _ := maxlogic.GetGroupManagerInstance().GetGroup(key); ok { return true } if s.streamNotFoundFn != nil { nazalog.Infof("stream not found, triggering on_stream_not_found. app=%s, stream=%s", appName, streamid) s.streamNotFoundFn(appName, streamid, schema) } ok, _ := maxlogic.GetGroupManagerInstance().WaitGroup(key, 500*time.Millisecond, 5*time.Second) return ok } func NewRtcServer(config config.RtcConfig, lal logic.ILalServer) (*RtcServer, error) { var udpMux ice.UDPMux var tcpMux ice.TCPMux if config.ICEUDPMuxPort != 0 { var udplistener *net.UDPConn udplistener, err := net.ListenUDP("udp", &net.UDPAddr{ IP: net.IP{0, 0, 0, 0}, Port: config.ICEUDPMuxPort, }) if err != nil { nazalog.Error(err) return nil, err } nazalog.Infof("webrtc ice udp listen. port=%d", config.ICEUDPMuxPort) udpMux = webrtc.NewICEUDPMux(nil, udplistener) } if config.WriteChanSize == 0 { config.WriteChanSize = 1024 } if config.ICETCPMuxPort != 0 { var tcplistener *net.TCPListener tcplistener, err := net.ListenTCP("tcp", &net.TCPAddr{ IP: net.IP{0, 0, 0, 0}, Port: config.ICETCPMuxPort, }) if err != nil { nazalog.Error(err) return nil, err } nazalog.Infof("webrtc ice tcp listen. port=%d", config.ICETCPMuxPort) tcpMux = webrtc.NewICETCPMux(nil, tcplistener, 20) } svr := &RtcServer{ config: config, lalServer: lal, udpMux: udpMux, tcpMux: tcpMux, } return svr, nil } func (s *RtcServer) HandleWHIP(c *gin.Context) { streamid := c.Request.URL.Query().Get("streamid") if streamid == "" { c.Status(http.StatusMethodNotAllowed) return } body, err := c.GetRawData() if err != nil { nazalog.Error(err) c.Status(http.StatusBadRequest) return } if len(body) == 0 { nazalog.Error("invalid body") c.Status(http.StatusNoContent) return } pc, err := newPeerConnection(s.config.ICEHostNATToIPs, s.udpMux, s.tcpMux) if err != nil { c.Status(http.StatusInternalServerError) return } whipsession := NewWhipSession(streamid, pc, s.lalServer) if whipsession == nil { c.Status(http.StatusInternalServerError) pc.Close() return } c.Header("Location", fmt.Sprintf("whip/%s", whipsession.subscriberId)) sdp := whipsession.GetAnswerSDP(string(body)) if sdp == "" { c.Status(http.StatusInternalServerError) whipsession.Close() return } go whipsession.Run() c.Data(http.StatusCreated, "application/sdp", []byte(sdp)) } // ServeWHIPPublishPage 返回内嵌推流页:浏览器直接打开 WHIP URL 即可通过 WHIP POST 建立 WebRTC 推流(与 ServeWHEPPlayPage 对称)。 func (s *RtcServer) ServeWHIPPublishPage(c *gin.Context) { if c.Request.URL.Query().Get("streamid") == "" { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusBadRequest, "WHIP

缺少查询参数 streamid。示例:/webrtc/whip?streamid=test110

") return } c.Header("Cache-Control", "no-store") c.Header("Accept-Post", "application/sdp") c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Expose-Headers", "Location") c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(buildWHIPPublishHTML())) } func buildWHIPPublishHTML() string { return "WHIP Publisher

本页使用摄像头/麦克风通过 WHIP 推流(H264+Opus)。请允许浏览器媒体权限。

connecting...
" } func (s *RtcServer) HandleJessibuca(c *gin.Context) { streamid := c.Param("streamid") if streamid == "" { c.Status(http.StatusMethodNotAllowed) return } appName := c.Query("app_name") body, err := c.GetRawData() if err != nil { nazalog.Error(err) c.Status(http.StatusBadRequest) return } if len(body) == 0 { nazalog.Error("invalid body") c.Status(http.StatusNoContent) return } if !s.waitStreamReady(appName, streamid, "rtsp") { nazalog.Errorf("stream not ready after waiting. app=%s, stream=%s", appName, streamid) c.Status(http.StatusNotFound) return } pc, err := newPeerConnection(s.config.ICEHostNATToIPs, s.udpMux, s.tcpMux) if err != nil { c.Status(http.StatusInternalServerError) return } jessibucaSession := NewJessibucaSession(appName, streamid, s.config.WriteChanSize, pc, s.lalServer) if jessibucaSession == nil { c.Status(http.StatusInternalServerError) pc.Close() return } c.Header("Location", fmt.Sprintf("jessibucaflv/%s", jessibucaSession.subscriberId)) sdp := jessibucaSession.GetAnswerSDP(string(body)) if sdp == "" { c.Status(http.StatusInternalServerError) jessibucaSession.Close() return } go jessibucaSession.Run() c.Data(http.StatusCreated, "application/sdp", []byte(sdp)) } // ServeWHEPPlayPage 返回内嵌播放页(与 topsmedia/pkg/httpflv handleWHEPPage + buildWHEPPage 对齐)。规范地址:GET /webrtc/whep?streamid=... func (s *RtcServer) ServeWHEPPlayPage(c *gin.Context) { if c.Request.URL.Query().Get("streamid") == "" { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusBadRequest, "WHEP

缺少查询参数 streamid。示例:/webrtc/whep?streamid=test110 或带 app_name/webrtc/whep?streamid=live/test110&app_name=live

") return } // 与 httpflv.handleWHEPPage 响应头一致(Gin 由框架管理 Connection,不设 close) c.Header("Cache-Control", "no-store") c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Expose-Headers", "Location") c.Header("Accept-Post", "application/sdp") c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(buildWHEPPlayHTML())) } // buildWHEPPlayHTML 与 topsmedia/pkg/httpflv buildWHEPPage 内嵌脚本与结构保持一致。 func buildWHEPPlayHTML() string { return "WHEP Player
connecting...
" } func (s *RtcServer) HandleWHEP(c *gin.Context) { streamid := c.Request.URL.Query().Get("streamid") if streamid == "" { c.Status(http.StatusMethodNotAllowed) return } appName := c.Request.URL.Query().Get("app_name") body, err := c.GetRawData() if err != nil { nazalog.Error(err) c.Status(http.StatusBadRequest) return } if len(body) == 0 { nazalog.Error("invalid body") c.Status(http.StatusNoContent) return } if !s.waitStreamReady(appName, streamid, "rtsp") { nazalog.Errorf("stream not ready after waiting. app=%s, stream=%s", appName, streamid) c.Status(http.StatusNotFound) return } pc, err := newPeerConnection(s.config.ICEHostNATToIPs, s.udpMux, s.tcpMux) if err != nil { c.Status(http.StatusInternalServerError) return } whepsession := NewWhepSession(appName, streamid, s.config.WriteChanSize, pc, s.lalServer) if whepsession == nil { c.Status(http.StatusInternalServerError) pc.Close() return } c.Header("Location", fmt.Sprintf("whep/%s", whepsession.subscriberId)) sdp := whepsession.GetAnswerSDP(string(body)) if sdp == "" { c.Status(http.StatusInternalServerError) whepsession.Close() return } go whepsession.Run() c.Data(http.StatusCreated, "application/sdp", []byte(sdp)) } // HandleZlmWebrtcPlay ZLM 兼容 WebRTC 播放,返回 SDP answer // 为什么独立方法:ZLM 信令格式为 JSON {"code":0,"sdp":"..."},与 WHEP 纯 SDP 不同 func (s *RtcServer) HandleZlmWebrtcPlay(app, stream, offer string) (string, error) { if !s.waitStreamReady(app, stream, "rtsp") { return "", fmt.Errorf("stream not found: %s/%s", app, stream) } pc, err := newPeerConnection(s.config.ICEHostNATToIPs, s.udpMux, s.tcpMux) if err != nil { return "", fmt.Errorf("create peer connection: %w", err) } session := NewWhepSession(app, stream, s.config.WriteChanSize, pc, s.lalServer) if session == nil { pc.Close() return "", fmt.Errorf("create session failed: %s/%s", app, stream) } sdp := session.GetAnswerSDP(offer) if sdp == "" { session.Close() return "", fmt.Errorf("generate answer sdp failed") } go session.Run() return sdp, nil } ================================================ FILE: rtc/subscriber_stat.go ================================================ package rtc import ( "fmt" "github.com/pion/webrtc/v3" ) func remoteAddrFromDTLSTransport(dtls *webrtc.DTLSTransport) string { if dtls == nil { return "" } return remoteAddrFromICETransport(dtls.ICETransport()) } func remoteAddrFromICETransport(iceTransport *webrtc.ICETransport) string { if iceTransport == nil { return "" } pair, err := iceTransport.GetSelectedCandidatePair() if err != nil || pair == nil || pair.Remote == nil { return "" } return fmt.Sprintf("%s:%d", pair.Remote.Address, pair.Remote.Port) } ================================================ FILE: rtc/unpacker.go ================================================ package rtc import ( "errors" "fmt" "time" "github.com/bluenviron/gortsplib/v4/pkg/format" "github.com/bluenviron/gortsplib/v4/pkg/format/rtph264" "github.com/bluenviron/gortsplib/v4/pkg/format/rtph265" "github.com/bluenviron/gortsplib/v4/pkg/format/rtplpcm" "github.com/bluenviron/gortsplib/v4/pkg/format/rtpsimpleaudio" "github.com/bluenviron/gortsplib/v4/pkg/rtpreorderer" "github.com/bluenviron/gortsplib/v4/pkg/rtptime" "github.com/pion/rtp" "github.com/pion/webrtc/v3" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/naza/pkg/nazalog" ) var ErrNeedMoreFrames = errors.New("need more frames") type UnPacker struct { reorderer *rtpreorderer.Reorderer payloadType base.AvPacketPt clockRate uint32 pktChan chan<- base.AvPacket timeDecoder *rtptime.GlobalDecoder format format.Format dec IRtpDecoder } func NewUnPacker(mimeType string, clockRate uint32, pktChan chan<- base.AvPacket) *UnPacker { un := &UnPacker{ reorderer: rtpreorderer.New(), clockRate: clockRate, pktChan: pktChan, timeDecoder: rtptime.NewGlobalDecoder(), } switch mimeType { case webrtc.MimeTypeH264: un.payloadType = base.AvPacketPtAvc un.format = &format.H264{} un.dec = NewH264RtpDecoder(un.format) case webrtc.MimeTypePCMA: un.payloadType = base.AvPacketPtG711A un.format = &format.G711{} case webrtc.MimeTypePCMU: un.payloadType = base.AvPacketPtG711U un.format = &format.G711{} case webrtc.MimeTypeOpus: un.payloadType = base.AvPacketPtOpus un.format = &format.Opus{} un.dec = NewOpusRtpDecoder(un.format) case webrtc.MimeTypeH265: un.payloadType = base.AvPacketPtHevc un.format = &format.H265{} un.dec = NewH265RtpDecoder(un.format) default: nazalog.Error("unsupport mineType:", mimeType) } nazalog.Info("create rtp unpacker, mimeType:", mimeType) return un } func (un *UnPacker) UnPack(pkt *rtp.Packet) (err error) { packets, lost := un.reorderer.Process(pkt) if lost != 0 { nazalog.Error("rtp lost") return } for _, rtppkt := range packets { pts, ok := un.timeDecoder.Decode(un.format, rtppkt) if !ok { continue } frame, err := un.dec.Decode(rtppkt) if err != nil { if err != ErrNeedMoreFrames { nazalog.Error("rtp dec Decode failed:", err) return err } continue } var pkt base.AvPacket pkt.PayloadType = un.payloadType pkt.Timestamp = int64(pts / time.Millisecond) pkt.Pts = pkt.Timestamp pkt.Payload = append(pkt.Payload, frame...) un.pktChan <- pkt } return } type IRtpDecoder interface { Decode(pkt *rtp.Packet) ([]byte, error) } type H264RtpDecoder struct { IRtpDecoder dec *rtph264.Decoder } func NewH264RtpDecoder(f format.Format) *H264RtpDecoder { dec, _ := f.(*format.H264).CreateDecoder() return &H264RtpDecoder{ dec: dec, } } func (r *H264RtpDecoder) Decode(pkt *rtp.Packet) ([]byte, error) { nalus, err := r.dec.Decode(pkt) if err != nil { return nil, ErrNeedMoreFrames } if len(nalus) == 0 { err = fmt.Errorf("invalid frame") return nil, err } var frame []byte for _, nalu := range nalus { frame = append(frame, avc.NaluStartCode4...) frame = append(frame, nalu...) } return frame, nil } type G711RtpDecoder struct { IRtpDecoder dec *rtplpcm.Decoder } func NewG711RtpDecoder(f format.Format) *G711RtpDecoder { dec, _ := f.(*format.G711).CreateDecoder() return &G711RtpDecoder{ dec: dec, } } func (r *G711RtpDecoder) Decode(pkt *rtp.Packet) ([]byte, error) { frame, err := r.dec.Decode(pkt) if err != nil { nazalog.Error(err) return nil, err } return frame, nil } type OpusRtpDecoder struct { IRtpDecoder dec *rtpsimpleaudio.Decoder } func NewOpusRtpDecoder(f format.Format) *OpusRtpDecoder { dec, _ := f.(*format.Opus).CreateDecoder() return &OpusRtpDecoder{ dec: dec, } } func (r *OpusRtpDecoder) Decode(pkt *rtp.Packet) ([]byte, error) { frame, err := r.dec.Decode(pkt) if err != nil { nazalog.Error(err) return nil, err } return frame, nil } type H265RtpDecoder struct { IRtpDecoder dec *rtph265.Decoder } func NewH265RtpDecoder(f format.Format) *H265RtpDecoder { dec, _ := f.(*format.H265).CreateDecoder() return &H265RtpDecoder{ dec: dec, } } func (r *H265RtpDecoder) Decode(pkt *rtp.Packet) ([]byte, error) { nalus, err := r.dec.Decode(pkt) if err != nil { return nil, ErrNeedMoreFrames } if len(nalus) == 0 { err = fmt.Errorf("invalid frame") return nil, err } var frame []byte for _, nalu := range nalus { frame = append(frame, avc.NaluStartCode4...) frame = append(frame, nalu...) } return frame, nil } ================================================ FILE: rtc/whepsession.go ================================================ package rtc import ( "bytes" "context" "sync" "sync/atomic" "time" maxlogic "github.com/q191201771/lalmax/logic" "github.com/smallnest/chanx" "github.com/gofrs/uuid" "github.com/pion/rtp" "github.com/pion/webrtc/v3" "github.com/q191201771/lal/pkg/avc" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/hevc" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) const whepMaxReplayPaceDelay = 5 * time.Millisecond type whepSession struct { group *maxlogic.Group pc *peerConnection subscriberId string lalServer logic.ILalServer videoTrack *webrtc.TrackLocalStaticRTP audioTrack *webrtc.TrackLocalStaticRTP videoSender *webrtc.RTPSender audioSender *webrtc.RTPSender videopacker *Packer audiopacker *Packer msgChan *chanx.UnboundedChan[base.RtmpMsg] closeChan chan bool connectedChan chan struct{} connectedOnce sync.Once paceBaseDts uint32 paceBaseAt time.Time paceStarted bool replayingCache bool wroteBytes atomic.Uint64 remoteAddr atomic.Value } func NewWhepSession(appName, streamid string, writeChanSize int, pc *peerConnection, lalServer logic.ILalServer) *whepSession { ok, group := maxlogic.GetGroupManagerInstance().GetGroup(maxlogic.NewStreamKey(appName, streamid)) if !ok { nazalog.Errorf("not found stream, appName:%s, streamid:%s", appName, streamid) return nil } u, _ := uuid.NewV4() return &whepSession{ group: group, pc: pc, lalServer: lalServer, subscriberId: u.String(), msgChan: chanx.NewUnboundedChan[base.RtmpMsg](context.Background(), writeChanSize), closeChan: make(chan bool, 2), connectedChan: make(chan struct{}, 1), } } func (conn *whepSession) GetAnswerSDP(offer string) (sdp string) { var err error videoHeader := conn.group.GetVideoSeqHeaderMsg() if videoHeader != nil { if videoHeader.IsAvcKeySeqHeader() { conn.videoTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "lalmax") if err != nil { nazalog.Error(err) return } conn.videoSender, err = conn.pc.AddTrack(conn.videoTrack) if err != nil { nazalog.Error(err) return } conn.videopacker = NewPacker(PacketH264, videoHeader.Payload) } else if videoHeader.IsHevcKeySeqHeader() { conn.videoTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH265}, "video", "lalmax") if err != nil { nazalog.Error(err) return } conn.videoSender, err = conn.pc.AddTrack(conn.videoTrack) if err != nil { nazalog.Error(err) return } conn.videopacker = NewPacker(PacketHEVC, videoHeader.Payload) } } audioHeader := conn.group.GetAudioSeqHeaderMsg() if audioHeader != nil { var mimeType string audioId := audioHeader.AudioCodecId() switch audioId { case base.RtmpSoundFormatG711A: conn.audioTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypePCMA}, "audio", "lalmax") if err != nil { nazalog.Error(err) return } mimeType = PacketPCMA case base.RtmpSoundFormatG711U: conn.audioTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypePCMU}, "audio", "lalmax") if err != nil { nazalog.Error(err) return } mimeType = PacketPCMU case base.RtmpSoundFormatOpus: conn.audioTrack, err = webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "lalmax") if err != nil { nazalog.Error(err) return } mimeType = PacketOPUS default: nazalog.Error("unsupport audio codeid:", audioId) } if conn.audioTrack != nil { conn.audioSender, err = conn.pc.AddTrack(conn.audioTrack) if err != nil { nazalog.Error(err) return } conn.audiopacker = NewPacker(mimeType, nil) } } gatherComplete := webrtc.GatheringCompletePromise(conn.pc.PeerConnection) conn.pc.SetRemoteDescription(webrtc.SessionDescription{ Type: webrtc.SDPTypeOffer, SDP: string(offer), }) answer, err := conn.pc.CreateAnswer(nil) if err != nil { nazalog.Error(err) return } err = conn.pc.SetLocalDescription(answer) if err != nil { nazalog.Error(err) return } <-gatherComplete sdp = conn.pc.LocalDescription().SDP return } func (conn *whepSession) Run() { conn.pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { nazalog.Info("peer connection state: ", state.String()) switch state { case webrtc.PeerConnectionStateConnected: conn.signalConnected() case webrtc.PeerConnectionStateDisconnected: fallthrough case webrtc.PeerConnectionStateFailed: fallthrough case webrtc.PeerConnectionStateClosed: conn.closeChan <- true } }) if conn.pc.ConnectionState() == webrtc.PeerConnectionStateConnected { conn.signalConnected() } for { select { case <-conn.connectedChan: conn.group.AddSubscriber(maxlogic.SubscriberInfo{ SubscriberID: conn.subscriberId, Protocol: maxlogic.SubscriberProtocolWHEP, }, conn) goto connected case <-conn.closeChan: nazalog.Info("RemoveConsumer, connid:", conn.subscriberId) conn.group.RemoveSubscriber(conn.subscriberId) return } } connected: for { select { case msg := <-conn.msgChan.Out: if msg.Header.MsgTypeId == 0 { conn.replayingCache = false conn.paceBaseAt = time.Time{} conn.paceStarted = false continue } if conn.replayingCache { conn.paceReplayMsg(msg) } if msg.Header.MsgTypeId == base.RtmpTypeIdAudio && conn.audioTrack != nil { conn.sendAudio(msg) } else if msg.Header.MsgTypeId == base.RtmpTypeIdVideo && conn.videoTrack != nil { conn.sendVideo(msg) } case <-conn.closeChan: nazalog.Info("RemoveConsumer, connid:", conn.subscriberId) conn.group.RemoveSubscriber(conn.subscriberId) return } } } func (conn *whepSession) signalConnected() { conn.connectedOnce.Do(func() { conn.refreshRemoteAddr() conn.connectedChan <- struct{}{} }) } func (conn *whepSession) OnReplayStart() { conn.replayingCache = true conn.paceBaseAt = time.Time{} conn.paceBaseDts = 0 conn.paceStarted = false } func (conn *whepSession) OnReplayStop() { conn.msgChan.In <- base.RtmpMsg{} } func (conn *whepSession) paceReplayMsg(msg base.RtmpMsg) { if msg.Header.MsgTypeId != base.RtmpTypeIdAudio && msg.Header.MsgTypeId != base.RtmpTypeIdVideo { return } if msg.IsVideoKeySeqHeader() || msg.IsAacSeqHeader() { return } if !conn.paceStarted { conn.paceBaseDts = msg.Dts() conn.paceBaseAt = time.Now() conn.paceStarted = true return } dtsDelta := int64(msg.Dts()) - int64(conn.paceBaseDts) if dtsDelta <= 0 { return } mediaElapsed := time.Duration(dtsDelta) * time.Millisecond delay := time.Until(conn.paceBaseAt.Add(mediaElapsed)) if delay <= 0 { return } if delay > whepMaxReplayPaceDelay { delay = whepMaxReplayPaceDelay } time.Sleep(delay) } func (conn *whepSession) OnMsg(msg base.RtmpMsg) { switch msg.Header.MsgTypeId { case base.RtmpTypeIdMetadata: return case base.RtmpTypeIdAudio: if conn.audioTrack != nil { conn.msgChan.In <- msg } case base.RtmpTypeIdVideo: if msg.IsVideoKeySeqHeader() { conn.updateVideoCodec(msg) return } if conn.videoTrack != nil { conn.msgChan.In <- msg } } } func (conn *whepSession) OnStop() { conn.closeChan <- true } func (conn *whepSession) sendAudio(msg base.RtmpMsg) { if conn.audiopacker != nil { pkts, err := conn.audiopacker.Encode(msg) if err != nil { nazalog.Error(err) return } for _, pkt := range pkts { if err := conn.audioTrack.WriteRTP(pkt); err != nil { continue } conn.recordSentRTP(pkt) } } } func (conn *whepSession) sendVideo(msg base.RtmpMsg) { if conn.videopacker != nil { pkts, err := conn.videopacker.Encode(msg) if err != nil { nazalog.Error(err) return } for _, pkt := range pkts { if err := conn.videoTrack.WriteRTP(pkt); err != nil { continue } conn.recordSentRTP(pkt) } } } func (conn *whepSession) updateVideoCodec(msg base.RtmpMsg) { if conn.videopacker == nil { return } if msg.IsAvcKeySeqHeader() { sps, pps, err := avc.ParseSpsPpsFromSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseSpsPpsFromSeqHeader err:", err) return } if h264Encoder, ok := conn.videopacker.enc.(*H264RtpEncoder); ok { if bytes.Equal(h264Encoder.sps, sps) && bytes.Equal(h264Encoder.pps, pps) { return } } conn.videopacker.UpdateVideoCodec(nil, sps, pps) return } if msg.IsHevcKeySeqHeader() { vps, sps, pps, err := hevc.ParseVpsSpsPpsFromSeqHeader(msg.Payload) if err != nil { nazalog.Error("ParseVpsSpsPpsFromSeqHeader err:", err) return } if hevcEncoder, ok := conn.videopacker.enc.(*HevcRtpEncoder); ok { if bytes.Equal(hevcEncoder.vps, vps) && bytes.Equal(hevcEncoder.sps, sps) && bytes.Equal(hevcEncoder.pps, pps) { return } } conn.videopacker.UpdateVideoCodec(vps, sps, pps) } } func (conn *whepSession) Close() { if conn.pc != nil { conn.pc.Close() } } func (conn *whepSession) GetSubscriberStat() maxlogic.SubscriberStat { conn.refreshRemoteAddr() return maxlogic.SubscriberStat{ RemoteAddr: conn.loadRemoteAddr(), WroteBytesSum: conn.wroteBytes.Load(), } } func (conn *whepSession) recordSentRTP(pkt *rtp.Packet) { if pkt == nil { return } conn.wroteBytes.Add(uint64(pkt.MarshalSize())) } func (conn *whepSession) refreshRemoteAddr() { if remoteAddr := conn.currentRemoteAddr(); remoteAddr != "" { conn.remoteAddr.Store(remoteAddr) } } func (conn *whepSession) currentRemoteAddr() string { if conn.videoSender != nil { if remoteAddr := remoteAddrFromDTLSTransport(conn.videoSender.Transport()); remoteAddr != "" { return remoteAddr } } if conn.audioSender != nil { if remoteAddr := remoteAddrFromDTLSTransport(conn.audioSender.Transport()); remoteAddr != "" { return remoteAddr } } if sctp := conn.pc.SCTP(); sctp != nil { return remoteAddrFromDTLSTransport(sctp.Transport()) } return "" } func (conn *whepSession) loadRemoteAddr() string { v := conn.remoteAddr.Load() addr, _ := v.(string) return addr } ================================================ FILE: rtc/whipsession.go ================================================ package rtc import ( "github.com/gofrs/uuid" "github.com/pion/webrtc/v3" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) type whipSession struct { streamid string pc *peerConnection lalServer logic.ILalServer lalSession logic.ICustomizePubSessionContext videoUnpacker *UnPacker audioUnpacker *UnPacker pktChan chan base.AvPacket closeChan chan bool subscriberId string } func NewWhipSession(streamid string, pc *peerConnection, lalServer logic.ILalServer) *whipSession { session, err := lalServer.AddCustomizePubSession(streamid) if err != nil { nazalog.Error(err) return nil } session.WithOption(func(option *base.AvPacketStreamOption) { option.VideoFormat = base.AvPacketStreamVideoFormatAnnexb }) u, _ := uuid.NewV4() return &whipSession{ streamid: streamid, pc: pc, lalServer: lalServer, lalSession: session, pktChan: make(chan base.AvPacket, 100), closeChan: make(chan bool, 2), subscriberId: u.String(), } } func (conn *whipSession) GetAnswerSDP(offer string) (sdp string) { gatherComplete := webrtc.GatheringCompletePromise(conn.pc.PeerConnection) conn.pc.SetRemoteDescription(webrtc.SessionDescription{ Type: webrtc.SDPTypeOffer, SDP: string(offer), }) answer, err := conn.pc.CreateAnswer(nil) if err != nil { nazalog.Error(err) return } err = conn.pc.SetLocalDescription(answer) if err != nil { nazalog.Error(err) return } <-gatherComplete sdp = conn.pc.LocalDescription().SDP return } func (conn *whipSession) Run() { conn.pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { nazalog.Info("peer connection state: ", state.String()) switch state { case webrtc.PeerConnectionStateConnected: case webrtc.PeerConnectionStateDisconnected: fallthrough case webrtc.PeerConnectionStateFailed: fallthrough case webrtc.PeerConnectionStateClosed: conn.closeChan <- true } }) var videoPt webrtc.PayloadType conn.pc.OnTrack(func(tr *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { switch tr.Kind() { case webrtc.RTPCodecTypeVideo: conn.videoUnpacker = NewUnPacker(tr.Codec().MimeType, tr.Codec().ClockRate, conn.pktChan) videoPt = tr.PayloadType() case webrtc.RTPCodecTypeAudio: mimeType := tr.Codec().MimeType if tr.Codec().MimeType == "" { // pt为0或者8按照G711U和G711A处理,提高兼容性 if tr.PayloadType() == 0 { mimeType = webrtc.MimeTypePCMU } else if tr.PayloadType() == 8 { mimeType = webrtc.MimeTypePCMA } } conn.audioUnpacker = NewUnPacker(mimeType, tr.Codec().ClockRate, conn.pktChan) } for { pkt, _, err := tr.ReadRTP() if err != nil { nazalog.Error(err) return } if conn.videoUnpacker != nil && pkt.Header.PayloadType == uint8(videoPt) { conn.videoUnpacker.UnPack(pkt) } else if conn.audioUnpacker != nil { conn.audioUnpacker.UnPack(pkt) } } }) for { select { case <-conn.closeChan: nazalog.Info("whip connect close, streamid:", conn.streamid) conn.lalServer.DelCustomizePubSession(conn.lalSession) return case pkt := <-conn.pktChan: conn.lalSession.FeedAvPacket(pkt) } } } func (conn *whipSession) Close() { if conn.lalServer != nil { conn.lalServer.DelCustomizePubSession(conn.lalSession) } if conn.pc != nil { conn.pc.Close() } } ================================================ FILE: run.sh ================================================ ./lalmax -c conf/lalmax.conf.json ================================================ FILE: server/hook_builtin_http_plugin.go ================================================ package server import "fmt" type hookBuiltinHTTPPlugin struct { name string hub *HttpNotify } func (p *hookBuiltinHTTPPlugin) Name() string { return p.name } func (p *hookBuiltinHTTPPlugin) OnHookEvent(event HookEvent) error { if p == nil || p.hub == nil { return nil } if !p.hub.cfg.Enable { return nil } switch event.Event { case HookEventServerStart: if p.hub.cfg.OnServerStart != "" { p.hub.asyncPostEvent(p.hub.cfg.OnServerStart, event) } if p.hub.cfg.ZlmOnServerStarted != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnServerStarted, event) } case HookEventUpdate: if p.hub.cfg.OnUpdate != "" { p.hub.asyncPostEvent(p.hub.cfg.OnUpdate, event) } case HookEventGroupStart: if p.hub.cfg.OnGroupStart != "" { p.hub.asyncPostEvent(p.hub.cfg.OnGroupStart, event) } case HookEventGroupStop: if p.hub.cfg.OnGroupStop != "" { p.hub.asyncPostEvent(p.hub.cfg.OnGroupStop, event) } case HookEventStreamActive: if p.hub.cfg.OnStreamActive != "" { p.hub.asyncPostEvent(p.hub.cfg.OnStreamActive, event) } case HookEventPubStart: if p.hub.cfg.OnPubStart != "" { p.hub.asyncPostEvent(p.hub.cfg.OnPubStart, event) } case HookEventPubStop: if p.hub.cfg.OnPubStop != "" { p.hub.asyncPostEvent(p.hub.cfg.OnPubStop, event) } case HookEventSubStart: if p.hub.cfg.OnSubStart != "" { p.hub.asyncPostEvent(p.hub.cfg.OnSubStart, event) } case HookEventSubStop: if p.hub.cfg.OnSubStop != "" { p.hub.asyncPostEvent(p.hub.cfg.OnSubStop, event) } case HookEventRelayPullStart: if p.hub.cfg.OnRelayPullStart != "" { p.hub.asyncPostEvent(p.hub.cfg.OnRelayPullStart, event) } case HookEventRelayPullStop: if p.hub.cfg.OnRelayPullStop != "" { p.hub.asyncPostEvent(p.hub.cfg.OnRelayPullStop, event) } case HookEventRtmpConnect: if p.hub.cfg.OnRtmpConnect != "" { p.hub.asyncPostEvent(p.hub.cfg.OnRtmpConnect, event) } case HookEventHlsMakeTs: if p.hub.cfg.OnHlsMakeTs != "" { p.hub.asyncPostEvent(p.hub.cfg.OnHlsMakeTs, event) } case HookEventStreamChanged: if p.hub.cfg.ZlmOnStreamChanged != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnStreamChanged, event) } case HookEventServerKeepalive: if p.hub.cfg.ZlmOnServerKeepalive != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnServerKeepalive, event) } case HookEventStreamNoneReader: if p.hub.cfg.ZlmOnStreamNoneReader != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnStreamNoneReader, event) } case HookEventRtpServerTimeout: if p.hub.cfg.ZlmOnRtpServerTimeout != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnRtpServerTimeout, event) } case HookEventRecordMp4: if p.hub.cfg.ZlmOnRecordMp4 != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnRecordMp4, event) } case HookEventPublish: if p.hub.cfg.ZlmOnPublish != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnPublish, event) } case HookEventPlay: if p.hub.cfg.ZlmOnPlay != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnPlay, event) } case HookEventStreamNotFound: if p.hub.cfg.ZlmOnStreamNotFound != "" { p.hub.asyncPostEvent(p.hub.cfg.ZlmOnStreamNotFound, event) } } return nil } func (h *HttpNotify) mustRegisterBuiltinHTTPPlugin() { if h == nil { return } _, err := h.RegisterPlugin(&hookBuiltinHTTPPlugin{ name: "builtin-http-notify", hub: h, }, HookPluginOptions{}) if err != nil { panic(fmt.Sprintf("register builtin http hook plugin failed: %v", err)) } } ================================================ FILE: server/hook_filter.go ================================================ package server import ( "strings" maxlogic "github.com/q191201771/lalmax/logic" ) type HookEventFilter struct { EventNames map[string]struct{} AppName string StreamName string SessionID string } func NewHookEventFilter(appName, streamName, sessionID string, eventNames []string) HookEventFilter { filter := HookEventFilter{ AppName: appName, StreamName: streamName, SessionID: sessionID, } if len(eventNames) != 0 { filter.EventNames = make(map[string]struct{}, len(eventNames)) for _, eventName := range eventNames { if eventName == "" { continue } filter.EventNames[eventName] = struct{}{} } } return filter } func ParseHookEventNames(raw string) []string { if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } out = append(out, part) } return out } func (f HookEventFilter) Match(event HookEvent) bool { if len(f.EventNames) != 0 { if _, ok := f.EventNames[event.Event]; !ok { return false } } if f.SessionID != "" && event.sessionID != f.SessionID { return false } if f.AppName == "" && f.StreamName == "" { return true } if event.streamName != "" || event.appName != "" { return matchStreamKey(maxlogic.NewStreamKey(event.appName, event.streamName), f.AppName, f.StreamName) } for _, key := range event.groupKeys { if matchStreamKey(key, f.AppName, f.StreamName) { return true } } return false } func matchStreamKey(key maxlogic.StreamKey, appName, streamName string) bool { if streamName != "" && key.StreamName != streamName { return false } if appName != "" && key.AppName != appName { return false } if streamName == "" && appName != "" && key.AppName == "" { return false } return key.StreamName != "" || key.AppName != "" } ================================================ FILE: server/hook_plugin.go ================================================ package server import ( "fmt" "sync" ) const defaultHookPluginBufferSize = 64 type HookPlugin interface { Name() string OnHookEvent(event HookEvent) error } type HookPluginOptions struct { BufferSize int Filter HookEventFilter } type hookPluginEntry struct { plugin HookPlugin filter HookEventFilter queue chan HookEvent } func (h *HttpNotify) RegisterPlugin(plugin HookPlugin, options HookPluginOptions) (func(), error) { if h == nil { return nil, fmt.Errorf("hook hub is nil") } if plugin == nil { return nil, fmt.Errorf("hook plugin is nil") } if plugin.Name() == "" { return nil, fmt.Errorf("hook plugin name is empty") } bufferSize := options.BufferSize if bufferSize <= 0 { bufferSize = defaultHookPluginBufferSize } entry := &hookPluginEntry{ plugin: plugin, filter: options.Filter, queue: make(chan HookEvent, bufferSize), } h.pluginMux.Lock() if _, exists := h.plugins[plugin.Name()]; exists { h.pluginMux.Unlock() return nil, fmt.Errorf("hook plugin already exists: %s", plugin.Name()) } h.plugins[plugin.Name()] = entry h.pluginMux.Unlock() go h.runPlugin(entry) var once sync.Once cancel := func() { once.Do(func() { h.unregisterPlugin(plugin.Name()) }) } return cancel, nil } func (h *HttpNotify) runPlugin(entry *hookPluginEntry) { for event := range entry.queue { if err := entry.plugin.OnHookEvent(event); err != nil { Log.Errorf("hook plugin handle error. plugin=%s, event=%s, err=%+v", entry.plugin.Name(), event.Event, err) } } } func (h *HttpNotify) dispatchPlugins(event HookEvent) { h.pluginMux.RLock() defer h.pluginMux.RUnlock() for _, entry := range h.plugins { if !entry.filter.Match(event) { continue } select { case entry.queue <- event: default: Log.Warnf("hook plugin queue full. plugin=%s, event=%s", entry.plugin.Name(), event.Event) } } } func (h *HttpNotify) unregisterPlugin(name string) { h.pluginMux.Lock() defer h.pluginMux.Unlock() entry, ok := h.plugins[name] if !ok { return } delete(h.plugins, name) close(entry.queue) } ================================================ FILE: server/http_notify.go ================================================ // Copyright 2020, Chef. All rights reserved. // https://github.com/q191201771/lal // // Use of this source code is governed by a MIT-style license // that can be found in the License file. // // Author: Chef (191201771@qq.com) package server import ( "bytes" "encoding/json" "fmt" "io" "net/http" "sync" "sync/atomic" "time" maxlogic "github.com/q191201771/lalmax/logic" config "github.com/q191201771/lalmax/config" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/naza/pkg/nazahttp" "github.com/q191201771/naza/pkg/nazalog" ) // TODO(chef): refactor 配置参数供外部传入 // TODO(chef): refactor maxTaskLen修改为能表示是阻塞任务的意思 var ( maxTaskLen = 1024 notifyTimeoutSec = 3 hookHistorySize = 256 hookSubBufSize = 64 hookHTTPPostWorkerIdleAfter = time.Minute ) var Log = nazalog.GetGlobalLogger() type hookHTTPPostTask struct { url string orderKey string eventName string payload []byte } type hookHTTPPostWorker struct { queue chan hookHTTPPostTask } type HookGroupInfo struct { base.EventCommonInfo AppName string `json:"app_name"` StreamName string `json:"stream_name"` } type HookEvent struct { ID int64 `json:"id"` Event string `json:"event"` Timestamp string `json:"timestamp"` Payload json.RawMessage `json:"payload"` sessionID string streamName string appName string groupKeys []maxlogic.StreamKey } const ( HookEventServerStart = "on_server_start" HookEventUpdate = "on_update" HookEventGroupStart = "on_group_start" HookEventGroupStop = "on_group_stop" HookEventStreamActive = "on_stream_active" HookEventPubStart = "on_pub_start" HookEventPubStop = "on_pub_stop" HookEventSubStart = "on_sub_start" HookEventSubStop = "on_sub_stop" HookEventRelayPullStart = "on_relay_pull_start" HookEventRelayPullStop = "on_relay_pull_stop" HookEventRtmpConnect = "on_rtmp_connect" HookEventHlsMakeTs = "on_hls_make_ts" HookEventStreamChanged = "on_stream_changed" HookEventServerKeepalive = "on_server_keepalive" HookEventStreamNoneReader = "on_stream_none_reader" HookEventRtpServerTimeout = "on_rtp_server_timeout" HookEventRecordMp4 = "on_record_mp4" HookEventPublish = "on_publish" HookEventPlay = "on_play" HookEventStreamNotFound = "on_stream_not_found" ) // SubCountFn 查询指定流当前的 sub 数量 // 为什么用回调:避免 HttpNotify 直接依赖 lalsvr,保持解耦 type SubCountFn func(streamName string) int type HttpNotify struct { cfg config.HttpNotifyConfig serverId string stats *maxlogic.StatAggregator client *http.Client subCountFn SubCountFn eventID atomic.Int64 subID atomic.Int64 historyMux sync.RWMutex history []HookEvent subscriberM sync.RWMutex subscribers map[int64]chan HookEvent pluginMux sync.RWMutex plugins map[string]*hookPluginEntry httpPostMux sync.Mutex httpPosts map[string]*hookHTTPPostWorker } // SetSubCountFn 注入 sub 数量查询函数,用于 on_stream_none_reader 判断 func (h *HttpNotify) SetSubCountFn(fn SubCountFn) { h.subCountFn = fn } // UpdateZlmHookConfig 运行时更新 ZLM 兼容 hook 配置 // 为什么:gb28181 通过 setServerConfig 动态设置 hook URL,需要立即生效 // 为什么清零原有字段:ZLM 回调与 lalmax 原有回调互斥,避免双重触发 func (h *HttpNotify) UpdateZlmHookConfig(zlmCfg config.ZlmCompatHookConfig) { h.cfg.ZlmCompatHookConfig = zlmCfg h.cfg.Enable = true if h.cfg.HookTimeoutSec > 0 { h.client.Timeout = time.Duration(h.cfg.HookTimeoutSec) * time.Second } h.cfg.OnServerStart = "" h.cfg.OnUpdate = "" h.cfg.OnGroupStart = "" h.cfg.OnGroupStop = "" h.cfg.OnStreamActive = "" h.cfg.OnPubStart = "" h.cfg.OnPubStop = "" h.cfg.OnSubStart = "" h.cfg.OnSubStop = "" h.cfg.OnRelayPullStart = "" h.cfg.OnRelayPullStop = "" h.cfg.OnRtmpConnect = "" h.cfg.OnHlsMakeTs = "" Log.Infof("zlm compat hook config updated. timeout=%ds, on_stream_changed=%s, on_server_keepalive=%s, on_publish=%s, on_play=%s", h.cfg.HookTimeoutSec, zlmCfg.ZlmOnStreamChanged, zlmCfg.ZlmOnServerKeepalive, zlmCfg.ZlmOnPublish, zlmCfg.ZlmOnPlay) } func NewHttpNotify(cfg config.HttpNotifyConfig, serverId string) *HttpNotify { timeout := notifyTimeoutSec if cfg.HookTimeoutSec > 0 { timeout = cfg.HookTimeoutSec } httpNotify := &HttpNotify{ cfg: cfg, serverId: serverId, stats: maxlogic.NewStatAggregator(maxlogic.GetGroupManagerInstance()), history: make([]HookEvent, 0, hookHistorySize), subscribers: make(map[int64]chan HookEvent), plugins: make(map[string]*hookPluginEntry), httpPosts: make(map[string]*hookHTTPPostWorker), client: &http.Client{ Timeout: time.Duration(timeout) * time.Second, }, } httpNotify.mustRegisterBuiltinHTTPPlugin() return httpNotify } // TODO(chef): Dispose // --------------------------------------------------------------------------------------------------------------------- func (h *HttpNotify) NotifyServerStart(info base.LalInfo) { info.ServerId = h.serverId h.publish(HookEventServerStart, info) } func (h *HttpNotify) NotifyUpdate(info base.UpdateInfo) { info.ServerId = h.serverId info.Groups = h.stats.MergeGroups(info.Groups) h.publish(HookEventUpdate, info) } func (h *HttpNotify) NotifyGroupStart(info HookGroupInfo) { info.ServerId = h.serverId h.publish(HookEventGroupStart, info) } func (h *HttpNotify) NotifyGroupStop(info HookGroupInfo) { info.ServerId = h.serverId h.publish(HookEventGroupStop, info) } func (h *HttpNotify) NotifyStreamActive(info HookGroupInfo) { info.ServerId = h.serverId h.publish(HookEventStreamActive, info) } func (h *HttpNotify) NotifyPubStart(info base.PubStartInfo) { info.ServerId = h.serverId h.publish(HookEventPubStart, info) if !h.cfg.HasZlmHooks() { return } // --- ZLM 兼容:派生 on_publish + on_stream_changed --- h.publish(HookEventPublish, ZlmOnPublishPayload{ MediaServerID: h.serverId, App: info.AppName, Schema: info.Protocol, Stream: info.StreamName, Vhost: "__defaultVhost__", }) h.publish(HookEventStreamChanged, ZlmOnStreamChangedPayload{ Regist: true, App: info.AppName, Stream: info.StreamName, AppName: info.AppName, StreamName: info.StreamName, Schema: info.Protocol, MediaServerID: h.serverId, Vhost: "__defaultVhost__", }) } func (h *HttpNotify) NotifyPubStop(info base.PubStopInfo) { info.ServerId = h.serverId h.publish(HookEventPubStop, info) if !h.cfg.HasZlmHooks() { return } // --- ZLM 兼容:派生 on_stream_changed(regist=false) --- h.publish(HookEventStreamChanged, ZlmOnStreamChangedPayload{ Regist: false, App: info.AppName, Stream: info.StreamName, AppName: info.AppName, StreamName: info.StreamName, Schema: info.Protocol, MediaServerID: h.serverId, Vhost: "__defaultVhost__", }) } func (h *HttpNotify) NotifySubStart(info base.SubStartInfo) { info.ServerId = h.serverId h.publish(HookEventSubStart, info) if !h.cfg.HasZlmHooks() { return } // --- ZLM 兼容:派生 on_play --- h.publish(HookEventPlay, ZlmOnPlayPayload{ MediaServerID: h.serverId, App: info.AppName, Schema: info.Protocol, Stream: info.StreamName, Vhost: "__defaultVhost__", }) } func (h *HttpNotify) NotifySubStop(info base.SubStopInfo) { info.ServerId = h.serverId h.publish(HookEventSubStop, info) if h.cfg.ZlmOnStreamNoneReader == "" || h.subCountFn == nil { return } // 检查该流是否已无观看者,触发 on_stream_none_reader if h.subCountFn(info.StreamName) <= 0 { h.NotifyStreamNoneReader(ZlmOnStreamNoneReaderPayload{ App: info.AppName, Schema: info.Protocol, Stream: info.StreamName, Vhost: "__defaultVhost__", }) } } func (h *HttpNotify) NotifyPullStart(info base.PullStartInfo) { info.ServerId = h.serverId h.publish(HookEventRelayPullStart, info) } func (h *HttpNotify) NotifyPullStop(info base.PullStopInfo) { info.ServerId = h.serverId h.publish(HookEventRelayPullStop, info) } func (h *HttpNotify) NotifyRtmpConnect(info base.RtmpConnectInfo) { info.ServerId = h.serverId h.publish(HookEventRtmpConnect, info) } func (h *HttpNotify) NotifyOnHlsMakeTs(info base.HlsMakeTsInfo) { info.ServerId = h.serverId h.publish(HookEventHlsMakeTs, info) } func (h *HttpNotify) NotifyStreamChanged(info ZlmOnStreamChangedPayload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventStreamChanged, info) } func (h *HttpNotify) NotifyServerKeepalive() { h.publish(HookEventServerKeepalive, ZlmOnServerKeepalivePayload{ MediaServerID: h.serverId, }) } func (h *HttpNotify) NotifyStreamNoneReader(info ZlmOnStreamNoneReaderPayload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventStreamNoneReader, info) } func (h *HttpNotify) NotifyRtpServerTimeout(info ZlmOnRtpServerTimeoutPayload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventRtpServerTimeout, info) } func (h *HttpNotify) NotifyRecordMp4(info ZlmOnRecordMp4Payload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventRecordMp4, info) } func (h *HttpNotify) NotifyPublish(info ZlmOnPublishPayload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventPublish, info) } func (h *HttpNotify) NotifyPlay(info ZlmOnPlayPayload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventPlay, info) } func (h *HttpNotify) NotifyStreamNotFound(info ZlmOnStreamNotFoundPayload) { if info.MediaServerID == "" { info.MediaServerID = h.serverId } h.publish(HookEventStreamNotFound, info) } // ----- implement INotifyHandler interface ---------------------------------------------------------------------------- func (h *HttpNotify) OnServerStart(info base.LalInfo) { h.NotifyServerStart(info) } func (h *HttpNotify) OnUpdate(info base.UpdateInfo) { h.NotifyUpdate(info) } func (h *HttpNotify) OnGroupStart(info HookGroupInfo) { h.NotifyGroupStart(info) } func (h *HttpNotify) OnGroupStop(info HookGroupInfo) { h.NotifyGroupStop(info) } func (h *HttpNotify) OnStreamActive(info HookGroupInfo) { h.NotifyStreamActive(info) } func (h *HttpNotify) OnPubStart(info base.PubStartInfo) { h.NotifyPubStart(info) } func (h *HttpNotify) OnPubStop(info base.PubStopInfo) { h.NotifyPubStop(info) } func (h *HttpNotify) OnSubStart(info base.SubStartInfo) { h.NotifySubStart(info) } func (h *HttpNotify) OnSubStop(info base.SubStopInfo) { h.NotifySubStop(info) } func (h *HttpNotify) OnRelayPullStart(info base.PullStartInfo) { h.NotifyPullStart(info) } func (h *HttpNotify) OnRelayPullStop(info base.PullStopInfo) { h.NotifyPullStop(info) } func (h *HttpNotify) OnRtmpConnect(info base.RtmpConnectInfo) { h.NotifyRtmpConnect(info) } func (h *HttpNotify) OnHlsMakeTs(info base.HlsMakeTsInfo) { h.NotifyOnHlsMakeTs(info) } func (h *HttpNotify) asyncPostEvent(url string, event HookEvent) { if !h.cfg.Enable || url == "" { return } h.dispatchHTTPPost(h.newHookHTTPPostTask(url, event)) } func (h *HttpNotify) newHookHTTPPostTask(url string, event HookEvent) hookHTTPPostTask { return hookHTTPPostTask{ url: url, orderKey: buildHookHTTPOrderKey(url, event), eventName: event.Event, payload: append([]byte(nil), event.Payload...), } } func buildHookHTTPOrderKey(url string, event HookEvent) string { if event.Event == HookEventUpdate { return url + "|__update__" } if len(event.groupKeys) == 1 { key := event.groupKeys[0] if key.AppName != "" && key.StreamName != "" { return fmt.Sprintf("__stream__|%s|%s", key.AppName, key.StreamName) } } if event.appName != "" && event.streamName != "" { return fmt.Sprintf("__stream__|%s|%s", event.appName, event.streamName) } return url + "|__global__" } func (h *HttpNotify) dispatchHTTPPost(task hookHTTPPostTask) { h.httpPostMux.Lock() worker, ok := h.httpPosts[task.orderKey] if !ok { worker = &hookHTTPPostWorker{ queue: make(chan hookHTTPPostTask, maxTaskLen), } h.httpPosts[task.orderKey] = worker go h.runHTTPPostWorker(task.orderKey, worker) } select { case worker.queue <- task: default: Log.Warnf("http notify queue full. key=%s, event=%s, url=%s", task.orderKey, task.eventName, task.url) } h.httpPostMux.Unlock() } func (h *HttpNotify) runHTTPPostWorker(orderKey string, worker *hookHTTPPostWorker) { timer := time.NewTimer(hookHTTPPostWorkerIdleAfter) defer timer.Stop() for { select { case task, ok := <-worker.queue: if !ok { return } if !timer.Stop() { select { case <-timer.C: default: } } h.postRaw(task.url, task.payload) timer.Reset(hookHTTPPostWorkerIdleAfter) case <-timer.C: h.httpPostMux.Lock() current, exists := h.httpPosts[orderKey] if exists && current == worker && len(worker.queue) == 0 { delete(h.httpPosts, orderKey) close(worker.queue) h.httpPostMux.Unlock() return } h.httpPostMux.Unlock() timer.Reset(hookHTTPPostWorkerIdleAfter) } } } func (h *HttpNotify) postRaw(url string, payload []byte) { if h == nil || url == "" || len(payload) == 0 { return } body := bytes.NewBuffer(payload) client := h.client if client == nil { client = http.DefaultClient } resp, err := client.Post(url, nazahttp.HeaderFieldContentType, body) if err != nil { Log.Errorf("http notify post raw payload error. err=%+v, url=%s, payload=%s", err, url, string(payload)) return } if resp != nil && resp.Body != nil { _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() } } func (h *HttpNotify) Recent(limit int) []HookEvent { h.historyMux.RLock() defer h.historyMux.RUnlock() if limit <= 0 || limit > len(h.history) { limit = len(h.history) } start := len(h.history) - limit out := make([]HookEvent, limit) copy(out, h.history[start:]) return out } func (h *HttpNotify) RecentFiltered(limit int, filter HookEventFilter) []HookEvent { h.historyMux.RLock() defer h.historyMux.RUnlock() if limit <= 0 || limit > len(h.history) { limit = len(h.history) } out := make([]HookEvent, 0, limit) for i := len(h.history) - 1; i >= 0 && len(out) < limit; i-- { if !filter.Match(h.history[i]) { continue } out = append(out, h.history[i]) } for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out } func (h *HttpNotify) Subscribe(buffer int) (int64, <-chan HookEvent, func()) { if buffer <= 0 { buffer = hookSubBufSize } id := h.subID.Add(1) ch := make(chan HookEvent, buffer) h.subscriberM.Lock() h.subscribers[id] = ch h.subscriberM.Unlock() cancel := func() { h.subscriberM.Lock() if sub, ok := h.subscribers[id]; ok { delete(h.subscribers, id) close(sub) } h.subscriberM.Unlock() } return id, ch, cancel } func (h *HttpNotify) publish(event string, info interface{}) { if h == nil { return } payload, err := json.Marshal(info) if err != nil { Log.Errorf("marshal hook event failed. event=%s, err=%+v", event, err) return } hookEvent := HookEvent{ ID: h.eventID.Add(1), Event: event, Timestamp: time.Now().Format(time.RFC3339Nano), Payload: payload, } populateHookEventMeta(&hookEvent, info) h.historyMux.Lock() h.history = append(h.history, hookEvent) if len(h.history) > hookHistorySize { h.history = append([]HookEvent(nil), h.history[len(h.history)-hookHistorySize:]...) } h.historyMux.Unlock() h.dispatchPlugins(hookEvent) h.subscriberM.RLock() stale := make([]int64, 0) for id, ch := range h.subscribers { select { case ch <- hookEvent: default: stale = append(stale, id) } } h.subscriberM.RUnlock() if len(stale) == 0 { return } h.subscriberM.Lock() for _, id := range stale { if ch, ok := h.subscribers[id]; ok { delete(h.subscribers, id) close(ch) } } h.subscriberM.Unlock() } func populateHookEventMeta(event *HookEvent, info interface{}) { if event == nil || info == nil { return } switch v := info.(type) { case base.UpdateInfo: event.groupKeys = make([]maxlogic.StreamKey, 0, len(v.Groups)) for _, group := range v.Groups { event.groupKeys = append(event.groupKeys, maxlogic.NewStreamKey(group.AppName, group.StreamName)) } case HookGroupInfo: event.streamName = v.StreamName event.appName = v.AppName case base.PubStartInfo: populateHookSessionMeta(event, v.SessionEventCommonInfo) case base.PubStopInfo: populateHookSessionMeta(event, v.SessionEventCommonInfo) case base.SubStartInfo: populateHookSessionMeta(event, v.SessionEventCommonInfo) case base.SubStopInfo: populateHookSessionMeta(event, v.SessionEventCommonInfo) case base.PullStartInfo: populateHookSessionMeta(event, v.SessionEventCommonInfo) case base.PullStopInfo: populateHookSessionMeta(event, v.SessionEventCommonInfo) case base.RtmpConnectInfo: event.sessionID = v.SessionId event.appName = v.App case base.HlsMakeTsInfo: event.streamName = v.StreamName case ZlmOnStreamChangedPayload: event.appName = v.App event.streamName = v.Stream if event.appName == "" { event.appName = v.AppName } if event.streamName == "" { event.streamName = v.StreamName } case ZlmOnStreamNoneReaderPayload: event.appName = v.App event.streamName = v.Stream case ZlmOnRtpServerTimeoutPayload: event.streamName = v.StreamID case ZlmOnRecordMp4Payload: event.appName = v.App event.streamName = v.Stream case ZlmOnPublishPayload: event.appName = v.App event.streamName = v.Stream case ZlmOnPlayPayload: event.appName = v.App event.streamName = v.Stream case ZlmOnStreamNotFoundPayload: event.appName = v.App event.streamName = v.Stream if event.appName == "" { event.appName = v.AppName } if event.streamName == "" { event.streamName = v.StreamName } } } func populateHookSessionMeta(event *HookEvent, info base.SessionEventCommonInfo) { if event == nil { return } event.sessionID = info.SessionId event.streamName = info.StreamName event.appName = info.AppName } ================================================ FILE: server/middle.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" ) func (s *LalMaxServer) Cors() gin.HandlerFunc { return func(c *gin.Context) { method := c.Request.Method origin := c.GetHeader("Origin") if len(origin) == 0 { c.Header("Access-Control-Allow-Origin", "*") } else { c.Header("Access-Control-Allow-Origin", origin) } //服务器支持的所有跨域请求的方法 c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE,UPDATE") //允许跨域设置可以返回其他子段,可以自定义字段 c.Header("Access-Control-Allow-Headers", "*") c.Header("Access-Control-Allow-Headers", "Content-Type,Access-Token") c.Header("Access-Control-Allow-Credentials", "true") c.Header("Cross-Origin-Resource-Policy", "cross-origin") //允许类型校验 if method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) } c.Next() } } // Authentication 接口鉴权 func Authentication(secrets, ips []string) gin.HandlerFunc { out := base.ApiRespBasic{ ErrorCode: http.StatusUnauthorized, Desp: http.StatusText(http.StatusUnauthorized), } return func(c *gin.Context) { if !authentication(c.Query("token"), c.ClientIP(), secrets, ips) { c.JSON(200, out) return } c.Next() } } // authentication 判断是否符合要求,返回 false 表示鉴权失败 func authentication(reqToken, clientIP string, secrets, ips []string) bool { // 秘钥过滤 if len(secrets) > 0 && !containFn(secrets, reqToken) { return false } // ip 白名单过滤 if len(ips) > 0 && !containFn(ips, clientIP) { return false } return true } func containFn[T comparable](ts []T, t T) bool { for _, v := range ts { if v == t { return true } } return false } ================================================ FILE: server/router.go ================================================ package server import "github.com/gin-gonic/gin" func (s *LalMaxServer) InitRouter(router *gin.Engine) { if router == nil { return } router.Use(s.Cors()) s.initRtcRouter(router) s.initFmp4Router(router) auth := Authentication(s.conf.HttpConfig.CtrlAuthWhitelist.Secrets, s.conf.HttpConfig.CtrlAuthWhitelist.IPs) s.initHookRouter(router, auth) s.initStatRouter(router, auth) s.initCtrlRouter(router, auth) s.initZlmCompatRouter(router, auth) s.initFlvProxy(router) } ================================================ FILE: server/router_ctrl.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" ) func (s *LalMaxServer) initCtrlRouter(router *gin.Engine, handlers ...gin.HandlerFunc) { ctrl := router.Group("/api/ctrl", handlers...) ctrl.POST("/start_relay_pull", s.ctrlStartRelayPullHandler) ctrl.GET("/stop_relay_pull", s.ctrlStopRelayPullHandler) ctrl.POST("/stop_relay_pull", s.ctrlStopRelayPullHandler) ctrl.POST("/kick_session", s.ctrlKickSessionHandler) ctrl.POST("/start_rtp_pub", s.ctrlStartRtpPubHandler) ctrl.POST("/stop_rtp_pub", s.ctrlStopRtpPubHandler) } func (s *LalMaxServer) ctrlStartRelayPullHandler(c *gin.Context) { var info base.ApiCtrlStartRelayPullReq var v base.ApiCtrlStartRelayPullResp j, err := unmarshalRequestJSONBody(c.Request, &info, "url") if err != nil { Log.Warnf("http api start pull error. err=%+v", err) v.ErrorCode = base.ErrorCodeParamMissing v.Desp = base.DespParamMissing c.JSON(http.StatusOK, v) return } if !j.Exist("pull_timeout_ms") { info.PullTimeoutMs = logic.DefaultApiCtrlStartRelayPullReqPullTimeoutMs } if !j.Exist("pull_retry_num") { info.PullRetryNum = base.PullRetryNumNever } if !j.Exist("auto_stop_pull_after_no_out_ms") { info.AutoStopPullAfterNoOutMs = base.AutoStopPullAfterNoOutMsNever } if !j.Exist("rtsp_mode") { info.RtspMode = base.RtspModeTcp } Log.Infof("http api start pull. req info=%+v", info) resp := s.lalsvr.CtrlStartRelayPull(info) c.JSON(http.StatusOK, resp) } func (s *LalMaxServer) ctrlStopRelayPullHandler(c *gin.Context) { var v base.ApiCtrlStopRelayPullResp streamName := c.Query("stream_name") if streamName == "" { v.ErrorCode = base.ErrorCodeParamMissing v.Desp = base.DespParamMissing c.JSON(http.StatusOK, v) return } Log.Infof("http api stop pull. stream_name=%s", streamName) resp := s.lalsvr.CtrlStopRelayPull(streamName) c.JSON(http.StatusOK, resp) } func (s *LalMaxServer) ctrlKickSessionHandler(c *gin.Context) { var v base.ApiCtrlKickSessionResp var info base.ApiCtrlKickSessionReq _, err := unmarshalRequestJSONBody(c.Request, &info, "stream_name", "session_id") if err != nil { Log.Warnf("http api kick session error. err=%+v", err) v.ErrorCode = base.ErrorCodeParamMissing v.Desp = base.DespParamMissing c.JSON(http.StatusOK, v) return } Log.Infof("http api kick session. req info=%+v", info) resp := s.lalsvr.CtrlKickSession(info) c.JSON(http.StatusOK, resp) } func (s *LalMaxServer) ctrlStartRtpPubHandler(c *gin.Context) { var v base.ApiCtrlStartRtpPubResp var info base.ApiCtrlStartRtpPubReq j, err := unmarshalRequestJSONBody(c.Request, &info, "stream_name") if err != nil { Log.Warnf("http api start rtp pub error. err=%+v", err) v.ErrorCode = base.ErrorCodeParamMissing v.Desp = base.DespParamMissing c.JSON(http.StatusOK, v) return } if !j.Exist("timeout_ms") { info.TimeoutMs = logic.DefaultApiCtrlStartRtpPubReqTimeoutMs } Log.Infof("http api start rtp pub. req info=%+v", info) resp := s.rtpPubMgr.Start(info) c.JSON(http.StatusOK, resp) } func (s *LalMaxServer) ctrlStopRtpPubHandler(c *gin.Context) { var v base.ApiCtrlStopRelayPullResp streamName := c.Query("stream_name") sessionID := c.Query("session_id") if streamName == "" && sessionID == "" { var info base.ApiCtrlKickSessionReq if _, err := unmarshalRequestJSONBody(c.Request, &info); err == nil { streamName = info.StreamName sessionID = info.SessionId } } if streamName == "" && sessionID == "" { v.ErrorCode = base.ErrorCodeParamMissing v.Desp = base.DespParamMissing c.JSON(http.StatusOK, v) return } Log.Infof("http api stop rtp pub. stream_name=%s, session_id=%s", streamName, sessionID) session, err := s.rtpPubMgr.Stop(streamName, sessionID) if err != nil { v.ErrorCode = base.ErrorCodeSessionNotFound v.Desp = err.Error() c.JSON(http.StatusOK, v) return } v.ErrorCode = base.ErrorCodeSucc v.Desp = base.DespSucc v.Data.SessionId = session.ID c.JSON(http.StatusOK, v) } ================================================ FILE: server/router_flv_proxy.go ================================================ package server import ( "encoding/json" "io" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/q191201771/naza/pkg/nazalog" ) // initFlvProxy 注册 NoRoute 兜底,将 .flv 请求代理到 lal 的 httpflv 服务 // 为什么:ZLM 的 FLV 拉流路径是 /{app}/{stream}.live.flv,lal 的 httpflv 在独立端口, // lalmax 不直接提供 httpflv,通过反向代理让外部只需访问 lalmax 单一端口 func (s *LalMaxServer) initFlvProxy(router *gin.Engine) { router.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if !strings.HasSuffix(path, ".flv") { c.Status(http.StatusNotFound) return } lalHTTPAddr := s.getLalHttpflvAddr() if lalHTTPAddr == "" { c.Status(http.StatusBadGateway) return } targetURL := "http://" + lalHTTPAddr + path if c.Request.URL.RawQuery != "" { targetURL += "?" + c.Request.URL.RawQuery } nazalog.Debugf("flv proxy. path=%s, target=%s", path, targetURL) req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, targetURL, nil) if err != nil { nazalog.Errorf("flv proxy create request failed. err=%v", err) c.Status(http.StatusInternalServerError) return } for k, vs := range c.Request.Header { for _, v := range vs { req.Header.Add(k, v) } } resp, err := http.DefaultClient.Do(req) if err != nil { nazalog.Errorf("flv proxy request failed. target=%s, err=%v", targetURL, err) c.Status(http.StatusBadGateway) return } defer resp.Body.Close() for k, vs := range resp.Header { for _, v := range vs { c.Header(k, v) } } c.Status(resp.StatusCode) if resp.StatusCode != http.StatusOK { return } c.Header("Transfer-Encoding", "chunked") c.Writer.Flush() io.Copy(c.Writer, resp.Body) }) } // getLalHttpflvAddr 从 lal 原始配置中提取 httpflv 服务地址 func (s *LalMaxServer) getLalHttpflvAddr() string { if len(s.conf.LalRawContent) == 0 { return "" } var raw struct { DefaultHTTP struct { Addr string `json:"http_listen_addr"` } `json:"default_http"` } if err := json.Unmarshal(s.conf.LalRawContent, &raw); err != nil { return "" } addr := raw.DefaultHTTP.Addr if addr == "" { return "" } if addr[0] == ':' { return "127.0.0.1" + addr } return addr } ================================================ FILE: server/router_fmp4.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" "github.com/q191201771/naza/pkg/nazalog" ) func (s *LalMaxServer) initFmp4Router(router *gin.Engine) { router.GET("/live/m4s/:streamid", s.HandleHttpFmp4) router.GET("/live/hls/:streamid/:type", s.HandleHls) } func (s *LalMaxServer) HandleHls(c *gin.Context) { if s.hlssvr != nil { s.hlssvr.HandleRequest(c) } else { nazalog.Error("hls is disable") c.Status(http.StatusNotFound) } } func (s *LalMaxServer) HandleHttpFmp4(c *gin.Context) { if s.httpfmp4svr != nil { s.httpfmp4svr.HandleRequest(c) } else { nazalog.Error("http-fmp4 is disable") c.Status(http.StatusNotFound) } } ================================================ FILE: server/router_helper.go ================================================ package server import ( "encoding/json" "io" "net/http" "github.com/q191201771/naza/pkg/nazahttp" "github.com/q191201771/naza/pkg/nazajson" ) func unmarshalRequestJSONBody(r *http.Request, info interface{}, keyFieldList ...string) (nazajson.Json, error) { body, err := io.ReadAll(r.Body) if err != nil { return nazajson.Json{}, err } j, err := nazajson.New(body) if err != nil { return j, err } for _, kf := range keyFieldList { if !j.Exist(kf) { return j, nazahttp.ErrParamMissing } } return j, json.Unmarshal(body, info) } ================================================ FILE: server/router_hook.go ================================================ package server import ( "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" ) func (s *LalMaxServer) initHookRouter(router *gin.Engine, handlers ...gin.HandlerFunc) { hook := router.Group("/api/hook", handlers...) hook.GET("/recent", s.hookRecentHandler) hook.GET("/stream", s.hookStreamHandler) } func (s *LalMaxServer) hookRecentHandler(c *gin.Context) { var out struct { base.ApiRespBasic Data struct { Events []HookEvent `json:"events"` } `json:"data"` } limit := 20 if v := c.Query("limit"); v != "" { if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { limit = parsed } } eventNames := ParseHookEventNames(c.Query("events")) if eventName := c.Query("event"); eventName != "" { eventNames = append(eventNames, eventName) } filter := NewHookEventFilter(c.Query("app_name"), c.Query("stream_name"), c.Query("session_id"), eventNames) out.ErrorCode = base.ErrorCodeSucc out.Desp = base.DespSucc out.Data.Events = s.notifyHub.RecentFiltered(limit, filter) c.JSON(http.StatusOK, out) } func (s *LalMaxServer) hookStreamHandler(c *gin.Context) { if s.notifyHub == nil { c.JSON(http.StatusOK, base.ApiRespBasic{ ErrorCode: http.StatusInternalServerError, Desp: "hook hub not initialized", }) return } flusher, ok := c.Writer.(http.Flusher) if !ok { c.JSON(http.StatusOK, base.ApiRespBasic{ ErrorCode: http.StatusInternalServerError, Desp: "streaming unsupported", }) return } c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") c.Status(http.StatusOK) _, ch, cancel := s.notifyHub.Subscribe(0) defer cancel() eventNames := ParseHookEventNames(c.Query("events")) if eventName := c.Query("event"); eventName != "" { eventNames = append(eventNames, eventName) } filter := NewHookEventFilter(c.Query("app_name"), c.Query("stream_name"), c.Query("session_id"), eventNames) history := s.notifyHub.RecentFiltered(20, filter) lastHistoryID := int64(0) for _, event := range history { if event.ID > lastHistoryID { lastHistoryID = event.ID } if err := writeHookEventSSE(c.Writer, event); err != nil { return } flusher.Flush() } for { select { case <-c.Request.Context().Done(): return case event, ok := <-ch: if !ok { return } if event.ID <= lastHistoryID { continue } if !filter.Match(event) { continue } if err := writeHookEventSSE(c.Writer, event); err != nil { return } flusher.Flush() } } } func writeHookEventSSE(w http.ResponseWriter, event HookEvent) error { if _, err := fmt.Fprintf(w, "id: %d\n", event.ID); err != nil { return err } if _, err := fmt.Fprintf(w, "event: %s\n", event.Event); err != nil { return err } if _, err := fmt.Fprintf(w, "data: %s\n\n", event.Payload); err != nil { return err } return nil } ================================================ FILE: server/router_rtc.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" ) func (s *LalMaxServer) initRtcRouter(router *gin.Engine) { rtc := router.Group("/webrtc") rtc.GET("/whip", s.HandleWHIP) rtc.POST("/whip", s.HandleWHIP) rtc.OPTIONS("/whip", s.HandleWHIP) rtc.DELETE("/whip", s.HandleWHIP) rtc.GET("/whep", s.HandleWHEP) rtc.POST("/whep", s.HandleWHEP) rtc.OPTIONS("/whep", s.HandleWHEP) rtc.DELETE("/whep", s.HandleWHEP) rtc.POST("/play/live/:streamid", s.HandleJessibuca) rtc.DELETE("/play/live/:streamid", s.HandleJessibuca) } func (s *LalMaxServer) HandleWHIP(c *gin.Context) { switch c.Request.Method { case "GET": if s.rtcsvr != nil { s.rtcsvr.ServeWHIPPublishPage(c) } else { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusServiceUnavailable, "WHIP

RTC 未启用:请在配置中将 lalmax.rtc_config.enable 设为 true 并重启服务。

推流地址示例:/webrtc/whip?streamid=test110

") } case "POST": if s.rtcsvr != nil { s.rtcsvr.HandleWHIP(c) } else { c.String(http.StatusServiceUnavailable, "rtc disabled") } case "OPTIONS": c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Expose-Headers", "Location") c.Header("Access-Control-Max-Age", "86400") c.Header("Accept-Post", "application/sdp") c.Status(http.StatusNoContent) case "DELETE": // TODO 实现 DELETE c.Status(http.StatusOK) } } func (s *LalMaxServer) HandleWHEP(c *gin.Context) { switch c.Request.Method { case "GET": if s.rtcsvr != nil { s.rtcsvr.ServeWHEPPlayPage(c) } else { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusServiceUnavailable, "WHEP

RTC 未启用:请在配置中将 lalmax.rtc_config.enable 设为 true 并重启服务。

播放地址示例:http://127.0.0.1:1290/webrtc/whep?streamid=test110(端口以 http_listen_addr 为准)

") } case "POST": if s.rtcsvr != nil { s.rtcsvr.HandleWHEP(c) } else { c.String(http.StatusServiceUnavailable, "rtc disabled") } case "OPTIONS": c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Expose-Headers", "Location") c.Header("Access-Control-Max-Age", "86400") c.Header("Accept-Post", "application/sdp") c.Status(http.StatusNoContent) case "DELETE": // TODO 实现 DELETE c.Status(http.StatusOK) } } func (s *LalMaxServer) HandleJessibuca(c *gin.Context) { switch c.Request.Method { case "POST": if s.rtcsvr != nil { s.rtcsvr.HandleJessibuca(c) } case "DELETE": // TODO 实现 DELETE c.Status(http.StatusOK) } } ================================================ FILE: server/router_stat.go ================================================ package server import ( "net/http" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" maxlogic "github.com/q191201771/lalmax/logic" ) func (s *LalMaxServer) initStatRouter(router *gin.Engine, handlers ...gin.HandlerFunc) { stat := router.Group("/api/stat", handlers...) stat.GET("/group", s.statGroupHandler) stat.GET("/all_group", s.statAllGroupHandler) stat.GET("/lal_info", s.statLalInfoHandler) } func (s *LalMaxServer) statGroupHandler(c *gin.Context) { var v ApiStatGroupResp streamName := c.Query("stream_name") if streamName == "" { v.ErrorCode = base.ErrorCodeParamMissing v.Desp = base.DespParamMissing c.JSON(http.StatusOK, v) return } appName := c.Query("app_name") view := s.stats.FindGroupView(s.lalsvr.StatAllGroup(), maxlogic.NewStreamKey(appName, streamName)) if view == nil { v.ErrorCode = base.ErrorCodeGroupNotFound v.Desp = base.DespGroupNotFound c.JSON(http.StatusOK, v) return } group := newLalmaxStatGroup(*view) v.Data = &group v.ErrorCode = base.ErrorCodeSucc v.Desp = base.DespSucc c.JSON(http.StatusOK, v) } func (s *LalMaxServer) statAllGroupHandler(c *gin.Context) { var out ApiStatAllGroupResp out.ErrorCode = base.ErrorCodeSucc out.Desp = base.DespSucc out.Data.Groups = newLalmaxStatGroups(s.stats.BuildGroupsView(s.lalsvr.StatAllGroup())) c.JSON(http.StatusOK, out) } func (s *LalMaxServer) statLalInfoHandler(c *gin.Context) { var v base.ApiStatLalInfoResp v.ErrorCode = base.ErrorCodeSucc v.Desp = base.DespSucc v.Data = s.lalsvr.StatLalInfo() c.JSON(http.StatusOK, v) } ================================================ FILE: server/router_test.go ================================================ package server import ( "bytes" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "os" "sync" "sync/atomic" "testing" "time" maxlogic "github.com/q191201771/lalmax/logic" config "github.com/q191201771/lalmax/config" "github.com/q191201771/lal/pkg/base" baseLogic "github.com/q191201771/lal/pkg/logic" ) type testHookPlugin struct { name string events chan HookEvent } type maxlogicTestSubscriber struct { stat maxlogic.SubscriberStat } type hookHTTPPayload struct { SessionID string `json:"session_id"` AppName string `json:"app_name"` StreamName string `json:"stream_name"` } func (s *maxlogicTestSubscriber) OnMsg(msg base.RtmpMsg) {} func (s *maxlogicTestSubscriber) OnStop() {} func (s *maxlogicTestSubscriber) GetSubscriberStat() maxlogic.SubscriberStat { return s.stat } func (p *testHookPlugin) Name() string { return p.name } func (p *testHookPlugin) OnHookEvent(event HookEvent) error { p.events <- event return nil } var max *LalMaxServer var onUpdateHook func(base.UpdateInfo) var onUpdateHookMu sync.RWMutex var testSeq atomic.Int64 const httpNotifyAddr = ":55559" func uniqueTestName(prefix string) string { return fmt.Sprintf("%s_%d", prefix, testSeq.Add(1)) } func findTestGroup(groups []LalmaxStatGroup, streamName string) *LalmaxStatGroup { for i := range groups { if groups[i].StreamName == streamName { return &groups[i] } } return nil } func TestMain(m *testing.M) { var err error max, err = NewLalMaxServer(&config.Config{ Fmp4Config: config.Fmp4Config{ Http: config.Fmp4HttpConfig{Enable: true}, }, LalRawContent: []byte(`{"rtmp":{"enable":false},"rtsp":{"enable":false},"http_api":{"enable":false},"pprof":{"enable":false}}`), HttpConfig: config.HttpConfig{ ListenAddr: ":52349", }, HttpNotifyConfig: config.HttpNotifyConfig{ Enable: true, UpdateIntervalSec: 2, OnUpdate: fmt.Sprintf("http://127.0.0.1%s/on_update", httpNotifyAddr), }, }) if err != nil { panic(err) } http.HandleFunc("/on_update", func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var out base.UpdateInfo if err := json.NewDecoder(r.Body).Decode(&out); err != nil { w.WriteHeader(http.StatusBadRequest) return } onUpdateHookMu.RLock() hook := onUpdateHook onUpdateHookMu.RUnlock() if hook != nil { hook(out) } w.WriteHeader(http.StatusOK) }) ln, err := net.Listen("tcp", httpNotifyAddr) if err != nil { panic(err) } go func() { _ = http.Serve(ln, nil) }() go max.Run() os.Exit(m.Run()) } func TestAllGroup(t *testing.T) { streamName := uniqueTestName("test_all_group") _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } t.Run("no consumer", func(t *testing.T) { r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/stat/all_group", nil) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != 200 { t.Fatal(resp.Status) } var out ApiStatAllGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } group := findTestGroup(out.Data.Groups, streamName) if group == nil { t.Fatal("no group") } if len(group.StatSubs) != 0 { t.Fatal("subs err") } if len(group.Lalmax.ExtSubs) != 0 { t.Fatal("lalmax ext_subs err") } }) t.Run("has consumer", func(t *testing.T) { ss, _ := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(streamName, streamName, max.hlssvr, 1, 0) ss.AddConsumer("consumer1", nil) r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/stat/all_group", nil) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != 200 { t.Fatal(resp.Status) } var out ApiStatAllGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } group := findTestGroup(out.Data.Groups, streamName) if group == nil { t.Fatal("no group") } if len(group.StatSubs) <= 0 { t.Fatal("subs err") } if len(group.Lalmax.ExtSubs) != 1 { t.Fatalf("unexpected lalmax ext_subs len: %d", len(group.Lalmax.ExtSubs)) } if group.StatSubs[0].SessionId != "consumer1" { t.Fatal("SessionId err") } if group.Lalmax.ExtSubs[0].SessionId != "consumer1" { t.Fatal("lalmax ext SessionId err") } }) } func TestNotifyUpdate(t *testing.T) { streamName := uniqueTestName("notify_test") consumerID := uniqueTestName("consumer_notify") matched := make(chan struct{}, 1) onUpdateHookMu.Lock() onUpdateHook = func(out base.UpdateInfo) { for _, group := range out.Groups { for _, sub := range group.StatSubs { if sub.SessionId == consumerID { select { case matched <- struct{}{}: default: } return } } } } onUpdateHookMu.Unlock() t.Cleanup(func() { onUpdateHookMu.Lock() onUpdateHook = nil onUpdateHookMu.Unlock() }) _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } ss, _ := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(streamName, streamName, max.hlssvr, 1, 0) ss.AddConsumer(consumerID, nil) select { case <-matched: case <-time.After(5 * time.Second): t.Fatal("did not receive on_update with expected SessionId") } } func TestRtpPubStartStop(t *testing.T) { body := bytes.NewBufferString(`{"stream_name":"rtp_pub_test","port":0,"timeout_ms":0}`) r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/ctrl/start_rtp_pub", body) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var startResp base.ApiCtrlStartRtpPubResp if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { t.Fatal(err) } if startResp.ErrorCode != base.ErrorCodeSucc { t.Fatalf("start_rtp_pub failed, code=%d desp=%s", startResp.ErrorCode, startResp.Desp) } if startResp.Data.StreamName != "rtp_pub_test" || startResp.Data.SessionId == "" || startResp.Data.Port == 0 { t.Fatalf("unexpected start_rtp_pub data: %+v", startResp.Data) } r = httptest.NewRecorder() req = httptest.NewRequest("POST", "/api/ctrl/stop_rtp_pub?stream_name=rtp_pub_test", nil) max.router.ServeHTTP(r, req) resp = r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var stopResp base.ApiCtrlStopRelayPullResp if err := json.NewDecoder(resp.Body).Decode(&stopResp); err != nil { t.Fatal(err) } if stopResp.ErrorCode != base.ErrorCodeSucc { t.Fatalf("stop_rtp_pub failed, code=%d desp=%s", stopResp.ErrorCode, stopResp.Desp) } if stopResp.Data.SessionId != startResp.Data.SessionId { t.Fatalf("stop_rtp_pub session id = %s, want %s", stopResp.Data.SessionId, startResp.Data.SessionId) } } func TestStatGroupWithAppName(t *testing.T) { r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/stat/group?stream_name=test&app_name=missing", nil) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var out ApiStatGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.ErrorCode != base.ErrorCodeGroupNotFound { t.Fatalf("unexpected error code: %+v", out) } } func TestStatGroupIncludesLalmaxExtSubs(t *testing.T) { streamName := uniqueTestName("test_stat_group_ext") _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } ss, _ := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(streamName, streamName, max.hlssvr, 1, 0) ss.AddConsumer("consumer-stat-group", nil) r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/stat/group?stream_name="+streamName, nil) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var out ApiStatGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.ErrorCode != base.ErrorCodeSucc { t.Fatalf("unexpected response: %+v", out) } if out.Data == nil { t.Fatal("group data is nil") } if len(out.Data.StatSubs) == 0 { t.Fatal("subs err") } if len(out.Data.Lalmax.ExtSubs) != 1 { t.Fatalf("unexpected lalmax ext_subs len: %d", len(out.Data.Lalmax.ExtSubs)) } if out.Data.Lalmax.ExtSubs[0].SessionId != "consumer-stat-group" { t.Fatalf("unexpected ext sub: %+v", out.Data.Lalmax.ExtSubs[0]) } } func TestStatGroupIncludesLalmaxExtSubsRuntimeFields(t *testing.T) { streamName := uniqueTestName("test_stat_group_runtime") _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } ss, _ := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(streamName, streamName, max.hlssvr, 1, 0) sub := &maxlogicTestSubscriber{ stat: maxlogic.SubscriberStat{ RemoteAddr: "10.0.0.1:9000", ReadBytesSum: 1024, WroteBytesSum: 2048, }, } ss.AddSubscriber(maxlogic.SubscriberInfo{ SubscriberID: "consumer-runtime", Protocol: maxlogic.SubscriberProtocolSRT, }, sub) r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/stat/group?stream_name="+streamName, nil) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var out ApiStatGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.ErrorCode != base.ErrorCodeSucc { t.Fatalf("unexpected response: %+v", out) } if out.Data == nil { t.Fatal("group data is nil") } if len(out.Data.Lalmax.ExtSubs) != 1 { t.Fatalf("unexpected lalmax ext_subs len: %d", len(out.Data.Lalmax.ExtSubs)) } stat := out.Data.Lalmax.ExtSubs[0] if stat.RemoteAddr != "10.0.0.1:9000" { t.Fatalf("remote addr = %s, want 10.0.0.1:9000", stat.RemoteAddr) } if stat.ReadBytesSum != 1024 || stat.WroteBytesSum != 2048 { t.Fatalf("unexpected bytes stat: %+v", stat) } } func TestStopRelayPullAllowsGet(t *testing.T) { r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/ctrl/stop_relay_pull?stream_name=missing", nil) max.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var out base.ApiCtrlStopRelayPullResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.ErrorCode != base.ErrorCodeGroupNotFound { t.Fatalf("unexpected response: %+v", out) } } func TestHookHubRecentAndSubscribe(t *testing.T) { hub := NewHttpNotify(config.HttpNotifyConfig{}, "hub-test") // NotifyPubStart 会派生 on_stream_changed,需要足够缓冲 _, ch, cancel := hub.Subscribe(8) defer cancel() hub.NotifyPubStart(base.PubStartInfo{}) select { case event := <-ch: if event.Event != HookEventPubStart { t.Fatalf("unexpected event: %+v", event) } case <-time.After(time.Second): t.Fatal("wait hook event timeout") } events := hub.Recent(0) found := false for _, e := range events { if e.Event == HookEventPubStart { found = true break } } if !found { t.Fatalf("on_pub_start not found in recent events") } } func TestHookGroupEventsFromDirectLifecycle(t *testing.T) { svr, err := NewLalMaxServer(&config.Config{ LalRawContent: []byte(`{"rtmp":{"enable":false},"rtsp":{"enable":false},"http_api":{"enable":false},"pprof":{"enable":false}}`), HttpConfig: config.HttpConfig{ ListenAddr: ":52353", }, }) if err != nil { t.Fatal(err) } svr.lalsvr.WithOnHookSession(func(uniqueKey string, streamName string) baseLogic.ICustomizeHookSessionContext { key := maxlogic.StreamKeyFromStreamName(streamName) group, created := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(uniqueKey, streamName, svr.hlssvr, svr.conf.LogicConfig.GopCacheNum, svr.conf.LogicConfig.SingleGopMaxFrameNum) group.BindStopHook(key, func(stopKey maxlogic.StreamKey) { svr.notifyHub.NotifyGroupStop(HookGroupInfo{ AppName: stopKey.AppName, StreamName: stopKey.StreamName, }) }) if created { svr.notifyHub.NotifyGroupStart(HookGroupInfo{ AppName: key.AppName, StreamName: key.StreamName, }) } return group }) streamName := "direct-group-lifecycle" session, err := svr.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } svr.lalsvr.DelCustomizePubSession(session) filter := NewHookEventFilter("", streamName, "", []string{HookEventGroupStart, HookEventGroupStop}) events := svr.notifyHub.RecentFiltered(10, filter) if len(events) != 2 { t.Fatalf("unexpected event len: %d", len(events)) } if events[0].Event != HookEventGroupStart { t.Fatalf("unexpected first event: %+v", events[0]) } if events[1].Event != HookEventGroupStop { t.Fatalf("unexpected second event: %+v", events[1]) } var start HookGroupInfo if err := json.Unmarshal(events[0].Payload, &start); err != nil { t.Fatal(err) } if start.StreamName != streamName { t.Fatalf("unexpected start payload: %+v", start) } var stop HookGroupInfo if err := json.Unmarshal(events[1].Payload, &stop); err != nil { t.Fatal(err) } if stop.StreamName != streamName { t.Fatalf("unexpected stop payload: %+v", stop) } } func TestHookHubStreamActiveEvent(t *testing.T) { hub := NewHttpNotify(config.HttpNotifyConfig{}, "hub-test") hub.NotifyStreamActive(HookGroupInfo{ AppName: "live", StreamName: "stream-active", }) filter := NewHookEventFilter("live", "stream-active", "", []string{HookEventStreamActive}) events := hub.RecentFiltered(10, filter) if len(events) != 1 { t.Fatalf("unexpected event len: %d", len(events)) } if events[0].Event != HookEventStreamActive { t.Fatalf("unexpected event: %+v", events[0]) } var payload HookGroupInfo if err := json.Unmarshal(events[0].Payload, &payload); err != nil { t.Fatal(err) } if payload.AppName != "live" || payload.StreamName != "stream-active" { t.Fatalf("unexpected payload: %+v", payload) } } func TestBuiltinHTTPPluginRespectsEnableFlag(t *testing.T) { var requestCount atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount.Add(1) w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: false, OnPubStart: ts.URL, }, "hub-test") hub.NotifyPubStart(base.PubStartInfo{}) time.Sleep(200 * time.Millisecond) if got := requestCount.Load(); got != 0 { t.Fatalf("unexpected webhook request count: %d", got) } } func TestBuiltinHTTPPluginPreservesOrderPerStream(t *testing.T) { firstStarted := make(chan struct{}) secondStarted := make(chan struct{}) allowFirstFinish := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload hookHTTPPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } switch payload.SessionID { case "first": close(firstStarted) <-allowFirstFinish case "second": close(secondStarted) } w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, OnPubStart: ts.URL, OnPubStop: ts.URL, }, "hub-test") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "first", AppName: "live", StreamName: "same-stream", }, }) hub.NotifyPubStop(base.PubStopInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "second", AppName: "live", StreamName: "same-stream", }, }) select { case <-firstStarted: case <-time.After(time.Second): t.Fatal("first webhook did not start in time") } select { case <-secondStarted: t.Fatal("second webhook started before the first one finished") case <-time.After(200 * time.Millisecond): } close(allowFirstFinish) select { case <-secondStarted: case <-time.After(time.Second): t.Fatal("second webhook did not start after the first one finished") } } func TestBuiltinHTTPPluginAllowsParallelAcrossStreams(t *testing.T) { firstStarted := make(chan struct{}) secondStreamStarted := make(chan struct{}) allowFirstFinish := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload hookHTTPPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } switch payload.StreamName { case "stream-a": close(firstStarted) <-allowFirstFinish case "stream-b": close(secondStreamStarted) } w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, OnPubStart: ts.URL, }, "hub-test") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "stream-a-session", AppName: "live", StreamName: "stream-a", }, }) hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "stream-b-session", AppName: "live", StreamName: "stream-b", }, }) select { case <-firstStarted: case <-time.After(time.Second): t.Fatal("first stream webhook did not start in time") } select { case <-secondStreamStarted: case <-time.After(time.Second): t.Fatal("second stream webhook was blocked by the first stream") } close(allowFirstFinish) } func TestBuiltinHTTPPluginPreservesOrderAcrossDifferentURLsForSameStream(t *testing.T) { firstStarted := make(chan struct{}) secondStarted := make(chan struct{}) allowFirstFinish := make(chan struct{}) startTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload hookHTTPPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode start payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } close(firstStarted) <-allowFirstFinish w.WriteHeader(http.StatusOK) })) defer startTS.Close() stopTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload hookHTTPPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode stop payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } close(secondStarted) w.WriteHeader(http.StatusOK) })) defer stopTS.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, OnPubStart: startTS.URL, OnPubStop: stopTS.URL, }, "hub-test") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "first", AppName: "live", StreamName: "same-stream", }, }) hub.NotifyPubStop(base.PubStopInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "second", AppName: "live", StreamName: "same-stream", }, }) select { case <-firstStarted: case <-time.After(time.Second): t.Fatal("start webhook did not start in time") } select { case <-secondStarted: t.Fatal("stop webhook started before start webhook finished") case <-time.After(200 * time.Millisecond): } close(allowFirstFinish) select { case <-secondStarted: case <-time.After(time.Second): t.Fatal("stop webhook did not start after start webhook finished") } } func TestHookRecentEndpoint(t *testing.T) { svr, err := NewLalMaxServer(&config.Config{ LalRawContent: []byte(`{"rtmp":{"enable":false},"rtsp":{"enable":false},"http_api":{"enable":false},"pprof":{"enable":false}}`), HttpConfig: config.HttpConfig{ ListenAddr: ":52350", }, }) if err != nil { t.Fatal(err) } svr.notifyHub.NotifyPubStop(base.PubStopInfo{}) r := httptest.NewRecorder() // 用 event filter 精确查询,因为 NotifyPubStop 会派生 on_stream_changed req := httptest.NewRequest("GET", "/api/hook/recent?limit=10&event=on_pub_stop", nil) svr.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var out struct { base.ApiRespBasic Data struct { Events []HookEvent `json:"events"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if out.ErrorCode != base.ErrorCodeSucc { t.Fatalf("unexpected response: %+v", out) } if len(out.Data.Events) < 1 { t.Fatalf("expected at least 1 on_pub_stop event, got: %d", len(out.Data.Events)) } if out.Data.Events[0].Event != HookEventPubStop { t.Fatalf("unexpected event: %+v", out.Data.Events[0]) } } func TestHookRecentEndpointFilterByEventAndStream(t *testing.T) { svr, err := NewLalMaxServer(&config.Config{ LalRawContent: []byte(`{"rtmp":{"enable":false},"rtsp":{"enable":false},"http_api":{"enable":false},"pprof":{"enable":false}}`), HttpConfig: config.HttpConfig{ ListenAddr: ":52351", }, }) if err != nil { t.Fatal(err) } svr.notifyHub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "pub-1", StreamName: "stream-a", AppName: "live", }, }) svr.notifyHub.NotifyPubStop(base.PubStopInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "pub-2", StreamName: "stream-b", AppName: "live", }, }) r := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/hook/recent?limit=10&stream_name=stream-a&event=on_pub_start", nil) svr.router.ServeHTTP(r, req) resp := r.Result() if resp.StatusCode != http.StatusOK { t.Fatal(resp.Status) } var out struct { base.ApiRespBasic Data struct { Events []HookEvent `json:"events"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if len(out.Data.Events) != 1 { t.Fatalf("unexpected event count: %d", len(out.Data.Events)) } if out.Data.Events[0].Event != HookEventPubStart { t.Fatalf("unexpected event: %+v", out.Data.Events[0]) } } func TestHookEventFilterBySessionID(t *testing.T) { filter := NewHookEventFilter("", "", "sess-2", nil) pubStart := HookEvent{Event: HookEventPubStart, sessionID: "sess-1"} pubStop := HookEvent{Event: HookEventPubStop, sessionID: "sess-2"} if filter.Match(pubStart) { t.Fatalf("session filter unexpectedly matched: %+v", pubStart) } if !filter.Match(pubStop) { t.Fatalf("session filter did not match: %+v", pubStop) } } func TestHookEventFilterByUpdateGroup(t *testing.T) { filter := NewHookEventFilter("live", "stream-a", "", []string{HookEventUpdate}) event := HookEvent{ Event: HookEventUpdate, groupKeys: []maxlogic.StreamKey{ maxlogic.NewStreamKey("live", "stream-a"), maxlogic.NewStreamKey("live", "stream-b"), }, } if !filter.Match(event) { t.Fatalf("update filter did not match: %+v", event) } } func TestHookEventFilterByGroupLifecycle(t *testing.T) { filter := NewHookEventFilter("live", "stream-a", "", []string{HookEventGroupStart}) event := HookEvent{ Event: HookEventGroupStart, appName: "live", streamName: "stream-a", } if !filter.Match(event) { t.Fatalf("group lifecycle filter did not match: %+v", event) } } func TestHookPluginReceivesFilteredEvents(t *testing.T) { hub := NewHttpNotify(config.HttpNotifyConfig{}, "plugin-test") plugin := &testHookPlugin{ name: "stream-a-plugin", events: make(chan HookEvent, 2), } cancel, err := hub.RegisterPlugin(plugin, HookPluginOptions{ Filter: NewHookEventFilter("live", "stream-a", "", []string{HookEventPubStart}), }) if err != nil { t.Fatal(err) } defer cancel() hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "pub-a", StreamName: "stream-a", AppName: "live", }, }) hub.NotifyPubStop(base.PubStopInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "pub-a", StreamName: "stream-a", AppName: "live", }, }) select { case event := <-plugin.events: if event.Event != HookEventPubStart { t.Fatalf("unexpected plugin event: %+v", event) } case <-time.After(time.Second): t.Fatal("wait plugin event timeout") } select { case event := <-plugin.events: t.Fatalf("unexpected extra plugin event: %+v", event) case <-time.After(200 * time.Millisecond): } } func TestRegisterHookPluginFromServer(t *testing.T) { svr, err := NewLalMaxServer(&config.Config{ LalRawContent: []byte(`{"rtmp":{"enable":false},"rtsp":{"enable":false},"http_api":{"enable":false},"pprof":{"enable":false}}`), HttpConfig: config.HttpConfig{ ListenAddr: ":52352", }, }) if err != nil { t.Fatal(err) } plugin := &testHookPlugin{ name: "server-plugin", events: make(chan HookEvent, 1), } cancel, err := svr.RegisterHookPlugin(plugin, HookPluginOptions{ Filter: NewHookEventFilter("", "", "", []string{HookEventPubStop}), }) if err != nil { t.Fatal(err) } defer cancel() svr.notifyHub.NotifyPubStop(base.PubStopInfo{}) select { case event := <-plugin.events: if event.Event != HookEventPubStop { t.Fatalf("unexpected event: %+v", event) } case <-time.After(time.Second): t.Fatal("wait server plugin event timeout") } } func TestAuthentication(t *testing.T) { t.Run("无须鉴权", func(t *testing.T) { if !authentication("12", "192.168.0.2", nil, nil) { t.Fatal("期望通过, 但实际未通过") } }) t.Run("Token 鉴权失败", func(t *testing.T) { if authentication("1", "192.168.0.2", []string{"12"}, nil) { t.Fatal("期望不通过, 但实际通过") } }) t.Run("token 鉴权成功", func(t *testing.T) { if !authentication("12", "192.168.0.2", []string{"12"}, nil) { t.Fatal("期望通过, 但实际不通过") } }) t.Run("ip 白名单鉴权失败", func(t *testing.T) { if authentication("12", "192.168.0.2", nil, []string{"192.168.1.2"}) { t.Fatal("期望不通过, 但实际通过") } }) t.Run("ip 白名单鉴权成功", func(t *testing.T) { if !authentication("12", "192.168.0.2", []string{"12"}, []string{"192.168.0.2"}) { t.Fatal("期望通过, 但实际不通过") } }) t.Run("两种模式结合鉴权通过", func(t *testing.T) { if !authentication("12", "192.168.0.2", []string{"12"}, []string{"192.168.0.2"}) { t.Fatal("期望通过, 但实际不通过") } }) } // TestWHIPGETNot404 确保浏览器 GET 能命中路由(无 RTC 时为 503,不应为 Gin 默认 404)。 func TestWHIPGETNot404(t *testing.T) { paths := []string{"/webrtc/whip?streamid=test110"} for _, p := range paths { t.Run(p, func(t *testing.T) { r := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, p, nil) max.router.ServeHTTP(r, req) resp := r.Result() defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { t.Fatalf("GET %s 不应返回 404,请检查 initRtcRouter 是否注册 GET", p) } if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("测试环境未启用 RTC,期望 503,实际 %d", resp.StatusCode) } }) } } // TestWHEPGETCanonicalPath 规范播放地址 GET /webrtc/whep 应命中路由(无 RTC 时为 503)。 func TestWHEPGETCanonicalPath(t *testing.T) { r := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/webrtc/whep?streamid=test110", nil) max.router.ServeHTTP(r, req) resp := r.Result() defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { t.Fatal("GET /webrtc/whep 不应 404") } if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("测试环境未启用 RTC,期望 503,实际 %d", resp.StatusCode) } } ================================================ FILE: server/router_zlm_compat.go ================================================ package server import ( "encoding/json" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" config "github.com/q191201771/lalmax/config" ) // initZlmCompatRouter 注册 /index/api/* ZLM 兼容路由 // 为什么独立文件:隔离 ZLM 兼容层,不影响现有 lalmax API func (s *LalMaxServer) initZlmCompatRouter(router *gin.Engine, handlers ...gin.HandlerFunc) { zlm := router.Group("/index/api", handlers...) zlm.POST("/openRtpServer", s.zlmOpenRtpServerHandler) zlm.POST("/closeRtpServer", s.zlmCloseRtpServerHandler) zlm.POST("/close_streams", s.zlmCloseStreamsHandler) zlm.POST("/getServerConfig", s.zlmGetServerConfigHandler) zlm.POST("/setServerConfig", s.zlmSetServerConfigHandler) zlm.POST("/restartServer", s.zlmRestartServerHandler) zlm.POST("/startRecord", s.zlmStartRecordHandler) zlm.POST("/stopRecord", s.zlmStopRecordHandler) zlm.POST("/addStreamProxy", s.zlmAddStreamProxyHandler) zlm.POST("/getSnap", s.zlmGetSnapHandler) zlm.POST("/webrtc", s.zlmWebrtcHandler) } // ---------- openRtpServer ---------- func (s *LalMaxServer) zlmOpenRtpServerHandler(c *gin.Context) { var req ZlmOpenRtpServerReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmOpenRtpServerResp{Code: -300, Msg: "invalid params"}) return } isTcpFlag := 0 if req.TCPMode > 0 { isTcpFlag = 1 } resp := s.rtpPubMgr.Start(base.ApiCtrlStartRtpPubReq{ StreamName: req.StreamID, Port: req.Port, IsTcpFlag: isTcpFlag, }) if resp.ErrorCode != base.ErrorCodeSucc { c.JSON(http.StatusOK, ZlmOpenRtpServerResp{Code: -1, Msg: resp.Desp}) return } Log.Infof("zlm compat openRtpServer. stream_id=%s, port=%d", req.StreamID, resp.Data.Port) c.JSON(http.StatusOK, ZlmOpenRtpServerResp{Code: 0, Port: resp.Data.Port}) } // ---------- closeRtpServer ---------- func (s *LalMaxServer) zlmCloseRtpServerHandler(c *gin.Context) { var req ZlmCloseRtpServerReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmCloseRtpServerResp{Code: -300}) return } _, err := s.rtpPubMgr.Stop(req.StreamID, "") if err != nil { Log.Infof("zlm compat closeRtpServer not found. stream_id=%s", req.StreamID) c.JSON(http.StatusOK, ZlmCloseRtpServerResp{Code: 0, Hit: 0}) return } Log.Infof("zlm compat closeRtpServer. stream_id=%s", req.StreamID) c.JSON(http.StatusOK, ZlmCloseRtpServerResp{Code: 0, Hit: 1}) } // ---------- close_streams ---------- func (s *LalMaxServer) zlmCloseStreamsHandler(c *gin.Context) { var req ZlmCloseStreamsReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmCloseStreamsResp{Code: -300}) return } streamName := req.Stream if streamName == "" { c.JSON(http.StatusOK, ZlmCloseStreamsResp{Code: 0, CountHit: 0, CountClosed: 0}) return } // 尝试通过 kick_session 关闭所有匹配的 session groups := s.lalsvr.StatAllGroup() hit := 0 closed := 0 for _, g := range groups { if g.StreamName != streamName { continue } hit++ // 关闭 pub session if g.StatPub.SessionId != "" { resp := s.lalsvr.CtrlKickSession(base.ApiCtrlKickSessionReq{ StreamName: streamName, SessionId: g.StatPub.SessionId, }) if resp.ErrorCode == base.ErrorCodeSucc { closed++ } } } // 也尝试关闭 RTP pub session if _, err := s.rtpPubMgr.Stop(streamName, ""); err == nil { if hit == 0 { hit++ } closed++ } Log.Infof("zlm compat close_streams. stream=%s, hit=%d, closed=%d", streamName, hit, closed) c.JSON(http.StatusOK, ZlmCloseStreamsResp{Code: 0, CountHit: hit, CountClosed: closed}) } // ---------- getServerConfig ---------- func (s *LalMaxServer) zlmGetServerConfigHandler(c *gin.Context) { cfg := buildZlmServerConfig(s.conf) c.JSON(http.StatusOK, ZlmGetServerConfigResp{Code: 0, Data: []map[string]any{cfg}}) } // ---------- setServerConfig ---------- func (s *LalMaxServer) zlmSetServerConfigHandler(c *gin.Context) { var params map[string]*string if err := c.ShouldBindJSON(¶ms); err != nil { c.JSON(http.StatusOK, ZlmSetServerConfigResp{ ZlmFixedHeader: ZlmFixedHeader{Code: -300, Msg: "invalid params"}, }) return } changed := 0 zlmCfg := s.conf.HttpNotifyConfig.ZlmCompatHookConfig hookMap := map[string]*string{ "hook.on_stream_changed": &zlmCfg.ZlmOnStreamChanged, "hook.on_server_keepalive": &zlmCfg.ZlmOnServerKeepalive, "hook.on_stream_none_reader": &zlmCfg.ZlmOnStreamNoneReader, "hook.on_rtp_server_timeout": &zlmCfg.ZlmOnRtpServerTimeout, "hook.on_record_mp4": &zlmCfg.ZlmOnRecordMp4, "hook.on_publish": &zlmCfg.ZlmOnPublish, "hook.on_play": &zlmCfg.ZlmOnPlay, "hook.on_stream_not_found": &zlmCfg.ZlmOnStreamNotFound, "hook.on_server_started": &zlmCfg.ZlmOnServerStarted, } for key, target := range hookMap { if v, ok := params[key]; ok && v != nil && *v != *target { *target = *v changed++ } } // 处理 keepalive 间隔 if v, ok := params["hook.alive_interval"]; ok && v != nil { if interval, err := strconv.Atoi(*v); err == nil && interval > 0 { s.conf.HttpNotifyConfig.KeepaliveIntervalSec = interval changed++ } } // 处理 hook 超时时间 if v, ok := params["hook.timeoutSec"]; ok && v != nil { if timeout, err := strconv.Atoi(*v); err == nil && timeout > 0 { s.conf.HttpNotifyConfig.HookTimeoutSec = timeout changed++ } } // 处理 rtp_proxy.port_range if v, ok := params["rtp_proxy.port_range"]; ok && v != nil { if portMin, portMax, ok := parsePortRange(*v); ok { s.rtpPubMgr.UpdatePortRange(portMin, portMax) changed++ } } if changed > 0 { s.conf.HttpNotifyConfig.Enable = true s.notifyHub.UpdateZlmHookConfig(zlmCfg) s.conf.HttpNotifyConfig.ZlmCompatHookConfig = zlmCfg // 同步清零 conf 中的原有 hook URL s.conf.HttpNotifyConfig.OnServerStart = "" s.conf.HttpNotifyConfig.OnUpdate = "" s.conf.HttpNotifyConfig.OnGroupStart = "" s.conf.HttpNotifyConfig.OnGroupStop = "" s.conf.HttpNotifyConfig.OnStreamActive = "" s.conf.HttpNotifyConfig.OnPubStart = "" s.conf.HttpNotifyConfig.OnPubStop = "" s.conf.HttpNotifyConfig.OnSubStart = "" s.conf.HttpNotifyConfig.OnSubStop = "" s.conf.HttpNotifyConfig.OnRelayPullStart = "" s.conf.HttpNotifyConfig.OnRelayPullStop = "" s.conf.HttpNotifyConfig.OnRtmpConnect = "" s.conf.HttpNotifyConfig.OnHlsMakeTs = "" if err := s.conf.SaveToFile(); err != nil { Log.Errorf("zlm compat setServerConfig persist failed. err=%v", err) } } Log.Infof("zlm compat setServerConfig. changed=%d", changed) c.JSON(http.StatusOK, ZlmSetServerConfigResp{ ZlmFixedHeader: ZlmFixedHeader{Code: 0}, Changed: changed, }) } // ---------- restartServer ---------- func (s *LalMaxServer) zlmRestartServerHandler(c *gin.Context) { // 为什么不重启:lalmax 不需要像 ZLM 那样通过重启来重绑端口 Log.Infof("zlm compat restartServer (noop)") c.JSON(http.StatusOK, ZlmFixedHeader{Code: 0, Msg: "ok"}) } // ---------- addStreamProxy ---------- func (s *LalMaxServer) zlmAddStreamProxyHandler(c *gin.Context) { var req ZlmAddStreamProxyReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmAddStreamProxyResp{ZlmFixedHeader: ZlmFixedHeader{Code: -300, Msg: "invalid params"}}) return } streamName := req.Stream if streamName == "" { c.JSON(http.StatusOK, ZlmAddStreamProxyResp{ZlmFixedHeader: ZlmFixedHeader{Code: -300, Msg: "stream is required"}}) return } pullReq := base.ApiCtrlStartRelayPullReq{ Url: req.URL, StreamName: streamName, PullTimeoutMs: int(req.TimeoutSec * 1000), PullRetryNum: req.RetryCount, AutoStopPullAfterNoOutMs: base.AutoStopPullAfterNoOutMsNever, RtspMode: req.RTPType, } if pullReq.PullRetryNum == 0 { pullReq.PullRetryNum = base.PullRetryNumNever } if pullReq.PullTimeoutMs == 0 { pullReq.PullTimeoutMs = logic.DefaultApiCtrlStartRelayPullReqPullTimeoutMs } resp := s.lalsvr.CtrlStartRelayPull(pullReq) if resp.ErrorCode != base.ErrorCodeSucc { c.JSON(http.StatusOK, ZlmAddStreamProxyResp{ZlmFixedHeader: ZlmFixedHeader{Code: -1, Msg: resp.Desp}}) return } Log.Infof("zlm compat addStreamProxy. stream=%s, session_id=%s", streamName, resp.Data.SessionId) var out ZlmAddStreamProxyResp out.Code = 0 out.Data.Key = resp.Data.SessionId c.JSON(http.StatusOK, out) } // ---------- startRecord ---------- func (s *LalMaxServer) zlmStartRecordHandler(c *gin.Context) { var req ZlmStartRecordReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmStartRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: -300, Msg: "invalid params"}}) return } rtmpAddr := extractHostPort(s.conf, "rtmp") if rtmpAddr == "" { c.JSON(http.StatusOK, ZlmStartRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: -1, Msg: "rtmp not configured"}}) return } _, err := s.recorder.startRecord(rtmpAddr, req.App, req.Stream, req.Type, req.MaxSecond) if err != nil { Log.Errorf("zlm compat startRecord failed. stream=%s, err=%v", req.Stream, err) c.JSON(http.StatusOK, ZlmStartRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: -1, Msg: err.Error()}, Result: false}) return } Log.Infof("zlm compat startRecord. app=%s, stream=%s, type=%d", req.App, req.Stream, req.Type) c.JSON(http.StatusOK, ZlmStartRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: 0}, Result: true}) } // ---------- stopRecord ---------- func (s *LalMaxServer) zlmStopRecordHandler(c *gin.Context) { var req ZlmStopRecordReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmStopRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: -300, Msg: "invalid params"}}) return } file, err := s.recorder.stopRecord(req.App, req.Stream, req.Type) if err != nil { Log.Infof("zlm compat stopRecord not recording. app=%s, stream=%s, err=%v", req.App, req.Stream, err) c.JSON(http.StatusOK, ZlmStopRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: 0}, Result: false}) return } Log.Infof("zlm compat stopRecord. app=%s, stream=%s, file=%s", req.App, req.Stream, file) c.JSON(http.StatusOK, ZlmStopRecordResp{ZlmFixedHeader: ZlmFixedHeader{Code: 0}, Result: true}) } // ---------- getSnap ---------- func (s *LalMaxServer) zlmGetSnapHandler(c *gin.Context) { var req ZlmGetSnapReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, ZlmFixedHeader{Code: -300, Msg: "invalid params"}) return } if req.URL == "" { c.JSON(http.StatusOK, ZlmFixedHeader{Code: -300, Msg: "url is required"}) return } data, err := getSnap(req.URL, req.TimeoutSec) if err != nil { Log.Errorf("zlm compat getSnap failed. url=%s, err=%v", req.URL, err) c.JSON(http.StatusOK, ZlmFixedHeader{Code: -1, Msg: err.Error()}) return } Log.Infof("zlm compat getSnap. url=%s, size=%d", req.URL, len(data)) c.Data(http.StatusOK, "image/jpeg", data) } // ---------- webrtc ---------- // zlmWebrtcHandler ZLM 兼容 WebRTC 信令接口 // 为什么:gb28181 前端通过 /index/api/webrtc?app=xx&stream=xx&type=play 播放 func (s *LalMaxServer) zlmWebrtcHandler(c *gin.Context) { typ := c.Query("type") app := c.Query("app") stream := c.Query("stream") if stream == "" || typ != "play" { c.JSON(http.StatusOK, gin.H{"code": -1, "msg": "only type=play supported"}) return } if s.rtcsvr == nil { c.JSON(http.StatusOK, gin.H{"code": -1, "msg": "webrtc not enabled"}) return } body, err := c.GetRawData() if err != nil || len(body) == 0 { c.JSON(http.StatusOK, gin.H{"code": -1, "msg": "invalid sdp offer"}) return } Log.Infof("zlm compat webrtc play. app=%s, stream=%s", app, stream) sdp, err := s.rtcsvr.HandleZlmWebrtcPlay(app, stream, string(body)) if err != nil { Log.Errorf("zlm compat webrtc play failed. app=%s, stream=%s, err=%v", app, stream, err) c.JSON(http.StatusOK, gin.H{"code": -1, "msg": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "code": 0, "id": s.conf.ServerId, "sdp": sdp, "type": "answer", }) } // extractHostPort 从 lal 原始配置中提取指定协议的 host:port // 为什么有默认值:ZLM 模式下 gb28181 假设 RTMP 总在标准端口可用 func extractHostPort(conf *config.Config, protocol string) string { var raw lalRawPorts if len(conf.LalRawContent) > 0 { _ = json.Unmarshal(conf.LalRawContent, &raw) } switch protocol { case "rtmp": addr := raw.Rtmp.Addr if addr == "" { return "127.0.0.1:1935" } if addr[0] == ':' { return "127.0.0.1" + addr } return addr } return "" } ================================================ FILE: server/server.go ================================================ package server import ( "context" "crypto/tls" "fmt" "net/http" "time" "github.com/q191201771/lalmax/srt" "github.com/q191201771/lalmax/rtc" "github.com/q191201771/lalmax/gb28181/rtppub" maxlogic "github.com/q191201771/lalmax/logic" httpfmp4 "github.com/q191201771/lalmax/fmp4/http-fmp4" "github.com/q191201771/lalmax/fmp4/hls" config "github.com/q191201771/lalmax/config" "github.com/gin-gonic/gin" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) type LalMaxServer struct { lalsvr logic.ILalServer conf *config.Config stats *maxlogic.StatAggregator notifyHub *HttpNotify srtsvr *srt.SrtServer rtcsvr *rtc.RtcServer router *gin.Engine routerTls *gin.Engine httpfmp4svr *httpfmp4.HttpFmp4Server hlssvr *hls.HlsServer rtpPubMgr *rtppub.Manager recorder *ffmpegRecorder } func NewLalMaxServer(conf *config.Config) (*LalMaxServer, error) { notifyHub := NewHttpNotify(conf.HttpNotifyConfig, conf.ServerId) lalsvr := logic.NewLalServer(func(option *logic.Option) { if len(conf.LalRawContent) != 0 { option.ConfRawContent = conf.LalRawContent } else { option.ConfFilename = conf.LalSvrConfigPath } option.NotifyHandler = notifyHub }) maxsvr := &LalMaxServer{ lalsvr: lalsvr, conf: conf, stats: maxlogic.NewStatAggregator(maxlogic.GetGroupManagerInstance()), notifyHub: notifyHub, rtpPubMgr: rtppub.NewManager(lalsvr, conf.GB28181Config.MediaConfig), recorder: newFfmpegRecorder(""), } // 注入 sub 数量查询,用于 on_stream_none_reader 判断 notifyHub.SetSubCountFn(func(streamName string) int { for _, g := range lalsvr.StatAllGroup() { if g.StreamName == streamName { return len(g.StatSubs) } } return 0 }) if conf.SrtConfig.Enable { maxsvr.srtsvr = srt.NewSrtServer(conf.SrtConfig.Addr, lalsvr, func(option *srt.SrtOption) { option.Latency = 300 option.PeerLatency = 300 }) } if conf.RtcConfig.Enable { var err error maxsvr.rtcsvr, err = rtc.NewRtcServer(conf.RtcConfig, lalsvr) if err != nil { nazalog.Error("create rtc svr failed, err:", err) return nil, err } maxsvr.rtcsvr.SetStreamNotFoundFn(func(app, stream, schema string) { notifyHub.NotifyStreamNotFound(ZlmOnStreamNotFoundPayload{ MediaServerID: conf.ServerId, App: app, Stream: stream, Schema: schema, Vhost: "__defaultVhost__", }) }) } if conf.Fmp4Config.Http.Enable { maxsvr.httpfmp4svr = httpfmp4.NewHttpFmp4Server() } if conf.Fmp4Config.Hls.Enable { maxsvr.hlssvr = hls.NewHlsServer(conf.Fmp4Config.Hls) } maxsvr.router = gin.Default() maxsvr.InitRouter(maxsvr.router) if conf.HttpConfig.EnableHttps { maxsvr.routerTls = gin.Default() maxsvr.InitRouter(maxsvr.routerTls) } return maxsvr, nil } func (s *LalMaxServer) Run() (err error) { s.lalsvr.WithOnHookSession(func(uniqueKey string, streamName string) logic.ICustomizeHookSessionContext { key := maxlogic.StreamKeyFromStreamName(streamName) group, created := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(uniqueKey, streamName, s.hlssvr, s.conf.LogicConfig.GopCacheNum, s.conf.LogicConfig.SingleGopMaxFrameNum) group.BindActiveHook(key, func(activeKey maxlogic.StreamKey) { if s.notifyHub == nil || !activeKey.Valid() { return } s.notifyHub.NotifyStreamActive(HookGroupInfo{ AppName: activeKey.AppName, StreamName: activeKey.StreamName, }) }) group.BindStopHook(key, func(stopKey maxlogic.StreamKey) { if s.notifyHub == nil || !stopKey.Valid() { return } s.notifyHub.NotifyGroupStop(HookGroupInfo{ AppName: stopKey.AppName, StreamName: stopKey.StreamName, }) }) if created && s.notifyHub != nil { s.notifyHub.NotifyGroupStart(HookGroupInfo{ AppName: key.AppName, StreamName: key.StreamName, }) } return group }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if s.srtsvr != nil { go s.srtsvr.Run(ctx) } go s.runPeriodicUpdate(ctx) go s.runPeriodicKeepalive(ctx) go func() { nazalog.Infof("lalmax http listen. addr=%s", s.conf.HttpConfig.ListenAddr) if err = s.router.Run(s.conf.HttpConfig.ListenAddr); err != nil { nazalog.Infof("lalmax http stop. addr=%s", s.conf.HttpConfig.ListenAddr) } }() if s.conf.HttpConfig.EnableHttps { server := &http.Server{Addr: s.conf.HttpConfig.HttpsListenAddr, Handler: s.routerTls, TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}} go func() { nazalog.Infof("lalmax https listen. addr=%s", s.conf.HttpConfig.HttpsListenAddr) if err = server.ListenAndServeTLS(s.conf.HttpConfig.HttpsCertFile, s.conf.HttpConfig.HttpsKeyFile); err != nil { nazalog.Infof("lalmax https stop. addr=%s", s.conf.HttpConfig.ListenAddr) } }() } return s.lalsvr.RunLoop() } func (s *LalMaxServer) runPeriodicUpdate(ctx context.Context) { if s == nil || s.notifyHub == nil || s.lalsvr == nil { return } intervalSec := s.conf.HttpNotifyConfig.UpdateIntervalSec if intervalSec <= 0 { return } ticker := time.NewTicker(time.Duration(intervalSec) * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.notifyHub.NotifyUpdate(base.UpdateInfo{ Groups: s.lalsvr.StatAllGroup(), }) } } } // runPeriodicKeepalive ZLM 兼容:定时发送 on_server_keepalive func (s *LalMaxServer) runPeriodicKeepalive(ctx context.Context) { if s == nil || s.notifyHub == nil { return } intervalSec := s.conf.HttpNotifyConfig.KeepaliveIntervalSec if intervalSec <= 0 { return } ticker := time.NewTicker(time.Duration(intervalSec) * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.notifyHub.NotifyServerKeepalive() } } } func (s *LalMaxServer) HookHub() *HttpNotify { return s.notifyHub } func (s *LalMaxServer) RegisterHookPlugin(plugin HookPlugin, options HookPluginOptions) (func(), error) { if s == nil || s.notifyHub == nil { return nil, fmt.Errorf("hook hub not initialized") } return s.notifyHub.RegisterPlugin(plugin, options) } ================================================ FILE: server/stat_view.go ================================================ package server import ( "github.com/q191201771/lal/pkg/base" maxlogic "github.com/q191201771/lalmax/logic" ) type LalmaxGroupStat struct { ExtSubs []base.StatSub `json:"ext_subs"` } type LalmaxStatGroup struct { StreamName string `json:"stream_name"` AppName string `json:"app_name"` AudioCodec string `json:"audio_codec"` VideoCodec string `json:"video_codec"` VideoWidth int `json:"video_width"` VideoHeight int `json:"video_height"` StatPub base.StatPub `json:"pub"` StatSubs []base.StatSub `json:"subs"` StatPull base.StatPull `json:"pull"` Fps []base.RecordPerSec `json:"in_frame_per_sec"` Lalmax LalmaxGroupStat `json:"lalmax"` } type ApiStatGroupResp struct { base.ApiRespBasic Data *LalmaxStatGroup `json:"data"` } type ApiStatAllGroupResp struct { base.ApiRespBasic Data struct { Groups []LalmaxStatGroup `json:"groups"` } `json:"data"` } func newLalmaxStatGroup(view maxlogic.StatGroupView) LalmaxStatGroup { group := view.Group return LalmaxStatGroup{ StreamName: group.StreamName, AppName: group.AppName, AudioCodec: group.AudioCodec, VideoCodec: group.VideoCodec, VideoWidth: group.VideoWidth, VideoHeight: group.VideoHeight, StatPub: group.StatPub, StatSubs: group.StatSubs, StatPull: group.StatPull, Fps: group.Fps, Lalmax: LalmaxGroupStat{ ExtSubs: view.ExtSubs, }, } } func newLalmaxStatGroups(views []maxlogic.StatGroupView) []LalmaxStatGroup { if len(views) == 0 { return nil } out := make([]LalmaxStatGroup, len(views)) for i, view := range views { out[i] = newLalmaxStatGroup(view) } return out } ================================================ FILE: server/zlm_compat_config.go ================================================ package server import ( "encoding/json" "fmt" "net" "strconv" "strings" config "github.com/q191201771/lalmax/config" ) // lalRawPorts 从 LalRawContent 中提取 lal 的端口配置 type lalRawPorts struct { Rtmp struct { Addr string `json:"addr"` SslAddr string `json:"rtmps_addr"` } `json:"rtmp"` Rtsp struct { Addr string `json:"addr"` SslAddr string `json:"rtsps_addr"` } `json:"rtsp"` } // buildZlmServerConfig 将 lalmax 配置转换为 ZLM getServerConfig 响应格式 // 为什么:owl 的 ZLMDriver.Connect 依赖 data[0] 中的 http.port / rtmp.port 等字段来更新端口信息 func buildZlmServerConfig(conf *config.Config) map[string]any { cfg := make(map[string]any) cfg["general.mediaServerId"] = conf.ServerId // 从 lal raw config 中提取 rtmp/rtsp 端口 var lalPorts lalRawPorts if len(conf.LalRawContent) > 0 { _ = json.Unmarshal(conf.LalRawContent, &lalPorts) } cfg["rtmp.port"] = extractPort(lalPorts.Rtmp.Addr) cfg["rtmp.sslport"] = extractPort(lalPorts.Rtmp.SslAddr) cfg["rtsp.port"] = extractPort(lalPorts.Rtsp.Addr) cfg["rtsp.sslport"] = extractPort(lalPorts.Rtsp.SslAddr) cfg["http.port"] = extractPort(conf.HttpConfig.ListenAddr) cfg["http.sslport"] = extractPort(conf.HttpConfig.HttpsListenAddr) // rtp_proxy 端口从 gb28181 配置获取 cfg["rtp_proxy.port"] = strconv.Itoa(int(conf.GB28181Config.MediaConfig.ListenPort)) rtpBase := int(conf.GB28181Config.MediaConfig.ListenPort) rtpMax := rtpBase + int(conf.GB28181Config.MediaConfig.MultiPortMaxIncrement) if rtpBase > 0 && rtpMax > rtpBase { cfg["rtp_proxy.port_range"] = fmt.Sprintf("%d-%d", rtpBase+1, rtpMax) } else { cfg["rtp_proxy.port_range"] = "30000-35000" } // --- RTC 配置 --- if conf.RtcConfig.Enable { cfg["rtc.port"] = strconv.Itoa(conf.RtcConfig.ICEUDPMuxPort) cfg["rtc.tcpPort"] = strconv.Itoa(conf.RtcConfig.ICETCPMuxPort) } else { cfg["rtc.port"] = "0" cfg["rtc.tcpPort"] = "0" } // --- Hook 配置 --- cfg["hook.enable"] = boolStr(conf.HttpNotifyConfig.Enable) cfg["hook.alive_interval"] = strconv.Itoa(conf.HttpNotifyConfig.KeepaliveIntervalSec) cfg["hook.on_stream_changed"] = conf.HttpNotifyConfig.ZlmOnStreamChanged cfg["hook.on_server_keepalive"] = conf.HttpNotifyConfig.ZlmOnServerKeepalive cfg["hook.on_stream_none_reader"] = conf.HttpNotifyConfig.ZlmOnStreamNoneReader cfg["hook.on_rtp_server_timeout"] = conf.HttpNotifyConfig.ZlmOnRtpServerTimeout cfg["hook.on_record_mp4"] = conf.HttpNotifyConfig.ZlmOnRecordMp4 cfg["hook.on_server_started"] = conf.HttpNotifyConfig.ZlmOnServerStarted cfg["hook.on_publish"] = conf.HttpNotifyConfig.ZlmOnPublish cfg["hook.on_play"] = conf.HttpNotifyConfig.ZlmOnPlay cfg["hook.on_flow_report"] = "" cfg["hook.on_http_access"] = "" cfg["hook.on_rtsp_auth"] = "" cfg["hook.on_rtsp_realm"] = "" cfg["hook.on_shell_login"] = "" cfg["hook.on_send_rtp_stopped"] = "" cfg["hook.on_server_exited"] = "" cfg["hook.on_stream_not_found"] = conf.HttpNotifyConfig.ZlmOnStreamNotFound cfg["hook.on_record_ts"] = "" hookTimeout := conf.HttpNotifyConfig.HookTimeoutSec if hookTimeout <= 0 { hookTimeout = 10 } cfg["hook.timeoutSec"] = strconv.Itoa(hookTimeout) cfg["hook.retry"] = "1" cfg["hook.retry_delay"] = "3" cfg["hook.stream_changed_schemas"] = "" // --- 默认值填充 --- cfg["api.secret"] = "" cfg["api.apiDebug"] = "1" return cfg } // extractPort 从 ":1935" 或 "0.0.0.0:1935" 格式中提取端口号字符串 func extractPort(addr string) string { if addr == "" { return "0" } _, portStr, err := net.SplitHostPort(addr) if err != nil { return "0" } return portStr } // parsePortRange 解析 "30000-35000" 格式的端口范围 // 为什么:owl 通过 setServerConfig 下发端口范围字符串,需转换为 min/max int func parsePortRange(s string) (int, int, bool) { idx := strings.Index(s, "-") if idx <= 0 || idx == len(s)-1 { return 0, 0, false } minPort, err1 := strconv.Atoi(strings.TrimSpace(s[:idx])) maxPort, err2 := strconv.Atoi(strings.TrimSpace(s[idx+1:])) if err1 != nil || err2 != nil || minPort <= 0 || maxPort <= minPort { return 0, 0, false } return minPort, maxPort, true } func boolStr(v bool) string { if v { return "1" } return "0" } ================================================ FILE: server/zlm_compat_ffmpeg.go ================================================ package server import ( "context" "fmt" "os" "os/exec" "path/filepath" "sync" "time" ) // ffmpegRecorder 管理 ffmpeg 录像进程 // 为什么用 ffmpeg:lal 内核无按需录像 API,ffmpeg 可从 RTMP 拉流写 MP4,与 ZLM 行为一致 type ffmpegRecorder struct { mu sync.Mutex sessions map[string]*recordSession outputDir string } type recordSession struct { cmd *exec.Cmd cancel context.CancelFunc app string stream string file string start time.Time } func newFfmpegRecorder(outputDir string) *ffmpegRecorder { if outputDir == "" { outputDir = "./record" } return &ffmpegRecorder{ sessions: make(map[string]*recordSession), outputDir: outputDir, } } // recordKey 生成录像会话唯一标识 func recordKey(app, stream string, typ int) string { return fmt.Sprintf("%d/%s/%s", typ, app, stream) } // startRecord 启动 ffmpeg 从 RTMP 拉流并录制为 MP4 func (r *ffmpegRecorder) startRecord(rtmpAddr, app, stream string, typ int, maxSecond int) (string, error) { key := recordKey(app, stream, typ) r.mu.Lock() defer r.mu.Unlock() if _, ok := r.sessions[key]; ok { return "", fmt.Errorf("already recording: %s", key) } dir := filepath.Join(r.outputDir, app, stream) if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("create record dir: %w", err) } filename := fmt.Sprintf("%s_%s.mp4", stream, time.Now().Format("20060102_150405")) outPath := filepath.Join(dir, filename) srcURL := fmt.Sprintf("rtmp://%s/%s/%s", rtmpAddr, app, stream) ctx, cancel := context.WithCancel(context.Background()) args := []string{ "-hide_banner", "-loglevel", "warning", "-i", srcURL, "-c", "copy", "-movflags", "+faststart", } if maxSecond > 0 { args = append(args, "-t", fmt.Sprintf("%d", maxSecond)) } args = append(args, "-y", outPath) cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd.Stdout = nil cmd.Stderr = nil if err := cmd.Start(); err != nil { cancel() return "", fmt.Errorf("ffmpeg start: %w", err) } sess := &recordSession{ cmd: cmd, cancel: cancel, app: app, stream: stream, file: outPath, start: time.Now(), } r.sessions[key] = sess go func() { _ = cmd.Wait() r.mu.Lock() delete(r.sessions, key) r.mu.Unlock() Log.Infof("ffmpeg record finished. key=%s, file=%s", key, outPath) }() Log.Infof("ffmpeg record started. key=%s, file=%s, src=%s", key, outPath, srcURL) return outPath, nil } // stopRecord 终止 ffmpeg 录像进程 func (r *ffmpegRecorder) stopRecord(app, stream string, typ int) (string, error) { key := recordKey(app, stream, typ) r.mu.Lock() sess, ok := r.sessions[key] if !ok { r.mu.Unlock() return "", fmt.Errorf("not recording: %s", key) } delete(r.sessions, key) r.mu.Unlock() sess.cancel() _ = sess.cmd.Wait() Log.Infof("ffmpeg record stopped. key=%s, file=%s, duration=%s", key, sess.file, time.Since(sess.start)) return sess.file, nil } // getSnap 用 ffmpeg 从指定 URL 截取一帧 JPEG 图片 func getSnap(srcURL string, timeoutSec int) ([]byte, error) { if timeoutSec <= 0 { timeoutSec = 10 } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) defer cancel() args := []string{ "-hide_banner", "-loglevel", "warning", "-i", srcURL, "-vframes", "1", "-f", "image2", "-vcodec", "mjpeg", "pipe:1", } cmd := exec.CommandContext(ctx, "ffmpeg", args...) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ffmpeg snap: %w", err) } if len(out) == 0 { return nil, fmt.Errorf("ffmpeg snap: empty output") } return out, nil } ================================================ FILE: server/zlm_compat_test.go ================================================ package server import ( "encoding/json" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" config "github.com/q191201771/lalmax/config" "github.com/q191201771/lal/pkg/base" ) // =========================================================================== // REST API 兼容测试 // =========================================================================== func TestZlmCompatOpenRtpServer(t *testing.T) { body := `{"port":0,"tcp_mode":0,"stream_id":"zlm_compat_rtp_test"}` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/openRtpServer", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("unexpected status: %d, body: %s", r.Code, r.Body.String()) } var resp ZlmOpenRtpServerResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Code != 0 { t.Fatalf("expected code=0, got %d msg=%s", resp.Code, resp.Msg) } if resp.Port == 0 { t.Fatal("expected non-zero port") } // 清理:关闭刚开启的 RTP 服务 t.Cleanup(func() { closeBody := `{"stream_id":"zlm_compat_rtp_test"}` cr := httptest.NewRecorder() creq := httptest.NewRequest("POST", "/index/api/closeRtpServer", strings.NewReader(closeBody)) creq.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(cr, creq) }) } func TestZlmCompatCloseRtpServer(t *testing.T) { // 先开启 openBody := `{"port":0,"tcp_mode":0,"stream_id":"zlm_close_rtp_test"}` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/openRtpServer", strings.NewReader(openBody)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("open failed: %d %s", r.Code, r.Body.String()) } // 再关闭 closeBody := `{"stream_id":"zlm_close_rtp_test"}` r = httptest.NewRecorder() req = httptest.NewRequest("POST", "/index/api/closeRtpServer", strings.NewReader(closeBody)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("close failed: %d %s", r.Code, r.Body.String()) } var resp ZlmCloseRtpServerResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Code != 0 { t.Fatalf("expected code=0, got %d", resp.Code) } if resp.Hit != 1 { t.Fatalf("expected hit=1, got %d", resp.Hit) } } func TestZlmCompatCloseRtpServerNotFound(t *testing.T) { body := `{"stream_id":"nonexistent_stream_id"}` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/closeRtpServer", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("unexpected status: %d", r.Code) } var resp ZlmCloseRtpServerResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Hit != 0 { t.Fatalf("expected hit=0 for nonexistent stream, got %d", resp.Hit) } } func TestZlmCompatCloseStreams(t *testing.T) { streamName := uniqueTestName("zlm_close_stream") _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } body := `{"app":"","stream":"` + streamName + `"}` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/close_streams", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("unexpected status: %d %s", r.Code, r.Body.String()) } var resp ZlmCloseStreamsResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Code != 0 { t.Fatalf("expected code=0, got %d", resp.Code) } if resp.CountHit == 0 { t.Fatal("expected count_hit > 0") } } func TestZlmCompatGetServerConfig(t *testing.T) { body := `{}` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/getServerConfig", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("unexpected status: %d %s", r.Code, r.Body.String()) } var resp ZlmGetServerConfigResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Code != 0 { t.Fatalf("expected code=0, got %d", resp.Code) } if len(resp.Data) == 0 { t.Fatal("expected non-empty data array") } // 验证返回的配置包含 ZLM 标准字段 cfg := resp.Data[0] requiredKeys := []string{ "http.port", "rtmp.port", "rtsp.port", "rtp_proxy.port", "general.mediaServerId", "hook.on_stream_changed", } for _, key := range requiredKeys { if _, ok := cfg[key]; !ok { t.Errorf("missing required config key: %s", key) } } } func TestZlmCompatSetServerConfig(t *testing.T) { body := `{ "hook.on_stream_changed":"http://127.0.0.1:15123/webhook/on_stream_changed", "hook.on_server_keepalive":"http://127.0.0.1:15123/webhook/on_server_keepalive", "hook.on_publish":"http://127.0.0.1:15123/webhook/on_publish", "hook.on_play":"http://127.0.0.1:15123/webhook/on_play", "hook.on_stream_not_found":"http://127.0.0.1:15123/webhook/on_stream_not_found", "hook.on_stream_none_reader":"http://127.0.0.1:15123/webhook/on_stream_none_reader", "hook.on_record_mp4":"http://127.0.0.1:15123/webhook/on_record_mp4", "hook.on_server_started":"http://127.0.0.1:15123/webhook/on_server_started", "hook.alive_interval":"10" }` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/setServerConfig", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("unexpected status: %d %s", r.Code, r.Body.String()) } var resp ZlmSetServerConfigResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.Code != 0 { t.Fatalf("expected code=0, got %d", resp.Code) } if resp.Changed < 8 { t.Fatalf("expected at least 8 changed, got %d", resp.Changed) } // 验证 getServerConfig 返回更新后的值 r2 := httptest.NewRecorder() req2 := httptest.NewRequest("POST", "/index/api/getServerConfig", strings.NewReader(`{}`)) req2.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r2, req2) var getResp ZlmGetServerConfigResp json.NewDecoder(r2.Body).Decode(&getResp) cfg := getResp.Data[0] if cfg["hook.on_stream_changed"] != "http://127.0.0.1:15123/webhook/on_stream_changed" { t.Errorf("on_stream_changed not updated: %v", cfg["hook.on_stream_changed"]) } if cfg["hook.on_publish"] != "http://127.0.0.1:15123/webhook/on_publish" { t.Errorf("on_publish not updated: %v", cfg["hook.on_publish"]) } } func TestZlmCompatAddStreamProxy(t *testing.T) { body := `{ "vhost":"__defaultVhost__", "app":"live", "stream":"proxy_test", "url":"rtmp://127.0.0.1:19350/live/test", "retry_count":0, "rtp_type":0, "timeout_sec":5 }` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/addStreamProxy", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("unexpected status: %d %s", r.Code, r.Body.String()) } var resp ZlmAddStreamProxyResp if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { t.Fatal(err) } // 拉流可能因目标不存在而失败,但响应格式必须正确 // code=0 表示成功,其他值表示拉流失败但格式正确 if resp.Code == 0 && resp.Data.Key == "" { t.Fatal("code=0 but key is empty") } } func TestZlmCompatStartStopRecord(t *testing.T) { streamName := uniqueTestName("zlm_record") _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } defer func() { // 清理:尝试停止录制 stopBody := `{"type":1,"vhost":"__defaultVhost__","app":"live","stream":"` + streamName + `"}` sr := httptest.NewRecorder() sreq := httptest.NewRequest("POST", "/index/api/stopRecord", strings.NewReader(stopBody)) sreq.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(sr, sreq) }() // 开始录制 startBody := `{"type":1,"vhost":"__defaultVhost__","app":"live","stream":"` + streamName + `"}` r := httptest.NewRecorder() req := httptest.NewRequest("POST", "/index/api/startRecord", strings.NewReader(startBody)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("start record unexpected status: %d %s", r.Code, r.Body.String()) } var startResp ZlmStartRecordResp if err := json.NewDecoder(r.Body).Decode(&startResp); err != nil { t.Fatal(err) } if startResp.Code != 0 { t.Fatalf("start record expected code=0, got %d msg=%s", startResp.Code, startResp.Msg) } if !startResp.Result { t.Fatal("start record expected result=true") } // 停止录制 stopBody := `{"type":1,"vhost":"__defaultVhost__","app":"live","stream":"` + streamName + `"}` r = httptest.NewRecorder() req = httptest.NewRequest("POST", "/index/api/stopRecord", strings.NewReader(stopBody)) req.Header.Set("Content-Type", "application/json") max.router.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("stop record unexpected status: %d %s", r.Code, r.Body.String()) } var stopResp ZlmStopRecordResp if err := json.NewDecoder(r.Body).Decode(&stopResp); err != nil { t.Fatal(err) } if stopResp.Code != 0 { t.Fatalf("stop record expected code=0, got %d msg=%s", stopResp.Code, stopResp.Msg) } } // =========================================================================== // Hook 兼容测试 // =========================================================================== // TestZlmHookOnStreamChangedFormat 验证 on_stream_changed hook 的 payload 格式与 ZLM 兼容 func TestZlmHookOnStreamChangedFormat(t *testing.T) { received := make(chan ZlmOnStreamChangedPayload, 2) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload ZlmOnStreamChangedPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode on_stream_changed payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } received <- payload w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnStreamChanged: ts.URL}, }, "zlm-hook-test") streamName := uniqueTestName("stream_changed_test") // 模拟推流开始 -> 应触发 on_stream_changed(regist=true) hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "pub-session-1", AppName: "live", StreamName: streamName, }, }) select { case payload := <-received: if !payload.Regist { t.Fatal("expected regist=true on pub_start") } // gb28181 优先读 app_name/stream_name(lalmax 兼容字段) if payload.StreamName == "" && payload.Stream == "" { t.Fatal("expected stream or stream_name to be set") } if payload.AppName == "" && payload.App == "" { t.Fatal("expected app or app_name to be set") } case <-time.After(2 * time.Second): t.Fatal("did not receive on_stream_changed for pub_start") } // 模拟推流结束 -> 应触发 on_stream_changed(regist=false) hub.NotifyPubStop(base.PubStopInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "pub-session-1", AppName: "live", StreamName: streamName, }, }) select { case payload := <-received: if payload.Regist { t.Fatal("expected regist=false on pub_stop") } case <-time.After(2 * time.Second): t.Fatal("did not receive on_stream_changed for pub_stop") } } // TestZlmHookOnStreamChangedFieldCompleteness 验证 payload 包含 ZLM 必需字段 func TestZlmHookOnStreamChangedFieldCompleteness(t *testing.T) { received := make(chan json.RawMessage, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var raw json.RawMessage if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { w.WriteHeader(http.StatusBadRequest) return } received <- raw w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnStreamChanged: ts.URL}, }, "field-test") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "completeness-sess", AppName: "live", StreamName: "completeness-stream", }, }) select { case raw := <-received: var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } // ZLM on_stream_changed 必须包含的字段 requiredFields := []string{ "regist", "schema", "mediaServerId", "vhost", } for _, field := range requiredFields { if _, ok := m[field]; !ok { t.Errorf("missing required field in on_stream_changed: %s", field) } } // 必须有 app+stream 或 app_name+stream_name hasZlmStyle := m["app"] != nil && m["stream"] != nil hasLalmaxStyle := m["app_name"] != nil && m["stream_name"] != nil if !hasZlmStyle && !hasLalmaxStyle { t.Error("payload must contain (app, stream) or (app_name, stream_name)") } case <-time.After(2 * time.Second): t.Fatal("did not receive on_stream_changed") } } // TestZlmHookOnServerKeepalive 验证 keepalive hook 的触发和 payload 格式 func TestZlmHookOnServerKeepalive(t *testing.T) { received := make(chan ZlmOnServerKeepalivePayload, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload ZlmOnServerKeepalivePayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode keepalive payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } received <- payload w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, KeepaliveIntervalSec: 1, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnServerKeepalive: ts.URL}, }, "keepalive-test") // 手动触发 keepalive hub.NotifyServerKeepalive() select { case payload := <-received: if payload.MediaServerID == "" { t.Fatal("expected non-empty mediaServerId") } case <-time.After(2 * time.Second): t.Fatal("did not receive on_server_keepalive") } } // TestZlmHookOnStreamNoneReader 验证无人观看 hook func TestZlmHookOnStreamNoneReader(t *testing.T) { received := make(chan ZlmOnStreamNoneReaderPayload, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload ZlmOnStreamNoneReaderPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode none_reader payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } received <- payload w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnStreamNoneReader: ts.URL}, }, "none-reader-test") hub.NotifyStreamNoneReader(ZlmOnStreamNoneReaderPayload{ MediaServerID: "none-reader-test", App: "live", Schema: "rtmp", Stream: "test-stream", Vhost: "__defaultVhost__", }) select { case payload := <-received: if payload.App != "live" { t.Fatalf("expected app=live, got %s", payload.App) } if payload.Stream != "test-stream" { t.Fatalf("expected stream=test-stream, got %s", payload.Stream) } case <-time.After(2 * time.Second): t.Fatal("did not receive on_stream_none_reader") } } // TestZlmHookOnRtpServerTimeout 验证 RTP 超时 hook func TestZlmHookOnRtpServerTimeout(t *testing.T) { received := make(chan ZlmOnRtpServerTimeoutPayload, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload ZlmOnRtpServerTimeoutPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode rtp_timeout payload failed: %v", err) w.WriteHeader(http.StatusBadRequest) return } received <- payload w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnRtpServerTimeout: ts.URL}, }, "rtp-timeout-test") hub.NotifyRtpServerTimeout(ZlmOnRtpServerTimeoutPayload{ LocalPort: 30000, StreamID: "timeout_stream", TCPMode: 0, MediaServerID: "rtp-timeout-test", }) select { case payload := <-received: if payload.StreamID != "timeout_stream" { t.Fatalf("expected stream_id=timeout_stream, got %s", payload.StreamID) } if payload.LocalPort != 30000 { t.Fatalf("expected local_port=30000, got %d", payload.LocalPort) } case <-time.After(2 * time.Second): t.Fatal("did not receive on_rtp_server_timeout") } } // TestZlmHookOnStreamChangedOrderPerStream 验证同一流的 stream_changed 事件保序 func TestZlmHookOnStreamChangedOrderPerStream(t *testing.T) { var order atomic.Int32 firstDone := make(chan struct{}) secondDone := make(chan struct{}) allowFirst := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var payload ZlmOnStreamChangedPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { w.WriteHeader(http.StatusBadRequest) return } seq := order.Add(1) if seq == 1 { close(firstDone) <-allowFirst } else if seq == 2 { close(secondDone) } w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnStreamChanged: ts.URL}, }, "order-test") streamName := uniqueTestName("order_stream") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "order-1", AppName: "live", StreamName: streamName, }, }) hub.NotifyPubStop(base.PubStopInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ SessionId: "order-2", AppName: "live", StreamName: streamName, }, }) select { case <-firstDone: case <-time.After(time.Second): t.Fatal("first on_stream_changed not received") } // 第二个应被阻塞(同流保序) select { case <-secondDone: t.Fatal("second on_stream_changed should be blocked") case <-time.After(200 * time.Millisecond): } close(allowFirst) select { case <-secondDone: case <-time.After(time.Second): t.Fatal("second on_stream_changed not received after first finished") } } // ---------- on_publish ---------- func TestZlmHookOnPublish(t *testing.T) { received := make(chan map[string]any, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var m map[string]any json.NewDecoder(r.Body).Decode(&m) received <- m w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnPublish: ts.URL}, }, "pub-hook-test") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ AppName: "live", StreamName: "test_pub", Protocol: "rtmp", }, }) select { case m := <-received: if m["app"] != "live" || m["stream"] != "test_pub" || m["schema"] != "rtmp" { t.Fatalf("unexpected on_publish payload: %+v", m) } if m["mediaServerId"] != "pub-hook-test" { t.Fatalf("unexpected mediaServerId: %v", m["mediaServerId"]) } case <-time.After(time.Second): t.Fatal("on_publish not received") } } // ---------- on_play ---------- func TestZlmHookOnPlay(t *testing.T) { received := make(chan map[string]any, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var m map[string]any json.NewDecoder(r.Body).Decode(&m) received <- m w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnPlay: ts.URL}, }, "play-hook-test") hub.NotifySubStart(base.SubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ AppName: "live", StreamName: "test_play", Protocol: "rtsp", }, }) select { case m := <-received: if m["app"] != "live" || m["stream"] != "test_play" || m["schema"] != "rtsp" { t.Fatalf("unexpected on_play payload: %+v", m) } if m["mediaServerId"] != "play-hook-test" { t.Fatalf("unexpected mediaServerId: %v", m["mediaServerId"]) } case <-time.After(time.Second): t.Fatal("on_play not received") } } // ---------- on_stream_not_found ---------- func TestZlmHookOnStreamNotFound(t *testing.T) { received := make(chan map[string]any, 1) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var m map[string]any json.NewDecoder(r.Body).Decode(&m) received <- m w.WriteHeader(http.StatusOK) })) defer ts.Close() hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnStreamNotFound: ts.URL}, }, "notfound-hook-test") hub.NotifyStreamNotFound(ZlmOnStreamNotFoundPayload{ App: "live", Stream: "missing_stream", Schema: "rtmp", Vhost: "__defaultVhost__", }) select { case m := <-received: if m["app"] != "live" || m["stream"] != "missing_stream" { t.Fatalf("unexpected on_stream_not_found payload: %+v", m) } if m["mediaServerId"] != "notfound-hook-test" { t.Fatalf("unexpected mediaServerId: %v", m["mediaServerId"]) } case <-time.After(time.Second): t.Fatal("on_stream_not_found not received") } } // ---------- 融合兼容逻辑 ---------- func TestZlmHookDispatchByConfig(t *testing.T) { // 验证:配置了 ZLM hook URL → ZLM 回调触发; // 未配置 ZLM hook URL → ZLM 回调不触发; // lalmax 原有回调始终按 URL 配置分发 zlmReceived := make(chan string, 8) tZlm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var m map[string]any json.NewDecoder(r.Body).Decode(&m) if _, ok := m["regist"]; ok { zlmReceived <- "on_stream_changed" } else { zlmReceived <- "on_publish" } w.WriteHeader(http.StatusOK) })) defer tZlm.Close() lalReceived := make(chan string, 8) tLal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { lalReceived <- "on_pub_start" w.WriteHeader(http.StatusOK) })) defer tLal.Close() // 同时配置 ZLM + lalmax → 两者都应触发 hub := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, OnPubStart: tLal.URL, ZlmCompatHookConfig: config.ZlmCompatHookConfig{ZlmOnStreamChanged: tZlm.URL, ZlmOnPublish: tZlm.URL}, }, "both-mode") hub.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ AppName: "live", StreamName: "both_test", Protocol: "rtmp", }, }) // ZLM 回调应触发(on_publish + on_stream_changed) for i := 0; i < 2; i++ { select { case evt := <-zlmReceived: t.Logf("both mode zlm: %s", evt) case <-time.After(time.Second): t.Fatal("both mode: expected zlm callback") } } // lalmax 原有回调也应触发 select { case evt := <-lalReceived: t.Logf("both mode lal: %s", evt) case <-time.After(time.Second): t.Fatal("both mode: expected lalmax callback") } // 仅配置 lalmax,不配置 ZLM → ZLM 回调不应触发 hubLal := NewHttpNotify(config.HttpNotifyConfig{ Enable: true, OnPubStart: tLal.URL, }, "lal-only") hubLal.NotifyPubStart(base.PubStartInfo{ SessionEventCommonInfo: base.SessionEventCommonInfo{ AppName: "live", StreamName: "lal_only_test", Protocol: "rtmp", }, }) select { case evt := <-lalReceived: t.Logf("lal-only mode: %s", evt) case <-time.After(time.Second): t.Fatal("lal-only mode: expected lalmax callback") } // ZLM 回调不应触发 select { case <-zlmReceived: t.Fatal("lal-only mode: should NOT receive zlm callback") case <-time.After(200 * time.Millisecond): } } ================================================ FILE: server/zlm_compat_types.go ================================================ package server // ZLM 兼容层请求/响应类型定义 // 为什么放在 server 包:ZLM 兼容路由与现有 lalmax 路由同级,需访问 LalMaxServer 内部成员 // ZlmFixedHeader ZLM 标准响应头 type ZlmFixedHeader struct { Code int `json:"code"` Msg string `json:"msg,omitempty"` } // --- /index/api/openRtpServer --- type ZlmOpenRtpServerReq struct { Port int `json:"port"` TCPMode int8 `json:"tcp_mode"` StreamID string `json:"stream_id"` } type ZlmOpenRtpServerResp struct { Code int `json:"code"` Msg string `json:"msg,omitempty"` Port int `json:"port"` } // --- /index/api/closeRtpServer --- type ZlmCloseRtpServerReq struct { StreamID string `json:"stream_id"` } type ZlmCloseRtpServerResp struct { Code int `json:"code"` Hit int `json:"hit"` } // --- /index/api/close_streams --- type ZlmCloseStreamsReq struct { Schema string `json:"schema,omitempty"` Vhost string `json:"vhost,omitempty"` App string `json:"app,omitempty"` Stream string `json:"stream,omitempty"` Force bool `json:"force,omitempty"` } type ZlmCloseStreamsResp struct { Code int `json:"code"` CountHit int `json:"count_hit"` CountClosed int `json:"count_closed"` } // --- /index/api/getServerConfig --- type ZlmGetServerConfigResp struct { Code int `json:"code"` Data []map[string]any `json:"data"` } // --- /index/api/setServerConfig --- type ZlmSetServerConfigResp struct { ZlmFixedHeader Changed int `json:"changed"` } // --- /index/api/startRecord --- type ZlmStartRecordReq struct { Type int `json:"type"` Vhost string `json:"vhost"` App string `json:"app"` Stream string `json:"stream"` CustomPath string `json:"customized_path,omitempty"` MaxSecond int `json:"max_second,omitempty"` } type ZlmStartRecordResp struct { ZlmFixedHeader Result bool `json:"result"` } // --- /index/api/stopRecord --- type ZlmStopRecordReq struct { Type int `json:"type"` Vhost string `json:"vhost"` App string `json:"app"` Stream string `json:"stream"` } type ZlmStopRecordResp struct { ZlmFixedHeader Result bool `json:"result"` } // --- /index/api/addStreamProxy --- type ZlmAddStreamProxyReq struct { Vhost string `json:"vhost"` App string `json:"app"` Stream string `json:"stream"` URL string `json:"url"` RetryCount int `json:"retry_count"` RTPType int `json:"rtp_type"` TimeoutSec float32 `json:"timeout_sec"` } type ZlmAddStreamProxyResp struct { ZlmFixedHeader Data struct { Key string `json:"key"` } `json:"data"` } // --- /index/api/getSnap --- type ZlmGetSnapReq struct { URL string `json:"url"` TimeoutSec int `json:"timeout_sec"` ExpireSec int `json:"expire_sec"` } // --- on_stream_changed Hook Payload --- type ZlmOnStreamChangedPayload struct { Regist bool `json:"regist"` AliveSecond int `json:"aliveSecond"` App string `json:"app"` BytesSpeed int `json:"bytesSpeed"` CreateStamp int64 `json:"createStamp"` MediaServerID string `json:"mediaServerId"` OriginSock ZlmOriginSock `json:"originSock"` OriginType int `json:"originType"` OriginTypeStr string `json:"originTypeStr"` OriginURL string `json:"originUrl"` ReaderCount int `json:"readerCount"` Schema string `json:"schema"` Stream string `json:"stream"` TotalReaderCount int `json:"totalReaderCount"` Tracks []ZlmTrack `json:"tracks"` Vhost string `json:"vhost"` AppName string `json:"app_name,omitempty"` StreamName string `json:"stream_name,omitempty"` } type ZlmOriginSock struct { Identifier string `json:"identifier"` LocalIP string `json:"local_ip"` LocalPort int `json:"local_port"` PeerIP string `json:"peer_ip"` PeerPort int `json:"peer_port"` } type ZlmTrack struct { Channels int `json:"channels,omitempty"` CodecID int `json:"codec_id"` CodecIDName string `json:"codec_id_name"` CodecType int `json:"codec_type"` Ready bool `json:"ready"` SampleBit int `json:"sample_bit,omitempty"` SampleRate int `json:"sample_rate,omitempty"` Fps float32 `json:"fps,omitempty"` Height int `json:"height,omitempty"` Width int `json:"width,omitempty"` } // --- on_server_keepalive Hook Payload --- type ZlmOnServerKeepalivePayload struct { MediaServerID string `json:"mediaServerId"` } // --- on_stream_none_reader Hook Payload --- type ZlmOnStreamNoneReaderPayload struct { MediaServerID string `json:"mediaServerId"` App string `json:"app"` Schema string `json:"schema"` Stream string `json:"stream"` Vhost string `json:"vhost"` } // --- on_record_mp4 Hook Payload --- type ZlmOnRecordMp4Payload struct { MediaServerID string `json:"mediaServerId"` App string `json:"app"` FileName string `json:"file_name"` FilePath string `json:"file_path"` FileSize int64 `json:"file_size"` Folder string `json:"folder"` StartTime int64 `json:"start_time"` Stream string `json:"stream"` TimeLen float64 `json:"time_len"` URL string `json:"url"` Vhost string `json:"vhost"` } // --- on_publish Hook Payload --- type ZlmOnPublishPayload struct { MediaServerID string `json:"mediaServerId"` App string `json:"app"` ID string `json:"id"` IP string `json:"ip"` Params string `json:"params"` Port int `json:"port"` Schema string `json:"schema"` Stream string `json:"stream"` Vhost string `json:"vhost"` } // --- on_play Hook Payload --- type ZlmOnPlayPayload struct { MediaServerID string `json:"mediaServerId"` App string `json:"app"` ID string `json:"id"` IP string `json:"ip"` Params string `json:"params"` Port int `json:"port"` Schema string `json:"schema"` Stream string `json:"stream"` Vhost string `json:"vhost"` } // --- on_stream_not_found Hook Payload --- type ZlmOnStreamNotFoundPayload struct { MediaServerID string `json:"mediaServerId"` App string `json:"app"` ID string `json:"id"` IP string `json:"ip"` Params string `json:"params"` Port int `json:"port"` Schema string `json:"schema"` Stream string `json:"stream"` Vhost string `json:"vhost"` AppName string `json:"app_name,omitempty"` StreamName string `json:"stream_name,omitempty"` } // --- on_rtp_server_timeout Hook Payload --- type ZlmOnRtpServerTimeoutPayload struct { LocalPort int `json:"local_port"` ReUsePort bool `json:"re_use_port"` SSRC uint32 `json:"ssrc"` StreamID string `json:"stream_id"` TCPMode int `json:"tcp_mode"` MediaServerID string `json:"mediaServerId"` } ================================================ FILE: srt/pub.go ================================================ package srt import ( "bufio" "context" srt "github.com/datarhei/gosrt" "github.com/q191201771/lal/pkg/aac" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" codec "github.com/yapingcat/gomedia/go-codec" ts "github.com/yapingcat/gomedia/go-mpeg2" ) type Publisher struct { ctx context.Context srv *SrtServer ss logic.ICustomizePubSessionContext streamName string demuxer *ts.TSDemuxer conn srt.Conn subscribers []*Subscriber } func NewPublisher(ctx context.Context, conn srt.Conn, streamName string, srv *SrtServer) *Publisher { pub := &Publisher{ ctx: ctx, srv: srv, streamName: streamName, conn: conn, demuxer: ts.NewTSDemuxer(), } nazalog.Infof("create srt publisher, streamName:%s", streamName) return pub } func (p *Publisher) SetSession(session logic.ICustomizePubSessionContext) { p.ss = session } func (p *Publisher) Run() { defer func() { p.conn.Close() p.srv.Remove(p.streamName, p.ss) }() audioSampleRate := uint32(0) var foundAudio bool p.demuxer.OnFrame = func(cid ts.TS_STREAM_TYPE, frame []byte, pts uint64, dts uint64) { var pkt base.AvPacket if cid == ts.TS_STREAM_AAC { if !foundAudio { if asc, err := codec.ConvertADTSToASC(frame); err != nil { return } else { p.ss.FeedAudioSpecificConfig(asc.Encode()) audioSampleRate = uint32(codec.AACSampleIdxToSample(int(asc.Sample_freq_index))) } foundAudio = true } var preAudioDts uint64 ctx := aac.AdtsHeaderContext{} for len(frame) > aac.AdtsHeaderLength { ctx.Unpack(frame[:]) if preAudioDts == 0 { preAudioDts = dts } else { preAudioDts += uint64(1024 * 1000 / audioSampleRate) } aacPacket := base.AvPacket{ Timestamp: int64(preAudioDts), PayloadType: base.AvPacketPtAac, Pts: int64(preAudioDts), } if len(frame) >= int(ctx.AdtsLength) { Payload := frame[aac.AdtsHeaderLength:ctx.AdtsLength] if len(frame) > int(ctx.AdtsLength) { frame = frame[ctx.AdtsLength:] } else { frame = frame[0:0] } aacPacket.Payload = Payload p.ss.FeedAvPacket(aacPacket) } } } else if cid == ts.TS_STREAM_H264 { pkt.Payload = frame pkt.PayloadType = base.AvPacketPtAvc pkt.Pts = int64(pts) pkt.Timestamp = int64(dts) p.ss.FeedAvPacket(pkt) } else if cid == ts.TS_STREAM_H265 { pkt.Payload = frame pkt.PayloadType = base.AvPacketPtHevc pkt.Pts = int64(pts) pkt.Timestamp = int64(dts) p.ss.FeedAvPacket(pkt) } } err := p.demuxer.Input(bufio.NewReader(p.conn)) if err != nil { nazalog.Infof("stream [%s] disconnected", p.streamName) } return } ================================================ FILE: srt/server.go ================================================ package srt import ( "context" "strings" "time" srt "github.com/datarhei/gosrt" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/lal/pkg/logic" "github.com/q191201771/naza/pkg/nazalog" ) type SrtServer struct { addr string lalServer logic.ILalServer srtOpt SrtOption } type SrtOption struct { Latency int RecvLatency int PeerLatency int TlpktDrop bool TsbpdMode bool RecvBuf int SendBuf int MaxSendPacketSize int } var defaultSrtOption = SrtOption{ Latency: 300, RecvLatency: 300, PeerLatency: 300, TlpktDrop: true, TsbpdMode: true, RecvBuf: 2 * 1024 * 1024, SendBuf: 2 * 1024 * 1024, MaxSendPacketSize: 4, } type ModSrtOption func(option *SrtOption) func NewSrtServer(addr string, lal logic.ILalServer, modOptions ...ModSrtOption) *SrtServer { opt := defaultSrtOption for _, fn := range modOptions { fn(&opt) } svr := &SrtServer{ addr: addr, lalServer: lal, srtOpt: opt, } nazalog.Info("create srt server") return svr } func (s *SrtServer) Run(ctx context.Context) { conf := srt.DefaultConfig() conf.Latency = time.Millisecond * time.Duration(s.srtOpt.Latency) conf.ReceiverLatency = time.Millisecond * time.Duration(s.srtOpt.RecvLatency) conf.PeerLatency = time.Millisecond * time.Duration(s.srtOpt.PeerLatency) conf.TooLatePacketDrop = s.srtOpt.TlpktDrop conf.TSBPDMode = s.srtOpt.TsbpdMode conf.SendBufferSize = uint32(s.srtOpt.SendBuf) conf.ReceiverBufferSize = uint32(s.srtOpt.RecvBuf) srtlistener, err := srt.Listen("srt", s.addr, conf) if err != nil { panic(err) } defer srtlistener.Close() nazalog.Info("srt server listen addr:", s.addr) for { select { case <-ctx.Done(): return default: } var info StreamInfo conn, mode, err := srtlistener.Accept(func(req srt.ConnRequest) srt.ConnType { info = getStreamInfo(req.StreamId()) return info.Mode }) if err != nil { // rejected connection, ignore continue } if mode == srt.REJECT { // rejected connection, ignore continue } if info.Mode == srt.PUBLISH { go s.handlePublish(ctx, conn, info.StreamName) } else { go s.handleSubcribe(ctx, conn, info.StreamName) } } } func (s *SrtServer) handlePublish(ctx context.Context, conn srt.Conn, streamid string) { publisher := NewPublisher(ctx, conn, streamid, s) session, err := s.lalServer.AddCustomizePubSession(streamid) if err != nil { nazalog.Error(err) } if session != nil { session.WithOption(func(option *base.AvPacketStreamOption) { option.VideoFormat = base.AvPacketStreamVideoFormatAnnexb }) } publisher.SetSession(session) publisher.Run() } func (s *SrtServer) handleSubcribe(ctx context.Context, conn srt.Conn, streamid string) { subscriber := NewSubscriber(ctx, conn, streamid, s.srtOpt.MaxSendPacketSize) subscriber.Run() } func (s *SrtServer) Remove(host string, ss logic.ICustomizePubSessionContext) { s.lalServer.DelCustomizePubSession(ss) } type StreamInfo struct { StreamName string Mode srt.ConnType } func getStreamInfo(streamid string) StreamInfo { info := StreamInfo{ Mode: srt.REJECT, } s := strings.TrimLeft(streamid, "#!::") values := strings.Split(s, ",") for _, v := range values { ss := strings.Split(v, "=") name := ss[0] switch name { case "h": info.StreamName = ss[1] case "m": switch ss[1] { case "publish": info.Mode = srt.PUBLISH case "request": info.Mode = srt.SUBSCRIBE } } } return info } ================================================ FILE: srt/stream_id.go ================================================ package srt import ( "errors" "strings" ) type StreamID struct { User string Host string Resource string SessionID string Type string Mode string } func parseStreamID(streamID string) (*StreamID, error) { if !strings.Contains(streamID, "#!::") { return nil, errors.New("invalid streamid") } split := strings.Split(strings.TrimPrefix(streamID, "#!::"), ",") id := &StreamID{} for _, s := range split { if strings.Contains(s, "=") { kv := strings.Split(s, "=") if len(kv) != 2 { return nil, errors.New("invalid streamid") } if kv[0] == "u" { id.User = kv[1] } if kv[0] == "h" { id.Host = kv[1] } if kv[0] == "r" { id.Resource = kv[1] } if kv[0] == "s" { id.SessionID = kv[1] } if kv[0] == "t" { id.Type = kv[1] } if kv[0] == "m" { id.Mode = kv[1] } } } return id, nil } ================================================ FILE: srt/sub.go ================================================ package srt import ( "context" maxlogic "github.com/q191201771/lalmax/logic" srt "github.com/datarhei/gosrt" "github.com/gofrs/uuid" "github.com/q191201771/lal/pkg/base" "github.com/q191201771/naza/pkg/nazalog" codec "github.com/yapingcat/gomedia/go-codec" flv "github.com/yapingcat/gomedia/go-flv" ts "github.com/yapingcat/gomedia/go-mpeg2" ) type Subscriber struct { ctx context.Context conn srt.Conn streamName string muxer *ts.TSMuxer hasInit bool videoPid uint16 audioPid uint16 flvVideoDemuxer flv.VideoTagDemuxer flvAudioDemuxer flv.AudioTagDemuxer videodts uint32 audiodts uint32 subscriberId string maxSendPacketSize int } func NewSubscriber(ctx context.Context, conn srt.Conn, streamName string, maxSendPacketSize int) *Subscriber { u, _ := uuid.NewV4() sub := &Subscriber{ ctx: ctx, conn: conn, streamName: streamName, muxer: ts.NewTSMuxer(), subscriberId: u.String(), maxSendPacketSize: maxSendPacketSize, } nazalog.Infof("create srt subscriber, streamName:%s, subscriberId:%s", streamName, sub.subscriberId) return sub } func (s *Subscriber) Run() { ok, group := maxlogic.GetGroupManagerInstance().GetGroupByStreamName(s.streamName) if ok { var err error sendBuf := make([]byte, 0, s.maxSendPacketSize*ts.TS_PAKCET_SIZE) s.muxer.OnPacket = func(tsPacket []byte) { defer func() { if err != nil { nazalog.Info("close srt socket") s.conn.Close() } }() select { case <-s.ctx.Done(): return default: } if len(sendBuf) > (s.maxSendPacketSize-1)*ts.TS_PAKCET_SIZE { if _, err = s.conn.Write(sendBuf); err != nil { group.RemoveSubscriber(s.subscriberId) return } sendBuf = sendBuf[0:0] } sendBuf = append(sendBuf, tsPacket...) } group.AddSubscriber(maxlogic.SubscriberInfo{ SubscriberID: s.subscriberId, Protocol: maxlogic.SubscriberProtocolSRT, }, s) } else { nazalog.Warnf("not found stream group, streamName:%s", s.streamName) s.conn.Close() } } func (s *Subscriber) OnMsg(msg base.RtmpMsg) { var err error if !s.hasInit { ok, group := maxlogic.GetGroupManagerInstance().GetGroupByStreamName(s.streamName) if ok { videoheader := group.GetVideoSeqHeaderMsg() if videoheader != nil { if videoheader.IsAvcKeySeqHeader() { s.videoPid = s.muxer.AddStream(ts.TS_STREAM_H264) s.flvVideoDemuxer = flv.CreateFlvVideoTagHandle(flv.FLV_AVC) } else { s.videoPid = s.muxer.AddStream(ts.TS_STREAM_H265) s.flvVideoDemuxer = flv.CreateFlvVideoTagHandle(flv.FLV_HEVC) } s.flvVideoDemuxer.OnFrame(func(codecid codec.CodecID, b []byte, cts int) { s.muxer.Write(s.videoPid, b, uint64(s.videodts)+uint64(cts), uint64(s.videodts)) }) if err = s.flvVideoDemuxer.Decode(videoheader.Payload); err != nil { nazalog.Error(err) return } } audioheader := group.GetAudioSeqHeaderMsg() if audioheader != nil { if audioheader.IsAacSeqHeader() { s.audioPid = s.muxer.AddStream(ts.TS_STREAM_AAC) } else { return } s.flvAudioDemuxer = flv.CreateAudioTagDemuxer(flv.FLV_AAC) s.flvAudioDemuxer.OnFrame(func(codecid codec.CodecID, b []byte) { s.muxer.Write(s.audioPid, b, uint64(s.audiodts), uint64(s.audiodts)) }) if err = s.flvAudioDemuxer.Decode(audioheader.Payload); err != nil { nazalog.Error(err) return } } } s.hasInit = true } if msg.Header.MsgTypeId == base.RtmpTypeIdVideo { s.videodts = msg.Dts() if s.flvVideoDemuxer != nil { if err = s.flvVideoDemuxer.Decode(msg.Payload); err != nil { nazalog.Error(err) return } } } else { s.audiodts = msg.Dts() if s.flvAudioDemuxer != nil { if err = s.flvAudioDemuxer.Decode(msg.Payload); err != nil { nazalog.Error(err) return } } } } func (s *Subscriber) OnStop() { nazalog.Info("srt subscriber onStop") s.conn.Close() } func (s *Subscriber) GetSubscriberStat() maxlogic.SubscriberStat { if s == nil || s.conn == nil { return maxlogic.SubscriberStat{} } var stats srt.Statistics s.conn.Stats(&stats) stat := maxlogic.SubscriberStat{ ReadBytesSum: stats.Accumulated.ByteRecv, WroteBytesSum: stats.Accumulated.ByteSent, } if remoteAddr := s.conn.RemoteAddr(); remoteAddr != nil { stat.RemoteAddr = remoteAddr.String() } return stat } ================================================ FILE: utils/adjustdts.go ================================================ package utils import "time" type DtsDecoder struct { startDts time.Duration clockRate time.Duration overall time.Duration prev uint32 } func NewDtsDecoder(startDts, clockRate time.Duration, prevDts uint32) *DtsDecoder { return &DtsDecoder{ startDts: startDts, clockRate: clockRate, prev: prevDts, } } func multiplyAndDivide(v, m, d time.Duration) time.Duration { secs := v / d dec := v % d return (secs*m + dec*m/d) } func (d *DtsDecoder) Decode(ts uint32) time.Duration { // 这样可以解决翻转问题 diff := int32(ts - d.prev) if diff >= 1000 || diff <= -1000 { // 以视频为主,音频计算以后看 diff = 40 } d.prev = ts d.overall += time.Duration(diff * int32(d.clockRate/1000)) return d.startDts + multiplyAndDivide(d.overall, time.Second, d.clockRate) } ================================================ FILE: version/README.md ================================================ 这个目录用于存放lalmax版本信息说明 版本格式 v0.x1.x2 说明如下 x1为大版本,例如一个大的功能发布或者常规迭代 x2为小版本,例如小问题修复 ================================================ FILE: version/v0.1.0.md ================================================ lalmax v0.1.0版本说明 # 功能点 (1) 支持SRT推拉流(暂不支持加密) [SRT相关说明](../document/srt.md) srt支持以后可以使用srt推流到lalmax,然后使用rtsp/hls/rtmp/http-flv/srt等协议进行拉流,也可以使用rtmp/rtsp推流到lalmax中,使用srt进行拉流 ## SRT url格式 推流url srt://127.0.0.1:6001?streamid=#!::r=test110,m=publish 拉流url srt://127.0.0.1:6001?streamid=#!::r=test110,m=request ================================================ FILE: version/v0.2.0.md ================================================ lalmax v0.2.0版本说明 [RTC相关说明](../document/rtc.md) # 功能点 (1)支持WHIP推流和WHEP拉流,可以对接[OBS](https://github.com/obsproject/obs-studio/actions/runs/5227109208?pr=7926)、[vue-wish](https://github.com/zllovesuki/vue-wish) 视频:h264 音频:G711A/G711U # RTC url格式 推流url http(s)://127.0.0.1:1290/whip?streamid=test110 拉流url http(s)://127.0.0.1:1290/whep?streamid=test110