旁路推流(Legacy FLS)
RTMP 推流与 HLS 播放、entry point、idleTimeout、心跳与回收、回调,以及迁移到 Media Push 的路径。
旁路推流(bypass / media push)把频道内容推成标准的 RTMP 流,并同时产出 HLS 播放列表,让没有 RTC SDK 的观众用普通播放器观看。与云端录制一样,它也是任务形资源:创建 → 排队 → 运行 → 停止/回收,全程有状态与回调。
产物与地址
| 产物 | 形态 | 说明 |
|---|---|---|
| RTMP 推流地址 | rtmp(s)://<RTMP_PUSH_BASE>/<entryPoint>/<streamName> | 推给第三方直播平台(OBS、云直播等) |
| HLS 播放地址 | https://<HLS_PLAY_BASE>/<entryPoint>/<streamName>.m3u8 | 任意 HLS 播放器可播;hls: false 时不产出 |
默认值来自环境变量:
| 变量 | 默认 |
|---|---|
RTMP_PUSH_BASE | rtmp://example.com/live |
HLS_PLAY_BASE | https://example.com/live |
RTMP_REGION | cn-shanghai |
平台接口
| 方法 | 路径 | 说明 |
|---|---|---|
POST | /v1/streams | 创建推流任务,202 |
GET | /v1/streams?channelName=&state=&limit= | 列出任务(响应键为 streams) |
GET | /v1/streams/{id} | 查询单任务 |
POST | /v1/streams/{id}/stop | 停止 |
POST | /v1/streams/{id}/heartbeat | 观众心跳:刷新 lastActiveAt,避免 idle 回收 |
创建请求体:
| 字段 | 默认 | 说明 |
|---|---|---|
channelName | 必填 | 频道必须存在且未销毁(否则 404 channel_not_found) |
entryPoint | live | 1–64,[A-Za-z0-9_-];默认 entry point live 恒存在 |
streamName | <channelName>_<随机> | 1–128,[A-Za-z0-9_-];会作为地址最后一段 |
rtmpUrl | 自动生成 | 自定义推流地址,必须以 rtmp:// 或 rtmps:// 开头,否则 400 invalid_parameter |
hls | true | 是否产出 HLS |
idleTimeoutSeconds | 300 | 观众无活动多久后自动回收;0 表示关闭 |
maxDurationSeconds | 无 | 最长推流时长保护(10–86400) |
idempotencyKey | 无 | 幂等键 |
前置条件:项目需开通 streaming(否则 403 service_not_enabled);并发配额 quotas.streamingConcurrent(默认 5)已满返回 429 streaming_quota_exceeded。
任务字段
| 字段 | 说明 |
|---|---|
id / channelName / entryPoint / streamName | 身份与命名 |
rtmpUrl / hlsUrl | 推流与播放地址 |
state | starting → running → stopping → stopped / failed / expired |
idleTimeoutSeconds | 空闲回收阈值 |
lastActiveAt | 最近一次心跳/创建时间 |
startedAt / endedAt / error | 生命周期时间与错误 |
expired 是推流特有的终态:由后台 tick 的空闲扫描触发,error 会写明 idle timeout after <N>s without viewer activity。
声网 FLS 兼容入口
| 方法 | 路径 | 说明 |
|---|---|---|
POST | /v1/projects/{appid}/fls/entry_points?id={name} | 创建 entry point,返回全部列表 |
GET | /v1/projects/{appid}/fls/entry_points | 列出(live 恒存在) |
DELETE | /v1/projects/{appid}/fls/entry_points/{entryPoint} | 删除;删除 live 会被拒绝(400) |
PUT | /v1/projects/{appid}/fls/entry_points/{entryPoint}/admin/record/standard/regions/{region}/tasks?id={streamName}&cname={channel} | 启动推流任务,202 |
DELETE | /v1/projects/{appid}/fls/entry_points/{entryPoint}/admin/record/standard/regions/{region}/tasks/{streamName} | 停止该 streamName 的任务 |
说明:
region段只作为响应回显(当前单区域部署),不改变行为;id(streamName)与cname(频道名)可以放在 query 或 JSON body 里;- 声网的「standard recording」与我们的「推流任务」是同一件事:把频道内容推成标准流;
- 认证:声网 FLS 使用 customer key/secret 派生的签名;这里统一用项目 Basic 凭证。
空闲回收与心跳
后台引擎每 EGRESS_POLL_INTERVAL_MS(默认 1000ms)tick 一次,其中包含 sweepIdle():
- 取所有
starting/running的任务; - 若
idleTimeoutSeconds > 0且now - lastActiveAt ≥ idleTimeout→ 停止任务; - 停止后把状态置为
expired,并写入原因。
“活动”由三件事刷新:创建、显式心跳、以及状态同步。观众端应周期性调用心跳(例如每 30–60 秒),否则长静默的直播会被回收。心跳响应返回任务当前快照(含更新后的 lastActiveAt)。
状态与回调
每次状态变化产生事件码 202 streaming_state_changed:
{
"eventType": 202,
"productId": 1,
"payload": {
"taskId": "stm_1a2b…",
"channelName": "weekly-sync",
"entryPoint": "live",
"streamName": "weekly-sync_9f2c",
"rtmpUrl": "rtmp://example.com/live/weekly-sync_9f2c",
"hlsUrl": "https://example.com/live/weekly-sync_9f2c.m3u8",
"state": "running",
"ts": 1793000000
}
}状态与回调都遵循「先落库 → 进 Outbox → 投递」的一致性路径,投递失败按 回调事件 的重试计划处理。
端到端示例
创建 → tick → 查询 → 心跳 → 停止,以及 entry point 的新建/删除与保护规则:
import { seedChannel, tick, uniqueChannel } from './_lib.mjs';
export const meta = {
id: '14-streaming-lifecycle',
title: '旁路推流:RTMP/HLS 任务与 entry point',
safe: false, // 会创建/停止推流任务
needsApi: true,
};
/**
* 平台接口:POST /v1/streams → tick → GET → heartbeat → stop
* 声网 FLS 兼容:GET/POST/DELETE /v1/projects/{appid}/fls/entry_points
*
* 推流任务默认 idleTimeoutSeconds=300;心跳刷新 lastActiveAt,超时后由后台 sweep 置为 expired。
* 默认 rtpush 域名来自 RTMP_PUSH_BASE / HLS_PLAY_BASE(见 控制面)。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channelName = uniqueChannel('docs-push');
await seedChannel(api, appId, channelName, 93001);
const created = await api('POST', '/v1/streams', {
body: { channelName, entryPoint: 'live', hls: true, idleTimeoutSeconds: 120 },
});
ctx.assert(created.status === 202, `创建推流任务应返回 202,实际 ${created.status}`);
const task = created.json;
ctx.assert(/^rtmp:\/\/.+\/live\/.+/.test(task.rtmpUrl), `rtmpUrl 形状不符: ${task.rtmpUrl}`);
ctx.assert(/^https:\/\/.+\/live\/.+\.m3u8$/.test(task.hlsUrl), `hlsUrl 形状不符: ${task.hlsUrl}`);
ctx.assert(task.rtmpUrl.endsWith(task.streamName), 'rtmpUrl 应以 streamName 结尾');
ctx.assert(task.idleTimeoutSeconds === 120, 'idleTimeoutSeconds 应被回显');
await tick(api);
const afterTick = await api('GET', `/v1/streams/${task.id}`);
ctx.assert(['starting', 'running'].includes(afterTick.json.state), `tick 后状态应为 starting/running,实际 ${afterTick.json.state}`);
const heartbeat = await api('POST', `/v1/streams/${task.id}/heartbeat`);
ctx.assert(heartbeat.json.lastActiveAt, '心跳应刷新 lastActiveAt');
const list = await api('GET', '/v1/streams', { query: { channelName } });
ctx.assert((list.json.streams ?? []).some((entry) => entry.id === task.id), '任务应出现在列表中');
const stopped = await api('POST', `/v1/streams/${task.id}/stop`);
ctx.assert(stopped.json.state === 'stopped', `stop 后应为 stopped,实际 ${stopped.json.state}`);
// 非法 rtmpUrl 必须在创建前被拒绝
const invalid = await ctx.client('POST', '/v1/streams', { body: { channelName, rtmpUrl: 'http://not-rtmp.example.com/x' } });
ctx.assert(invalid.status === 400 && invalid.json.code === 'invalid_parameter', '非 rtmp(s):// 地址应 400');
// ------------------------------------------------------------ entry points
const before = await api('GET', `/v1/projects/${appId}/fls/entry_points`);
ctx.assert((before.json.entryPoints ?? []).includes('live'), '默认 entry point "live" 必须存在');
const entryPoint = `docs${Date.now().toString(36).slice(-4)}`;
const createdPoint = await api('POST', `/v1/projects/${appId}/fls/entry_points`, { query: { id: entryPoint } });
ctx.assert((createdPoint.json.entryPoints ?? []).includes(entryPoint), '新建 entry point 应出现在列表中');
const deletedPoint = await api('DELETE', `/v1/projects/${appId}/fls/entry_points/${entryPoint}`);
ctx.assert(deletedPoint.json.deleted === true, 'entry point 应被删除');
const deleteDefault = await ctx.client('DELETE', `/v1/projects/${appId}/fls/entry_points/live`);
ctx.assert(deleteDefault.status === 400, '默认 entry point "live" 不允许删除');
return {
summary:
`${channelName}: streamName=${task.streamName}(${task.idleTimeoutSeconds}s idle);tick 后 ${afterTick.json.state} → ${stopped.json.state};` +
`entry point 新建/删除成功,live 受保护`,
details: {
rtmpUrl: task.rtmpUrl,
hlsUrl: task.hlsUrl,
heartbeatAt: heartbeat.json.lastActiveAt,
entryPointsBefore: before.json.entryPoints,
},
};
}curl 版本:
#!/usr/bin/env bash
# 旁路推流:创建 RTMP/HLS 任务、心跳续命、停止;以及 entry point 管理
set -euo pipefail
: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
: "${CNAME:=weekly-sync}"
CRED_FILE=$([ -f 控制面 class="tok-flag">-credentials.json ] && echo 控制面 class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")
APP_ID=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).appId)" "$CRED_FILE")
# 1) 创建推流任务(hls=true 同时产出播放用 m3u8)
CREATE=$(curl -s -X POST "$NIMEET_API_BASE/v1/streams" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"channelName\":\"$CNAME\",\"entryPoint\":\"live\",\"hls\":true,\"idleTimeoutSeconds\":300}")
echo "$CREATE"
TASK_ID=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).id)" "$CREATE")
# 2) 观众心跳:刷新 lastActiveAt,避免 idleTimeout 回收
curl -s -X POST "$NIMEET_API_BASE/v1/streams/$TASK_ID/heartbeat" \
-H "Authorization: Basic $CRED"
# 3) 查询与停止
curl -s "$NIMEET_API_BASE/v1/streams/$TASK_ID" -H "Authorization: Basic $CRED"
curl -s -X POST "$NIMEET_API_BASE/v1/streams/$TASK_ID/stop" -H "Authorization: Basic $CRED"
# 4) entry point(默认 live 恒存在且不可删除)
curl -s "$NIMEET_API_BASE/v1/projects/$APP_ID/fls/entry_points" -H "Authorization: Basic $CRED"
curl -s -X POST "$NIMEET_API_BASE/v1/projects/$APP_ID/fls/entry_points?id=docs-demo" -H "Authorization: Basic $CRED"
curl -s -X DELETE "$NIMEET_API_BASE/v1/projects/$APP_ID/fls/entry_points/docs-demo" -H "Authorization: Basic $CRED"限制与注意事项
entryPoint名称 1–64,streamName1–128,都只允许[A-Za-z0-9_-];地址中的${entryPoint}/${streamName}不做 URL 转义,请避免特殊字符。liveentry point 不可删除;其它 entry point 删除后不可恢复。- HLS 采用 4 秒分片(Egress 侧
segmentDurationSeconds: 4),播放端会有数秒级延迟;要低延迟互动请使用 RTC 订阅而不是 HLS。 - 推流是单向的:观众侧没有 RTC 能力(不能发言、不能上报质量),质量数据只来自 RTC 参会者。
- 当前实现按 streamName 停止 FLS 任务时会在项目内查找,同名 streamName 请保持唯一(默认生成的
<channelName>_<随机>已保证)。
下一步
- REST API 参考:完整端点、参数与 curl。
- 错误码:失败时按
code/ HTTP 状态定位。 - 常见问题:配额、限制与排障。