diff --git a/README.md b/README.md index 7ecb6fc..127e034 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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),原生 HTTP API 见[lal_api.md](./document/lal_api.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 两个顶层标签维护单个配置文件。 @@ -20,7 +20,7 @@ lalmax.conf.json 配置主要由 2 部分组成 ``` docker build -t lalmax:init ./ -docker run -it -p 1935:1935 -p 8080:8080 -p 4433:4433 -p 5544:5544 -p 8083:8083 -p 8084:8084 -p 30000-30100:30000-30100/udp -p 1290:1290 -p 6001:6001/udp 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 ``` diff --git a/conf/lalmax.conf.json b/conf/lalmax.conf.json index 30f793e..0aa9bc9 100644 --- a/conf/lalmax.conf.json +++ b/conf/lalmax.conf.json @@ -42,6 +42,9 @@ "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", @@ -132,7 +135,7 @@ "addr": "" }, "http_api": { - "enable": true, + "enable": false, "addr": ":8083" }, "server_id": "1", diff --git a/config/config.go b/config/config.go index 805246a..9262297 100644 --- a/config/config.go +++ b/config/config.go @@ -90,6 +90,9 @@ type HttpNotifyConfig struct { UpdateIntervalSec int `json:"update_interval_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"` diff --git a/document/api.md b/document/api.md index a9b6004..3e749d7 100644 --- a/document/api.md +++ b/document/api.md @@ -1,395 +1,374 @@ -# HTTP API +# HTTP API 总览 -lalmax 提供了一些 HTTP 的 API 接口,通过这些接口,可以获取 lalmax 的一些状态,以及控制一些行为。 +`lalmax` 对外建议统一只暴露一个 HTTP 管理入口,也就是 `lalmax.http_config.http_listen_addr`,示例配置通常是 `:1290`。 +`lal.http_api` 仍然可以保留给排查 `lal` 原生行为时使用,但默认建议关闭,不要和 `lalmax` 的管理入口混用。 - lalmax 的 HTTP API 旨在包含 lal 的 API 调用,并补充相关订阅数据。其请求方式,请求参数,响应参数等与 lal API 完全一致。 +建议配合以下文档一起看: -可参考本文档,也可以参考 lal API 文档。 +- [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 -- 查询类型的,以 `/api/stat` 开头 -- 控制类型的,以 `/api/ctrl` 开头 +统一返回结构如下: -```bash -1.1. /api/stat/group // 查询特定group的信息 -1.2. /api/stat/all_group // 查询所有group的信息 -1.3. /api/stat/lal_info // 查询服务器信息 - -2.1. /api/ctrl/start_relay_pull // 控制服务器从远端拉流至本地 -2.2. /api/ctrl/stop_relay_pull // 停止relay pull -2.3. /api/ctrl/kick_session // 强行踢出关闭指定session,session可以是pub、sub、pull类型 -2.4. /api/ctrl/start_rtp_pub // 打开GB28181接收端口(停止先使用kick_session) +```json +{ + "error_code": 0, + "desp": "succ", + "data": {} +} ``` -## 名词解释 +常见 `error_code` 如下: -+ `group` lal中的group是群组的概念,lal作为流媒体服务器,通过流名称将每1路输入流转发给`1~n`路输出流,流名称相同的输入输出流被同1个group群组管理。 +| 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` 执行失败 | -## 接口规则 +## 当前接口列表 -1 所有接口的返回结果中,必含的一级参数: +### 统计接口 -```json -{ - "error_code": 0, - "desp": "succ", - "data": ... -} -``` +- `GET /api/stat/group` +- `GET /api/stat/all_group` +- `GET /api/stat/lal_info` -2 `error_code`列表: +### 控制接口 -| error_code | desp | 说明 | -| ---------- | -------------------------- | -------------------- | -| 0 | succ | 调用成功 | -| 1001 | group not found | group不存在 | -| 1002 | param missing | 必填参数缺失 | -| 1003 | session not found | session不存在 | -| 2001 | 多种值,表示失败的具体原因 | start_relay_pull失败 | -| 2002 | 打开gb28181端口失败 | start_rtp_pub失败 | +- `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` -3 注意,有的接口使用HTTP GET+URL 参数的形式调用,有的接口使用 HTTP POST+JSON body 的形式调用,请仔细查看文档说明。 +### Hook 接口 -## 接口详情 +- `GET /api/hook/recent` +- `GET /api/hook/stream` -### 1.1 `/api/stat/group` +## 统计接口 -✸ 简要描述: 查询指定group的信息 +### `GET /api/stat/group` -✸ 请求示例: +查询单个流分组的当前状态。 -``` -$curl http://127.0.0.1:8083/api/stat/group?stream_name=test110 -``` +请求参数: -✸ 请求方式: `HTTP GET`+url参数 +- `stream_name`:必填,流名 +- `app_name`:可选,应用名 -✸ 请求参数: +说明: -- stream_name | 必填项 | 指定 group 的流名称 +- 如果同一个 `stream_name` 只对应一个流分组,可以只传 `stream_name` +- 如果同一个 `stream_name` 在多个 `app_name` 下都存在,建议同时传 `app_name + stream_name` +- 当前返回结果会在兼容 `lal` 原生字段的基础上,额外补充 `lalmax.ext_subs` -✸ 返回值`error_code`可能取值: +请求示例: -- 0 group存在,查询成功 -- 1001 group不存在 -- 1002 必填参数缺失 +```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, // 接口返回值,0表示成功 - "desp": "succ", // 接口返回描述,"succ"表示成功 + "error_code": 0, + "desp": "succ", "data": { - "stream_name": "test110", // 流名称 - "app_name": "live", // appName - "audio_codec": "AAC", // 音频编码格式 "AAC" - "video_codec": "H264", // 视频编码格式 "H264" | "H265" - "video_width": 640, // 视频宽 - "video_height": 360, // 视频高 - "pub": { // -----接收推流的信息----- - "session_id": "RTMPPUBSUB1", // 会话ID,会话全局唯一标识 - "protocol": "RTMP", // 推流协议,取值范围: "RTMP" | "RTSP" - "base_type": "PUB", // 基础类型,该处固定为"PUB" - "start_time": "2020-10-11 19:17:41.586", // 推流开始时间 - "remote_addr": "127.0.0.1:61353", // 对端地址 - "read_bytes_sum": 9219247, // 累计读取数据大小(从推流开始时计算) - "wrote_bytes_sum": 3500, // 累计发送数据大小 - "bitrate_kbits": 436, // 最近5秒码率,单位kbit/s。对于pub类型,如无特殊声明,等价于`read_bitrate_kbits` - "read_bitrate"_kbits: 436, // 最近5秒读取数据码率 - "write_bitrate_kbits": 0 // 最近5秒发送数据码率 + "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": [ // -----拉流的信息,可能存在多种协议,每种协议可能存在多个会话连接----- + "subs": [ + { + "session_id": "RTMPSUB1", + "protocol": "RTMP", + "base_type": "SUB" + }, { - "session_id": "FLVSUB1", // 会话ID,会话全局唯一标识 - "protocol": "FLV", // 拉流协议,取值范围: "RTMP" | "FLV" | "TS" - "base_type" "SUB" // 基础类型,该处固定为"SUB" - "start_time": "2020-10-11 19:19:21.724", // 拉流开始时间 - "remote_addr": "127.0.0.1:61785", // 对端地址 - "read_bytes_sum": 134, // 累计读取数据大小(从拉流开始时计算) - "wrote_bytes_sum": 2944020, // 累计发送数据大小 - "bitrate_kbits": 439, // 最近5秒码率,单位kbit/s。对于sub类型,如无特殊声明,等价于`write_bitrate_kbits` - "read_bitrate_kbits": 0, // 最近5秒读取数据码率 - "write_bitrate_kbits": 439 // 最近5秒发送数据码率 + "session_id": "whep-123", + "protocol": "WHEP", + "base_type": "SUB" } ], - "pull": { // -----该节点从其他节点拉流回源信息----- - "base_type": "PULL", // 该处固定为"PULL" - ... // 其他字段和上面pub的内部字段相同,不再赘述 + "pull": { + "base_type": "PULL" }, - "pushs":[] // 主动外连转推信息,暂时不提供 + "in_frame_per_sec": [], + "lalmax": { + "ext_subs": [ + { + "session_id": "whep-123", + "protocol": "WHEP", + "base_type": "SUB" + } + ] + } } } ``` -### 1.2 `/api/stat/all_group` +字段语义: -✸ 简要描述: 查询所有group的信息 +- `subs`:统一后的订阅者视图,包含 `lal` 原生订阅者和 `lalmax` 扩展订阅者 +- `lalmax.ext_subs`:只包含 `lalmax` 扩展层维护的订阅者,便于业务侧区分来源 -✸ 请求示例: +### `GET /api/stat/all_group` -``` -$curl http://127.0.0.1:8083/api/stat/all_group -``` - -✸ 请求方式: `HTTP GET` - -✸ 请求参数: 无 +查询当前所有流分组。 -✸ 返回值`error_code`可能取值: +请求示例: -- 0 查询成功 +```bash +curl "http://127.0.0.1:1290/api/stat/all_group" +``` -✸ 返回示例: +返回示例: -``` +```json { - "error_code": 0, - "desp": "succ", - "data": { - "groups": [ - ... // 数组内每个元素的内容格式和/api/stat/group接口中data字段相同,不再赘述 - ] - } + "error_code": 0, + "desp": "succ", + "data": { + "groups": [ + { + "stream_name": "test110", + "app_name": "live", + "lalmax": { + "ext_subs": [] + } + } + ] + } } ``` -### 1.3 `/api/stat/lal_info` +其中 `groups[*]` 的结构和 `/api/stat/group` 的 `data` 完全一致。 -✸ 简要描述: 查询服务器信息 +### `GET /api/stat/lal_info` -✸ 请求示例: +查询服务基础信息。 -``` -$curl http://127.0.0.1:8083/api/stat/lal_info +请求示例: + +```bash +curl "http://127.0.0.1:1290/api/stat/lal_info" ``` -✸ 请求方式: `HTTP GET` +该接口直接返回内嵌 `lal` 的运行信息,常见字段包括: -✸ 请求参数: 无 +- `server_id` +- `bin_info` +- `lal_version` +- `api_version` +- `notify_version` +- `start_time` -✸ 返回值`error_code`可能取值: +## 控制接口 -- 0 查询成功 +### `POST /api/ctrl/start_relay_pull` -✸ 返回示例: +让服务主动去远端拉流。 -``` -{ - "error_code": 0, - "desp": "succ", - "data": { - "server_id": "1", - "bin_info": "GitTag=v0.17.0. GitCommitLog=bbf850aca2d4f3e55380d44ca9c3a16be60c8d39 ${NewVersion} -> version.go. GitStatus= M CHANGELOG.md | M gen_tag.sh | M pkg/base/version.go. BuildTime=2020.11.21.173812. GoVersion=go version go1.14.2 darwin/amd64. runtime=darwin/amd64.", - "lal_version": "v0.17.0", // lal可执行文件版本信息 - "api_version": "v0.1.2", // HTTP API接口版本信息 - "notify_version": "v0.0.4", // HTTP Notify版本信息 - "start_time": "2020-11-21 17:34:53.973" // lal进程启动时间 - } -} -``` +请求体为 JSON,必填字段: -### 2.1 `/api/ctrl/start_relay_pull` +- `url` -✸ 简要描述: 控制服务器主动从远端拉流至本地 +常用可选字段: -✸ 请求示例: +- `stream_name` +- `pull_timeout_ms` +- `pull_retry_num` +- `auto_stop_pull_after_no_out_ms` +- `rtsp_mode` +- `debug_dump_packet` -``` -$curl -H "Content-Type:application/json" -X POST -d '{"url": "rtmp://127.0.0.1/live/test110?token=aaa&p2=bbb", "pull_retry_num": 0}' http://127.0.0.1:8083/api/ctrl/start_relay_pull -``` +当前程序里的默认值如下: -✸ 请求方式: `HTTP POST` +- `pull_timeout_ms` 默认 `10000` +- `pull_retry_num` 默认 `0` +- `auto_stop_pull_after_no_out_ms` 默认 `-1` +- `rtsp_mode` 默认 `0`,也就是 TCP -✸ 请求参数: +请求示例: -``` -{ - "url": "rtmp://127.0.0.1/live/test110?token=aaa&p2=bbb", //. 必填项,回源拉流的完整url地址,目前支持rtmp和rtsp - // - "stream_name": "test110", //. 选填项,如果不指定,则从`url`参数中解析获取 - // - "pull_timeout_ms": 10000, //. 选填项,pull建立会话的超时时间,单位毫秒。 - // 默认值是10000 - // - "pull_retry_num": 0, //. 选填项,pull连接失败或者中途断开连接的重试次数 - // -1 表示一直重试,直到收到stop请求,或者开启并触发下面的自动关闭功能 - // = 0 表示不重试 - // > 0 表示重试次数 - // 默认值是0 - // 提示:不开启自动重连,你可以在收到HTTP-Notify on_relay_pull_stop, on_update等消息时决定是否重连 - // - "auto_stop_pull_after_no_out_ms": -1, //. 选填项,没有观看者时,自动关闭pull会话,节约资源 - // -1 表示不启动该功能 - // = 0 表示没有观看者时,立即关闭pull会话 - // > 0 表示没有观看者持续多长时间,关闭pull会话,单位毫秒 - // 默认值是-1 - // 提示:不开启该功能,你可以在收到HTTP-Notify on_sub_stop, on_update等消息时决定是否关闭relay pull - // - "rtsp_mode": 0, //. 选填项,使用rtsp时的连接方式 - // 0 tcp - // 1 udp - // 默认值是0 - "debug_dump_packet": "" //. 选填项,将接收的数据存成文件 - // 注意啊,有问题的时候才使用,把存储的文件提供给lal作者分析。没问题时关掉,避免性能下降并且浪费磁盘 - // 值举例:"./dump/test110.laldump", "/tmp/test110.laldump" - // 如果为空字符串"",则不会存文件 - // 默认值是"" -} +```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" ``` -✸ 返回值`error_code`可能取值: +说明: -- 0 请求接口成功。 -- 1002 参数错误 -- 2001 请求接口失败,失败描述参考desp - - "lal.logic: in stream already exist in group": 输入流已经存在了 +- 接口返回成功,只代表命令已经被接受 +- 是否真的拉流成功,需要看后续状态接口,或者看 Hook 事件中的 `on_relay_pull_start`、`on_update` -> 注意:返回成功表示lalserver收到命令并开始从远端拉流,并不保证从远端拉流成功。判断是否拉流成功,可以使用HTTP-Notify的on_relay_pull_start, on_update等回调事件 +### `GET /api/ctrl/stop_relay_pull` +### `POST /api/ctrl/stop_relay_pull` -✸ 返回示例: +关闭指定的 relay pull 会话。 -``` -{ - "error_code": 0, - "desp": "succ", - "data": { - "stream_name": "test110", - "session_id": "RTMPPULL1" - } -} +当前实现里,这个接口无论用 `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" ``` -### 2.2 `/api/ctrl/stop_relay_pull` +说明: -✸ 简要描述: 关闭特定的relay pull +- 也可以用 `kick_session` 关闭 pull 会话 -✸ 请求示例: +### `POST /api/ctrl/kick_session` -``` -$curl http://127.0.0.1:8083/api/ctrl/stop_relay_pull?stream_name=test110 +强制关闭指定会话。 + +请求体为 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" ``` -✸ 请求方式: `HTTP GET`+url参数 +适用对象: -✸ 请求参数: +- 推流会话 +- 拉流会话 +- relay pull 会话 -- stream_name | 必填项 | 需要关闭relay pull的流名称 +### `POST /api/ctrl/start_rtp_pub` -✸ 返回值`error_code`可能取值: +打开一个 GB28181/RTP 接收会话。 -- 0 group存在,查询成功 -- 1001 group不存在 -- 1002 必填参数缺失 -- 1003 pull session不存在 +请求体为 JSON,必填字段: -✸ 返回示例: +- `stream_name` -``` -{ - "error_code": 0, - "desp": "succ", - "data": { - "session_id": "RTMPPULL1" - } -} -``` +常用可选字段: -> 提示,除了stop_relay_pull,也可以使用kick_session关闭relay pull回源拉流。 +- `port` +- `timeout_ms` +- `is_tcp_flag` +- `debug_dump_packet` -### 2.3 `/api/ctrl/kick_session` +当前程序里的默认值: -✸ 简要描述: 强行踢出关闭指定session。session可以是pub、sub、pull类型。 +- `timeout_ms` 默认 `60000` -✸ 请求示例: +请求示例: -``` -$curl -H "Content-Type:application/json" -X POST -d '{"stream_name": "test110", "session_id": "FLVSUB1"}' http://127.0.0.1:8083/api/ctrl/kick_session +```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" ``` -✸ 请求方式: `HTTP POST` +说明: -✸ 请求参数: +- `port=0` 表示由服务自动分配端口 +- 成功后会返回 `session_id` 和最终监听端口 -``` -{ - "stream_name": "test110", // 必填项,流名称 - "session_id": "FLVSUB1" // 必填项,会话唯一标识 -} -``` +### `POST /api/ctrl/stop_rtp_pub` -✸ 返回值`error_code`可能取值: +关闭 GB28181/RTP 接收会话。 -- 0 请求接口成功。指定会话被关闭 -- 1001 指定流名称对应的group不存在 -- 1002 参数错误 -- 1003 指定会话不存在 +当前实现支持两种传参方式: -✸ 返回示例: +- 查询参数:`stream_name` 或 `session_id` +- JSON 请求体:`stream_name` 或 `session_id` -``` -{ - "error_code": 0, - "desp": "succ" -} +两者至少传一个。 + +请求示例: + +```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" ``` -### 2.4 `/api/ctrl/start_rtp_pub` +成功后会返回被关闭的 `session_id`。 -✸ 简要描述: 打开GB28181接收端口 +## Hook 接口 -✸ 请求示例: +### `GET /api/hook/recent` -``` -$curl -H "Content-Type:application/json" -X POST -d '{"stream_name": "test110", "port": 0, "timeout_ms": 10000}' http://127.0.0.1:8083/api/ctrl/start_rtp_pub -``` +读取最近的 Hook 事件快照。 -✸ 请求方式: `HTTP POST` +常用查询参数: -✸ 请求参数: +- `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" ``` -{ - "stream_name": "test110", //. 必填项,流名称,后续这条流都与这个流名称绑定,比如生成的录制文件名,用其他协议拉流的流名称等 - // - "port": 0, //. 选填项,接收端口 - // 如果为0,lalserver选择一个随机端口,并将端口通过返回值返回给调用方 - // 默认值是0 - // - "timeout_ms": 60000, //. 选填项,超时时间,单位毫秒,开启时或中途超过这个时长没有收到任何数据,则关闭端口监听 - // 如果为0,则不会超时关闭 - // 默认值是60000 - // - "is_tcp_flag": 0, //. 选填项,是否使用tcp传输流媒体音视频数据 - // 如果为1,使用tcp;如果为0,使用udp - // 默认值为0 - "debug_dump_packet": "" //. 选填项,将接收的udp数据存成文件 - // 注意啊,有问题的时候才使用,把存储的文件提供给lal作者分析。没问题时关掉,避免性能下降并且浪费磁盘 - // 值举例:"./dump/test110.laldump", "/tmp/test110.laldump" - // 如果为空字符串"",则不会存文件 - // 默认值是"" -} -``` -✸ 返回值`error_code`可能取值: +### `GET /api/hook/stream` + +使用 SSE 持续订阅 Hook 事件。 -- 0 请求接口成功。端口成功打开 -- 1002 参数错误 -- 2002 绑定监听端口失败 +它和 `/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" ``` -{ - "error_code": 0, - "desp": "succ", - "data": { - "stream_name": "test110", - "session_id": "PSSUB1", - "port": 20000 - } -} -``` \ No newline at end of file + +返回格式示例: + +```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)。 diff --git a/document/api_gateway.md b/document/api_gateway.md new file mode 100644 index 0000000..d37d228 --- /dev/null +++ b/document/api_gateway.md @@ -0,0 +1,112 @@ +# 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/*` 获取 +- 避免控制响应膨胀,降低兼容性和调用方解析成本 diff --git a/document/config.md b/document/config.md index 594b156..a6cb95c 100644 --- a/document/config.md +++ b/document/config.md @@ -1,223 +1,311 @@ -# srt_config -主要用于设置srt相关的配置 -- enable: srt服务使能配置,设置为true才可以使用srt功能 +# lalmax 配置说明 -*类型*: bool +本文档说明 `conf/lalmax.conf.json` 里 `lalmax` 这一段的配置。 +`lal` 原生配置请看 [lal_config.md](./lal_config.md)。 -*值举例*: true +## 推荐配置结构 -- addr[string]: srt服务监听地址,srt服务监听的是UDP端口 +当前程序推荐使用一个统一的配置文件,并按顶层标签拆开: -*类型*: string +```json +{ + "lalmax": { + "server_id": "1", + "srt_config": {}, + "rtc_config": {}, + "http_config": {}, + "fmp4_config": {}, + "logic_config": {}, + "http_notify": {}, + "gb28181_config": {} + }, + "lal": {} +} +``` -*值举例*: ":6001" +说明: -# rtc_config -主要用于设置rtc相关的配置,目前rtc只实现了WHIP/WHEP,需要配合http_config一起使用 -- enable: rtc服务使能配置,设置为true才可以使用rtc功能 +- `lalmax`:`lalmax` 自己的扩展能力配置 +- `lal`:内嵌 `lal` 的原生配置 -*类型*: bool +如果同时提供了顶层 `lal` 标签,程序会优先使用这段内容作为 `lal` 的原生配置,不再读取 `lal_config_path` 指向的文件。 -*值举例*: true +## srt_config -- iceHostNatToIps: rtc服务内穿ip,具体为SDP中的candidate信息,不设置的话,会输出全部网卡的地址 +SRT 服务配置。 -*类型*: []string +- `enable`:是否启用 SRT +- `addr`:SRT 监听地址,示例 `:6001` -*举例*: ["192.168.0.1"] +示例: -- iceUdpMuxPort: rtc udp复用端口 +```json +{ + "enable": true, + "addr": ":6001" +} +``` -*类型*: int +## rtc_config -*值举例*: 4888 +RTC 服务配置。目前主要用于 WHIP、WHEP 和 Jessibuca 播放链路。 -- iceTcpMuxPort: rtc tcp复用端口 +- `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` -*类型*: int +示例: -*值举例*: 4888 +```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 -主要用于设置http相关的配置,依赖http的协议均需要设置,涉及的协议有rtc、http-fmp4、hls(fmp4/llhls) -- http_listen_addr: http服务监听地址 +## http_config -*类型*: string +`lalmax` 自己的 HTTP/HTTPS 配置。管理接口、RTC 信令、HTTP-FMP4、HLS-FMP4/LLHLS 都依赖这里。 -*值举例*: ":1290" +- `http_listen_addr`:HTTP 监听地址,示例 `:1290` +- `enable_https`:是否启用 HTTPS +- `https_listen_addr`:HTTPS 监听地址 +- `https_cert_file`:HTTPS 证书文件 +- `https_key_file`:HTTPS 私钥文件 +- `ctrl_auth_whitelist`:管理接口鉴权配置 -- enable_https: https使能 +`ctrl_auth_whitelist` 的字段如下: -*类型*: bool +- `secrets`:允许的令牌列表,请求时通过查询参数 `token` 传入 +- `ips`:允许访问的客户端 IP 列表 -*值举例*: true +当前鉴权覆盖范围: -- https_listen_addr: https监听地址 +- `/api/stat/*` +- `/api/ctrl/*` +- `/api/hook/*` -*类型*: string +规则说明: -*值举例*: ":1233" +- 如果 `secrets` 和 `ips` 都为空,表示不做鉴权 +- 如果两者都配置了,请求必须同时满足两项 +- 鉴权失败时,HTTP 状态码仍然是 `200`,返回体里的 `error_code` 是 `401` -- https_cert_file: https cert文件路径 +示例: -*类型*: string +```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"] + } +} +``` -*值举例*: "./conf/cert.pem" +## fmp4_config -- https_key_file: https key文件路径 +`lalmax` 的 FMP4 相关配置,分成 `http` 和 `hls` 两段。 -*类型*: string +### fmp4_config.http -*值举例*: "./conf/key.pem" +HTTP-FMP4 配置。 -- ctrl_auth_whitelist: 统计控制类接口鉴权,用于访问以 `/api/stat` 和 `/api/ctrl` 前缀的接口,无权限访问时 http status 将会响应 200,其 error_code 为 401。多种鉴权方式都不是零值时,必须同时满足才会通过鉴权。 +- `enable`:是否启用 HTTP-FMP4 -*类型*: object +### fmp4_config.hls -- secrets: 用户请求鉴权的方式是增加 query 参数 `token`,例如 `token=secret`,满足数组中任意匹配则通过。 +HLS-FMP4 / LLHLS 配置。 -*类型*: []string +- `enable`:是否启用 HLS-FMP4 / LLHLS +- `segment_count`:m3u8 保留的切片数量 +- `segment_duration`:切片时长,单位秒 +- `part_duration`:LLHLS part 时长,单位毫秒 +- `low_latency`:是否启用低延迟 HLS -*值举例*: ["EC3D1536-5D93-4BD6-9FBD-96A52CB1596D"] +示例: -- ips: 远程 IP 白名单,空数组表示允许任意 IP 访问,无权限访问时 http status 将会响应 200,其 error_code 为 401。 +```json +{ + "http": { + "enable": true + }, + "hls": { + "enable": true, + "segment_count": 7, + "segment_duration": 1, + "part_duration": 200, + "low_latency": false + } +} +``` -*类型*: []string +## logic_config -*值举例*: ["192.168.1.2","192.168.1.3"] +`lalmax` 扩展流分组配置。 +- `gop_cache_num`:GOP 缓存数量 +- `single_gop_max_frame_num`:单个 GOP 最多缓存多少帧;`0` 表示自动判断 -# fmp4_config -主要用于设置 lalmax fMP4 相关能力,需要配合 `http_config` 一起使用。 +示例: -## http -主要用于设置 HTTP-FMP4 相关的配置。 +```json +{ + "gop_cache_num": 1, + "single_gop_max_frame_num": 0 +} +``` -- enable: HTTP-FMP4 服务使能配置 +## server_id -*类型*: bool +服务实例标识。 -*值举例*: true +这个值会出现在: -## hls -主要用于设置 HLS-FMP4/LLHLS 相关的配置。HLS-TS 能力请使用 lal 的 `hls` 配置。 +- Hook 事件的 `server_id` +- HTTP 回调的 payload -- enable: HLS-FMP4/LLHLS 服务使能配置 +示例: -*类型*: bool - -*值举例*: true - -- segment_count: HLS-FMP4 m3u8 返回的切片个数,默认为 7。LLHLS 默认设置为 7 个。 - -*类型*: int - -*值举例*: 3 - -- segment_duration: HLS-FMP4 切片时长,默认为 1s - -*类型*: int - -*值举例*: 3 - -- part_duration: LLHLS part 部分的时长,默认为 200ms - -*类型*: int - -*值举例*: 100 - -- low_latency: LLHLS 使能配置,开启此配置后使用低延迟 HLS - -*类型*: bool - -*值举例*: true - -# logic_config -主要用于 lalmax 扩展流组相关的配置。 - -- gop_cache_num: gop 缓存的数量,默认为 1 - -*类型*: int - -*值举例*: 3 - -- single_gop_max_frame_num: 一个 gop 的缓存帧数,0 表示智能识别 - -*类型*: int - -*值举例*: 120 - - -# gb28181_config - -- enable: gb28181使能配置 - -*类型*: bool - -*值举例*: true - -- listenAddr: gb28181监听地址 - -*类型*: string - -*值举例*: "0.0.0.0" - -- sipNetwork: 传输协议 - -*类型*: string - -*值举例*: "udp" - -- sipIp: sip服务器公网IP - -*类型*: string - -*值举例*: "100.100.100.101" - -- sipPort: sip服务器公网端口 - -*类型*: uint16 - -*值举例*: 5060 - -- serial: sip服务器ID - -*类型*: string - -*值举例*: "34020000002000000001" - -- realm: sip服务器域 - -*类型*: string - -*值举例*: "3402000000" - -- username: sip服务器账号 - -*类型*: string - -*值举例*: "admin" - -- password: sip服务器密码 - -*类型*: string - -*值举例*: "admin123" - -# onvif_config -- enable: onvif使能配置 - -*类型*: bool - -*值举例*: true - -# 配置文件结构 -推荐使用单个 `lalmax.conf.json`,并按顶层标签拆分配置: - -- `lalmax`: lalmax 扩展能力配置,例如 SRT、RTC、HTTP-FMP4、GB28181、Hook 缓存等。 -- `lal`: lal 原生配置,例如 RTMP、RTSP、HTTP-FLV、HLS-TS、录制、鉴权等。 - -旧版平铺配置和 `lal_config_path` 仍兼容。新配置中如果提供了 `lal` 标签,将优先使用该标签内容作为 lal 原生配置,不再读取 `lal_config_path`。 - -# lal_config_path -兼容旧版配置,用于设置 lal 原生配置文件的路径。推荐新配置使用顶层 `lal` 标签代替。 +```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 与插件化架构 diff --git a/document/hook_api.md b/document/hook_api.md new file mode 100644 index 0000000..c42f8b2 --- /dev/null +++ b/document/hook_api.md @@ -0,0 +1,176 @@ +# 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` 仍然建议保留给状态快照、巡检和最终一致对账使用 diff --git a/document/hook_plugin_architecture.md b/document/hook_plugin_architecture.md new file mode 100644 index 0000000..2c7e942 --- /dev/null +++ b/document/hook_plugin_architecture.md @@ -0,0 +1,526 @@ +# 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: 聚合、过滤、缓存、分发 +- 插件: 具体业务处理 + +这套结构的核心价值是: + +- 主流程稳定 +- 业务接入灵活 +- 多业务可并存 +- 后续扩展成本更低 diff --git a/document/lal_api.md b/document/lal_api.md index 33b281f..88b66e3 100644 --- a/document/lal_api.md +++ b/document/lal_api.md @@ -1,306 +1,53 @@ -# lal 原生 HTTP API +# lal Native HTTP API -本文档说明 `conf/lalmax.conf.json` 中 `lal.http_api` 暴露的 lal 原生 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": true, + "enable": false, "addr": ":8083" } } ``` -默认访问地址: +启用后可访问: ```text http://127.0.0.1:8083 ``` -lalmax 自身也在 `lalmax.http_config.http_listen_addr` 上提供 `/api/stat` 和 `/api/ctrl` 兼容接口,并会补充 lalmax 扩展订阅信息。只需要管理 lal 原生流状态时,可以直接使用本文档中的 lal 原生 API。 - -## 通用响应 - -所有 API 都返回 JSON,基础结构如下: - -```json -{ - "error_code": 0, - "desp": "succ", - "data": {} -} -``` - -常见 `error_code`: - -| error_code | desp | 说明 | -| --- | --- | --- | -| 0 | succ | 调用成功 | -| 404 | page not found | API 路径不存在 | -| 1001 | group not found | 流分组不存在 | -| 1002 | param missing | 必填参数缺失 | -| 1003 | session not found | 会话不存在 | -| 2001 | 失败原因见 desp | `start_relay_pull` 失败 | -| 2002 | 失败原因见 desp | `start_rtp_pub` 监听端口失败 | - -## Web UI - -### `GET /lal.html` - -返回 lal 原生 Web UI 页面。 - -```bash -curl http://127.0.0.1:8083/lal.html -``` - -## 查询接口 - -### `GET /api/stat/lal_info` - -查询 lal 服务信息。 - -```bash -curl http://127.0.0.1:8083/api/stat/lal_info -``` +## Native Endpoints -响应示例: +`lal` 原生 HTTP API 当前主要包含: -```json -{ - "error_code": 0, - "desp": "succ", - "data": { - "server_id": "1", - "bin_info": "GitTag=unknown. GitCommitLog=unknown.", - "lal_version": "v0.37.4", - "api_version": "v0.1.2", - "notify_version": "v0.0.4", - "WebUiVersion": "", - "start_time": "2026-04-22 10:00:00.000" - } -} -``` +- `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` -### `GET /api/stat/all_group` +## Recommended Gateway -查询所有流分组。 +推荐直接使用 `lalmax` API,因为它会在 `lal` 原生结果基础上补充: -```bash -curl http://127.0.0.1:8083/api/stat/all_group -``` +- `lalmax` 扩展订阅者统计 +- 更完整的统一状态视图 +- 统一的 hook 事件读取能力 +- 统一鉴权入口 -响应中的 `data.groups` 是数组,每个元素结构与 `/api/stat/group` 的 `data` 相同。 - -### `GET /api/stat/group` - -查询指定流分组。 - -```bash -curl "http://127.0.0.1:8083/api/stat/group?stream_name=test110" -``` - -请求参数: - -| 参数 | 必填 | 说明 | -| --- | --- | --- | -| `stream_name` | 是 | 流名称 | -| `app_name` | 否 | lalmax 兼容 API 可用于精确匹配扩展订阅者;lal 原生 API 当前仍主要按 `stream_name` 查询 | - -响应示例: - -```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": "RTMPPUBSUB1", - "protocol": "RTMP", - "base_type": "PUB", - "remote_addr": "127.0.0.1:50000", - "start_time": "2026-04-22 10:00:00", - "read_bytes_sum": 1024, - "wrote_bytes_sum": 0, - "bitrate_kbits": 800, - "read_bitrate_kbits": 800, - "write_bitrate_kbits": 0 - }, - "subs": [], - "pull": { - "session_id": "", - "protocol": "", - "base_type": "" - }, - "in_frame_per_sec": [] - } -} -``` - -字段说明: - -| 字段 | 说明 | -| --- | --- | -| `stream_name` | 流名称 | -| `app_name` | 应用名或路径前缀 | -| `audio_codec` | 音频编码,例如 `AAC`、`PCMA`、`PCMU`、`OPUS` | -| `video_codec` | 视频编码,例如 `H264`、`H265` | -| `pub` | 推流会话统计 | -| `subs` | 拉流会话统计数组 | -| `pull` | 回源拉流会话统计 | -| `in_frame_per_sec` | 输入帧率采样 | - -会话统计字段: - -| 字段 | 说明 | -| --- | --- | -| `session_id` | 会话唯一标识 | -| `protocol` | 协议,例如 `RTMP`、`RTSP`、`FLV`、`TS` | -| `base_type` | 会话类型,常见为 `PUB`、`SUB`、`PULL` | -| `remote_addr` | 对端地址 | -| `start_time` | 会话开始时间 | -| `read_bytes_sum` | 累计读取字节数 | -| `wrote_bytes_sum` | 累计写出字节数 | -| `bitrate_kbits` | 统计周期内码率,单位 kbit/s | -| `read_bitrate_kbits` | 统计周期内读取码率,单位 kbit/s | -| `write_bitrate_kbits` | 统计周期内写出码率,单位 kbit/s | - -## 控制接口 - -### `POST /api/ctrl/start_relay_pull` - -让 lal 主动从远端拉流到本地。 - -```bash -curl -H "Content-Type: application/json" \ - -X POST \ - -d '{"url":"rtmp://127.0.0.1/live/test110","pull_retry_num":0}' \ - http://127.0.0.1:8083/api/ctrl/start_relay_pull -``` - -请求参数: - -| 参数 | 必填 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `url` | 是 | 无 | 远端拉流地址,支持 RTMP、RTSP | -| `stream_name` | 否 | 从 `url` 解析 | 本地流名称 | -| `pull_timeout_ms` | 否 | `10000` | 建立拉流连接的超时时间 | -| `pull_retry_num` | 否 | `0` | 重试次数,`-1` 表示一直重试,`0` 表示不重试 | -| `auto_stop_pull_after_no_out_ms` | 否 | `-1` | 没有输出订阅时自动停止拉流,`-1` 表示关闭 | -| `rtsp_mode` | 否 | `0` | RTSP 拉流模式,`0` 为 TCP,`1` 为 UDP | -| `debug_dump_packet` | 否 | 空字符串 | 调试用抓包文件路径,生产环境建议为空 | - -响应示例: - -```json -{ - "error_code": 0, - "desp": "succ", - "data": { - "stream_name": "test110", - "session_id": "RTMPPULL1" - } -} -``` - -注意:返回成功只表示命令已被接受,不保证远端流已经拉取成功。实际状态可通过 `/api/stat/group` 或 HTTP Notify 判断。 - -### `GET /api/ctrl/stop_relay_pull` - -停止指定流的回源拉流。 - -```bash -curl "http://127.0.0.1:8083/api/ctrl/stop_relay_pull?stream_name=test110" -``` - -请求参数: - -| 参数 | 必填 | 说明 | -| --- | --- | --- | -| `stream_name` | 是 | 需要停止回源拉流的流名称 | - -响应示例: - -```json -{ - "error_code": 0, - "desp": "succ", - "data": { - "session_id": "RTMPPULL1" - } -} -``` - -### `POST /api/ctrl/kick_session` - -关闭指定会话。会话可以是推流、拉流或回源拉流。 - -```bash -curl -H "Content-Type: application/json" \ - -X POST \ - -d '{"stream_name":"test110","session_id":"FLVSUB1"}' \ - http://127.0.0.1:8083/api/ctrl/kick_session -``` - -请求参数: - -| 参数 | 必填 | 说明 | -| --- | --- | --- | -| `stream_name` | 是 | 流名称 | -| `session_id` | 是 | 会话唯一标识,可从 `/api/stat/group` 获取 | - -响应示例: - -```json -{ - "error_code": 0, - "desp": "succ" -} -``` - -### `POST /api/ctrl/start_rtp_pub` - -打开 RTP/PS 接收端口。常用于 GB28181 或外部系统向 lal 投递 RTP/PS 流。 - -```bash -curl -H "Content-Type: application/json" \ - -X POST \ - -d '{"stream_name":"test110","port":0,"timeout_ms":60000,"is_tcp_flag":0}' \ - http://127.0.0.1:8083/api/ctrl/start_rtp_pub -``` - -请求参数: - -| 参数 | 必填 | 默认值 | 说明 | -| --- | --- | --- | --- | -| `stream_name` | 是 | 无 | 绑定到 lal 内部的流名称 | -| `port` | 否 | `0` | 接收端口,`0` 表示自动分配 | -| `timeout_ms` | 否 | `60000` | 超时时间,`0` 表示不超时 | -| `is_tcp_flag` | 否 | `0` | `0` 表示 UDP,`1` 表示 TCP | -| `debug_dump_packet` | 否 | 空字符串 | 调试用抓包文件路径,生产环境建议为空 | - -响应示例: - -```json -{ - "error_code": 0, - "desp": "succ", - "data": { - "stream_name": "test110", - "session_id": "PSSUB1", - "port": 20000 - } -} -``` - -## lalmax 兼容 API - -lalmax 在自己的 HTTP 服务上也提供兼容接口,默认地址来自 `lalmax.http_config.http_listen_addr`: +默认地址: ```text http://127.0.0.1:1290/api/stat/group @@ -310,22 +57,41 @@ 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 ``` -lalmax 兼容 API 的请求和响应结构与 lal 原生 API 基本一致,但会在统计结果中补充 lalmax 扩展订阅者信息。控制类接口还可能受 `lalmax.http_config.ctrl_auth_whitelist` 限制。 +## 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` 中已经过聚合增强 -lal 原生 HTTP API 本身不使用 `simple_auth` 中的流鉴权配置。`simple_auth` 主要控制 RTMP、RTSP、HTTP-FLV、HTTP-TS、HLS 等流访问鉴权。 +## Stat API Extension -如果启用流鉴权,请在流地址参数中携带: +`lalmax` 的统计接口会返回两层信息: -```text -lal_secret= -``` +1. `lal` 兼容层 + 也就是原有的 `stream_name`、`app_name`、`pub`、`subs`、`pull`、`in_frame_per_sec` 等字段 +2. `lalmax` 扩展层 + 当前主要是 `lalmax.ext_subs` -如果配置了 `dangerous_lal_secret`,也可以直接传: +其中: -```text -lal_secret= -``` +- `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` 升级后的兼容性 diff --git a/fmp4/http-fmp4/session.go b/fmp4/http-fmp4/session.go index 3be5c39..945fd3a 100644 --- a/fmp4/http-fmp4/session.go +++ b/fmp4/http-fmp4/session.go @@ -171,3 +171,19 @@ func (session *HttpFmp4Session) OnStop() { 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 +} diff --git a/logic/group.go b/logic/group.go index 941dd4c..d2b5825 100644 --- a/logic/group.go +++ b/logic/group.go @@ -40,27 +40,39 @@ type SubscriberInfo struct { // Group 只维护 lalmax 侧订阅者和回放缓存,推流状态仍以 lal 为准。 type Group struct { - uniqueKey string - key StreamKey - consumers sync.Map - hlssvr *hls.HlsServer - manager *ComplexGroupManager - gopCache *GopCache - gopCacheMux sync.RWMutex - lifecycleMux sync.RWMutex - stopOnce sync.Once - msgMux sync.Mutex - hasVideo bool - closed atomic.Bool + 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 } @@ -70,7 +82,10 @@ func (s *subscriberState) AppName() string { } func (s *subscriberState) GetStat() base.StatSession { - return s.StatSession + if s == nil { + return base.StatSession{} + } + return s.refreshStat(0) } func (s *subscriberState) IsAlive() (readAlive bool, writeAlive bool) { @@ -90,6 +105,10 @@ func (s *subscriberState) UniqueKey() string { } func (s *subscriberState) UpdateStat(intervalSec uint32) { + if s == nil { + return + } + s.refreshStat(float64(intervalSec)) } func (s *subscriberState) Url() string { @@ -133,6 +152,28 @@ 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() { @@ -147,6 +188,7 @@ func (group *Group) OnMsg(msg base.RtmpMsg) { 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 { @@ -158,17 +200,42 @@ func (group *Group) OnMsg(msg base.RtmpMsg) { 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() @@ -197,6 +264,14 @@ func (group *Group) OnStop() { 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) + } }) } @@ -222,9 +297,11 @@ func (group *Group) AddSubscriberWithReplay(info SubscriberInfo, subscriber Subs defer group.lifecycleMux.RUnlock() state := &subscriberState{ - key: group.key, - subscriber: subscriber, - replayCache: replayCache, + key: group.key, + subscriber: subscriber, + replayCache: replayCache, + lastStatAt: time.Now(), + statProvider: nil, StatSession: base.StatSession{ SessionId: info.SubscriberID, Protocol: info.Protocol, @@ -233,6 +310,9 @@ func (group *Group) AddSubscriberWithReplay(info SubscriberInfo, subscriber Subs 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 { @@ -389,6 +469,68 @@ func (s *subscriberState) deliverMsg(msg base.RtmpMsg) bool { 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 diff --git a/logic/group_test.go b/logic/group_test.go index 47fddd4..ec8bd9b 100644 --- a/logic/group_test.go +++ b/logic/group_test.go @@ -132,6 +132,27 @@ func (s *selfRemovingSubscriber) markerAt(idx int) byte { 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}, @@ -206,6 +227,22 @@ func newTestGroup(streamName string) *Group { 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") @@ -606,6 +643,32 @@ func TestOnStopIsIdempotentAndClosesSubscribers(t *testing.T) { } } +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") @@ -642,3 +705,66 @@ func TestDuplicateSubscriberIDIsIgnored(t *testing.T) { 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) + } +} diff --git a/logic/stat_aggregator.go b/logic/stat_aggregator.go new file mode 100644 index 0000000..bbc96ce --- /dev/null +++ b/logic/stat_aggregator.go @@ -0,0 +1,120 @@ +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 +} diff --git a/logic/subscriber_stat.go b/logic/subscriber_stat.go new file mode 100644 index 0000000..2668f5c --- /dev/null +++ b/logic/subscriber_stat.go @@ -0,0 +1,13 @@ +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 +} diff --git a/rtc/jessibucasession.go b/rtc/jessibucasession.go index 0e959f5..0410562 100644 --- a/rtc/jessibucasession.go +++ b/rtc/jessibucasession.go @@ -4,6 +4,7 @@ import ( "context" "math" "sync" + "sync/atomic" "github.com/gofrs/uuid" "github.com/pion/webrtc/v3" @@ -32,6 +33,8 @@ type jessibucaSession struct { 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 { @@ -121,6 +124,8 @@ func (conn *jessibucaSession) Run() { 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) @@ -142,6 +147,7 @@ func (conn *jessibucaSession) Run() { nazalog.Warnf(" stream write msg err:%s", err.Error()) return } + conn.wroteBytes.Add(uint64(len(v))) } case <-conn.closeChan: @@ -200,3 +206,37 @@ func (conn *jessibucaSession) Close() { 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 +} diff --git a/rtc/subscriber_stat.go b/rtc/subscriber_stat.go new file mode 100644 index 0000000..e64f4d7 --- /dev/null +++ b/rtc/subscriber_stat.go @@ -0,0 +1,27 @@ +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) +} diff --git a/rtc/whepsession.go b/rtc/whepsession.go index a6e5dbb..29675c7 100644 --- a/rtc/whepsession.go +++ b/rtc/whepsession.go @@ -4,12 +4,14 @@ 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" @@ -27,6 +29,8 @@ type whepSession struct { lalServer logic.ILalServer videoTrack *webrtc.TrackLocalStaticRTP audioTrack *webrtc.TrackLocalStaticRTP + videoSender *webrtc.RTPSender + audioSender *webrtc.RTPSender videopacker *Packer audiopacker *Packer msgChan *chanx.UnboundedChan[base.RtmpMsg] @@ -37,6 +41,8 @@ type whepSession struct { 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 { @@ -70,7 +76,7 @@ func (conn *whepSession) GetAnswerSDP(offer string) (sdp string) { return } - _, err = conn.pc.AddTrack(conn.videoTrack) + conn.videoSender, err = conn.pc.AddTrack(conn.videoTrack) if err != nil { nazalog.Error(err) return @@ -84,7 +90,7 @@ func (conn *whepSession) GetAnswerSDP(offer string) (sdp string) { return } - _, err = conn.pc.AddTrack(conn.videoTrack) + conn.videoSender, err = conn.pc.AddTrack(conn.videoTrack) if err != nil { nazalog.Error(err) return @@ -128,7 +134,7 @@ func (conn *whepSession) GetAnswerSDP(offer string) (sdp string) { } if conn.audioTrack != nil { - _, err = conn.pc.AddTrack(conn.audioTrack) + conn.audioSender, err = conn.pc.AddTrack(conn.audioTrack) if err != nil { nazalog.Error(err) return @@ -226,6 +232,7 @@ connected: func (conn *whepSession) signalConnected() { conn.connectedOnce.Do(func() { + conn.refreshRemoteAddr() conn.connectedChan <- struct{}{} }) } @@ -308,6 +315,7 @@ func (conn *whepSession) sendAudio(msg base.RtmpMsg) { if err := conn.audioTrack.WriteRTP(pkt); err != nil { continue } + conn.recordSentRTP(pkt) } } } @@ -325,6 +333,7 @@ func (conn *whepSession) sendVideo(msg base.RtmpMsg) { if err := conn.videoTrack.WriteRTP(pkt); err != nil { continue } + conn.recordSentRTP(pkt) } } } @@ -373,3 +382,47 @@ func (conn *whepSession) Close() { 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 +} diff --git a/server/hook_builtin_http_plugin.go b/server/hook_builtin_http_plugin.go new file mode 100644 index 0000000..e14b66c --- /dev/null +++ b/server/hook_builtin_http_plugin.go @@ -0,0 +1,92 @@ +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.asyncPost(p.hub.cfg.OnServerStart, event.Payload) + } + case HookEventUpdate: + if p.hub.cfg.OnUpdate != "" { + p.hub.notifyUpdateAsyncPost(p.hub.cfg.OnUpdate, event.Payload) + } + case HookEventGroupStart: + if p.hub.cfg.OnGroupStart != "" { + p.hub.asyncPost(p.hub.cfg.OnGroupStart, event.Payload) + } + case HookEventGroupStop: + if p.hub.cfg.OnGroupStop != "" { + p.hub.asyncPost(p.hub.cfg.OnGroupStop, event.Payload) + } + case HookEventStreamActive: + if p.hub.cfg.OnStreamActive != "" { + p.hub.asyncPost(p.hub.cfg.OnStreamActive, event.Payload) + } + case HookEventPubStart: + if p.hub.cfg.OnPubStart != "" { + p.hub.asyncPost(p.hub.cfg.OnPubStart, event.Payload) + } + case HookEventPubStop: + if p.hub.cfg.OnPubStop != "" { + p.hub.asyncPost(p.hub.cfg.OnPubStop, event.Payload) + } + case HookEventSubStart: + if p.hub.cfg.OnSubStart != "" { + p.hub.asyncPost(p.hub.cfg.OnSubStart, event.Payload) + } + case HookEventSubStop: + if p.hub.cfg.OnSubStop != "" { + p.hub.asyncPost(p.hub.cfg.OnSubStop, event.Payload) + } + case HookEventRelayPullStart: + if p.hub.cfg.OnRelayPullStart != "" { + p.hub.asyncPost(p.hub.cfg.OnRelayPullStart, event.Payload) + } + case HookEventRelayPullStop: + if p.hub.cfg.OnRelayPullStop != "" { + p.hub.asyncPost(p.hub.cfg.OnRelayPullStop, event.Payload) + } + case HookEventRtmpConnect: + if p.hub.cfg.OnRtmpConnect != "" { + p.hub.asyncPost(p.hub.cfg.OnRtmpConnect, event.Payload) + } + case HookEventHlsMakeTs: + if p.hub.cfg.OnHlsMakeTs != "" { + p.hub.asyncPost(p.hub.cfg.OnHlsMakeTs, event.Payload) + } + } + + 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)) + } +} diff --git a/server/hook_filter.go b/server/hook_filter.go new file mode 100644 index 0000000..9763fc5 --- /dev/null +++ b/server/hook_filter.go @@ -0,0 +1,95 @@ +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 != "" +} diff --git a/server/hook_plugin.go b/server/hook_plugin.go new file mode 100644 index 0000000..6063bfe --- /dev/null +++ b/server/hook_plugin.go @@ -0,0 +1,104 @@ +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) +} diff --git a/server/http_notify.go b/server/http_notify.go index 6582e70..71d6936 100644 --- a/server/http_notify.go +++ b/server/http_notify.go @@ -9,7 +9,12 @@ package server import ( + "bytes" + "encoding/json" + "io" "net/http" + "sync" + "sync/atomic" "time" maxlogic "github.com/q191201771/lalmax/logic" @@ -26,6 +31,8 @@ import ( var ( maxTaskLen = 1024 notifyTimeoutSec = 3 + hookHistorySize = 256 + hookSubBufSize = 64 ) var Log = nazalog.GetGlobalLogger() @@ -35,26 +42,75 @@ type PostTask struct { info interface{} } +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" +) + type HttpNotify struct { cfg config.HttpNotifyConfig serverId string + stats *maxlogic.StatAggregator taskQueue chan PostTask notifyUpdateQueue chan PostTask client *http.Client + + 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 } func NewHttpNotify(cfg config.HttpNotifyConfig, serverId string) *HttpNotify { httpNotify := &HttpNotify{ cfg: cfg, serverId: serverId, + stats: maxlogic.NewStatAggregator(maxlogic.GetGroupManagerInstance()), taskQueue: make(chan PostTask, maxTaskLen), notifyUpdateQueue: make(chan PostTask, maxTaskLen), + history: make([]HookEvent, 0, hookHistorySize), + subscribers: make(map[int64]chan HookEvent), + plugins: make(map[string]*hookPluginEntry), client: &http.Client{ Timeout: time.Duration(notifyTimeoutSec) * time.Second, }, } + httpNotify.mustRegisterBuiltinHTTPPlugin() go httpNotify.RunLoop() go httpNotify.NotifyUpdateRunLoop() @@ -67,58 +123,68 @@ func NewHttpNotify(cfg config.HttpNotifyConfig, serverId string) *HttpNotify { func (h *HttpNotify) NotifyServerStart(info base.LalInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnServerStart, info) + h.publish(HookEventServerStart, info) } func (h *HttpNotify) NotifyUpdate(info base.UpdateInfo) { info.ServerId = h.serverId - for i, v := range info.Groups { - exist, session := maxlogic.GetGroupManagerInstance().GetGroup(maxlogic.NewStreamKey(v.AppName, v.StreamName)) - if exist { - info.Groups[i].StatSubs = append(info.Groups[i].StatSubs, session.StatSubscribers()...) - } - } - h.notifyUpdateAsyncPost(h.cfg.OnUpdate, info) + 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.asyncPost(h.cfg.OnPubStart, info) + h.publish(HookEventPubStart, info) } func (h *HttpNotify) NotifyPubStop(info base.PubStopInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnPubStop, info) + h.publish(HookEventPubStop, info) } func (h *HttpNotify) NotifySubStart(info base.SubStartInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnSubStart, info) + h.publish(HookEventSubStart, info) } func (h *HttpNotify) NotifySubStop(info base.SubStopInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnSubStop, info) + h.publish(HookEventSubStop, info) } func (h *HttpNotify) NotifyPullStart(info base.PullStartInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnRelayPullStart, info) + h.publish(HookEventRelayPullStart, info) } func (h *HttpNotify) NotifyPullStop(info base.PullStopInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnRelayPullStop, info) + h.publish(HookEventRelayPullStop, info) } func (h *HttpNotify) NotifyRtmpConnect(info base.RtmpConnectInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnRtmpConnect, info) + h.publish(HookEventRtmpConnect, info) } func (h *HttpNotify) NotifyOnHlsMakeTs(info base.HlsMakeTsInfo) { info.ServerId = h.serverId - h.asyncPost(h.cfg.OnHlsMakeTs, info) + h.publish(HookEventHlsMakeTs, info) } // ----- implement INotifyHandler interface ---------------------------------------------------------------------------- @@ -131,6 +197,18 @@ 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) } @@ -212,7 +290,200 @@ func (h *HttpNotify) asyncPost(url string, info interface{}) { } func (h *HttpNotify) post(url string, info interface{}) { - if _, err := nazahttp.PostJson(url, info, h.client); err != nil { + switch v := info.(type) { + case json.RawMessage: + h.postRaw(url, v) + return + case []byte: + h.postRaw(url, v) + return + } + + resp, err := nazahttp.PostJson(url, info, h.client) + if err != nil { Log.Errorf("http notify post error. err=%+v, url=%s, info=%+v", err, url, info) + return + } + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } +} + +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 + } +} + +func populateHookSessionMeta(event *HookEvent, info base.SessionEventCommonInfo) { + if event == nil { + return + } + + event.sessionID = info.SessionId + event.streamName = info.StreamName + event.appName = info.AppName } diff --git a/server/router.go b/server/router.go index 2210523..ac2ddd0 100644 --- a/server/router.go +++ b/server/router.go @@ -12,6 +12,7 @@ func (s *LalMaxServer) InitRouter(router *gin.Engine) { 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) } diff --git a/server/router_ctrl.go b/server/router_ctrl.go index e45572a..01f109c 100644 --- a/server/router_ctrl.go +++ b/server/router_ctrl.go @@ -11,6 +11,7 @@ import ( 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) diff --git a/server/router_hook.go b/server/router_hook.go new file mode 100644 index 0000000..147acdd --- /dev/null +++ b/server/router_hook.go @@ -0,0 +1,122 @@ +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 +} diff --git a/server/router_stat.go b/server/router_stat.go index cda5640..11cadae 100644 --- a/server/router_stat.go +++ b/server/router_stat.go @@ -16,7 +16,7 @@ func (s *LalMaxServer) initStatRouter(router *gin.Engine, handlers ...gin.Handle } func (s *LalMaxServer) statGroupHandler(c *gin.Context) { - var v base.ApiStatGroupResp + var v ApiStatGroupResp streamName := c.Query("stream_name") if streamName == "" { v.ErrorCode = base.ErrorCodeParamMissing @@ -25,34 +25,25 @@ func (s *LalMaxServer) statGroupHandler(c *gin.Context) { return } appName := c.Query("app_name") - v.Data = s.lalsvr.StatGroup(streamName) - if v.Data == nil { + 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 } - exist, session := maxlogic.GetGroupManagerInstance().GetGroup(maxlogic.NewStreamKey(appName, streamName)) - if exist { - v.Data.StatSubs = append(v.Data.StatSubs, session.StatSubscribers()...) - } + 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 base.ApiStatAllGroupResp + var out ApiStatAllGroupResp out.ErrorCode = base.ErrorCodeSucc out.Desp = base.DespSucc - groups := s.lalsvr.StatAllGroup() - for i, group := range groups { - exist, session := maxlogic.GetGroupManagerInstance().GetGroup(maxlogic.NewStreamKey(group.AppName, group.StreamName)) - if exist { - groups[i].StatSubs = append(groups[i].StatSubs, session.StatSubscribers()...) - } - } - out.Data.Groups = groups + out.Data.Groups = newLalmaxStatGroups(s.stats.BuildGroupsView(s.lalsvr.StatAllGroup())) c.JSON(http.StatusOK, out) } diff --git a/server/router_test.go b/server/router_test.go index 4df705e..504b737 100644 --- a/server/router_test.go +++ b/server/router_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "sync/atomic" "testing" "time" @@ -15,12 +16,48 @@ import ( 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 +} + +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 const httpNotifyAddr = ":55559" +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{ @@ -45,7 +82,8 @@ func TestMain(m *testing.M) { } func TestAllGroup(t *testing.T) { - _, err := max.lalsvr.AddCustomizePubSession("test") + streamName := "test_all_group" + _, err := max.lalsvr.AddCustomizePubSession(streamName) if err != nil { t.Fatal(err) } @@ -57,20 +95,24 @@ func TestAllGroup(t *testing.T) { if resp.StatusCode != 200 { t.Fatal(resp.Status) } - var out base.ApiStatAllGroupResp + var out ApiStatAllGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } - if len(out.Data.Groups) <= 0 { + group := findTestGroup(out.Data.Groups, streamName) + if group == nil { t.Fatal("no group") } - if len(out.Data.Groups[0].StatSubs) != 0 { + 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("test", "test", max.hlssvr, 1, 0) + ss, _ := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(streamName, streamName, max.hlssvr, 1, 0) ss.AddConsumer("consumer1", nil) r := httptest.NewRecorder() @@ -80,20 +122,26 @@ func TestAllGroup(t *testing.T) { if resp.StatusCode != 200 { t.Fatal(resp.Status) } - var out base.ApiStatAllGroupResp + var out ApiStatAllGroupResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } - if len(out.Data.Groups) <= 0 { + group := findTestGroup(out.Data.Groups, streamName) + if group == nil { t.Fatal("no group") } - if len(out.Data.Groups[0].StatSubs) <= 0 { + if len(group.StatSubs) <= 0 { t.Fatal("subs err") } - group := out.Data.Groups[0] + 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") + } }) } @@ -109,11 +157,11 @@ func TestNotifyUpdate(t *testing.T) { ss.AddConsumer(consumerID, nil) http.HandleFunc("/on_update", func(w http.ResponseWriter, r *http.Request) { - var out base.ApiStatAllGroupResp + var out base.UpdateInfo if err := json.NewDecoder(r.Body).Decode(&out); err != nil { t.Fatal(err) } - for _, group := range out.Data.Groups { + for _, group := range out.Groups { for _, sub := range group.StatSubs { if sub.SessionId == consumerID { return @@ -167,6 +215,486 @@ func TestRtpPubStartStop(t *testing.T) { } } +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 := "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 := "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") + _, ch, cancel := hub.Subscribe(1) + 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(1) + if len(events) != 1 { + t.Fatalf("unexpected recent len: %d", len(events)) + } + if events[0].Event != HookEventPubStart { + t.Fatalf("unexpected recent event: %+v", events[0]) + } +} + +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 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() + req := httptest.NewRequest("GET", "/api/hook/recent?limit=1", 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("unexpected event count: %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) { diff --git a/server/server.go b/server/server.go index 466c8ec..3526223 100644 --- a/server/server.go +++ b/server/server.go @@ -3,7 +3,9 @@ package server import ( "context" "crypto/tls" + "fmt" "net/http" + "time" "github.com/q191201771/lalmax/srt" @@ -20,6 +22,7 @@ import ( 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" ) @@ -27,6 +30,8 @@ import ( type LalMaxServer struct { lalsvr logic.ILalServer conf *config.Config + stats *maxlogic.StatAggregator + notifyHub *HttpNotify srtsvr *srt.SrtServer rtcsvr *rtc.RtcServer router *gin.Engine @@ -37,18 +42,21 @@ type LalMaxServer struct { } 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 = NewHttpNotify(conf.HttpNotifyConfig, conf.ServerId) + option.NotifyHandler = notifyHub }) maxsvr := &LalMaxServer{ lalsvr: lalsvr, conf: conf, + stats: maxlogic.NewStatAggregator(maxlogic.GetGroupManagerInstance()), + notifyHub: notifyHub, rtpPubMgr: rtppub.NewManager(lalsvr, conf.GB28181Config.MediaConfig), } @@ -88,7 +96,32 @@ func NewLalMaxServer(conf *config.Config) (*LalMaxServer, error) { func (s *LalMaxServer) Run() (err error) { s.lalsvr.WithOnHookSession(func(uniqueKey string, streamName string) logic.ICustomizeHookSessionContext { - group, _ := maxlogic.GetGroupManagerInstance().GetOrCreateGroupByStreamName(uniqueKey, streamName, s.hlssvr, s.conf.LogicConfig.GopCacheNum, s.conf.LogicConfig.SingleGopMaxFrameNum) + 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 }) @@ -99,6 +132,8 @@ func (s *LalMaxServer) Run() (err error) { go s.srtsvr.Run(ctx) } + go s.runPeriodicUpdate(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 { @@ -118,3 +153,39 @@ func (s *LalMaxServer) Run() (err error) { 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(), + }) + } + } +} + +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) +} diff --git a/server/stat_view.go b/server/stat_view.go new file mode 100644 index 0000000..f6a4168 --- /dev/null +++ b/server/stat_view.go @@ -0,0 +1,67 @@ +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 +} diff --git a/srt/sub.go b/srt/sub.go index 311fea4..7719860 100644 --- a/srt/sub.go +++ b/srt/sub.go @@ -156,3 +156,21 @@ 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 +}