nimeet 文档中心
English

旁路推流(Legacy FLS)

RTMP 推流与 HLS 播放、entry point、idleTimeout、心跳与回收、回调,以及迁移到 Media Push 的路径。

版本 v1.1.0 · 更新于 2026-10-26 · 修订 1

旁路推流(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_BASErtmp://example.com/live
HLS_PLAY_BASEhttps://example.com/live
RTMP_REGIONcn-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)
entryPointlive1–64,[A-Za-z0-9_-];默认 entry point live 恒存在
streamName<channelName>_<随机>1–128,[A-Za-z0-9_-];会作为地址最后一段
rtmpUrl自动生成自定义推流地址,必须以 rtmp:// 或 rtmps:// 开头,否则 400 invalid_parameter
hlstrue是否产出 HLS
idleTimeoutSeconds300观众无活动多久后自动回收;0 表示关闭
maxDurationSeconds无最长推流时长保护(10–86400)
idempotencyKey无幂等键

前置条件:项目需开通 streaming(否则 403 service_not_enabled);并发配额 quotas.streamingConcurrent(默认 5)已满返回 429 streaming_quota_exceeded。

任务字段

字段说明
id / channelName / entryPoint / streamName身份与命名
rtmpUrl / hlsUrl推流与播放地址
statestarting → 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():

  1. 取所有 starting / running 的任务;
  2. 若 idleTimeoutSeconds > 0 且 now - lastActiveAt ≥ idleTimeout → 停止任务;
  3. 停止后把状态置为 expired,并写入原因。

“活动”由三件事刷新:创建、显式心跳、以及状态同步。观众端应周期性调用心跳(例如每 30–60 秒),否则长静默的直播会被回收。心跳响应返回任务当前快照(含更新后的 lastActiveAt)。

状态与回调

每次状态变化产生事件码 202 streaming_state_changed:

json
{
  "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 的新建/删除与保护规则:

javascript
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 版本:

bash
#!/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,streamName 1–128,都只允许 [A-Za-z0-9_-];地址中的 ${entryPoint}/${streamName} 不做 URL 转义,请避免特殊字符。
  • live entry point 不可删除;其它 entry point 删除后不可恢复。
  • HLS 采用 4 秒分片(Egress 侧 segmentDurationSeconds: 4),播放端会有数秒级延迟;要低延迟互动请使用 RTC 订阅而不是 HLS。
  • 推流是单向的:观众侧没有 RTC 能力(不能发言、不能上报质量),质量数据只来自 RTC 参会者。
  • 当前实现按 streamName 停止 FLS 任务时会在项目内查找,同名 streamName 请保持唯一(默认生成的 <channelName>_<随机> 已保证)。

下一步

本页由文档构建流水线生成 · 2026-10-31 01:24:02 UTC · 文档中心 v1.1.0.

内容依据公开 API 定义.

ni-meet 是自建 RTC PaaS,媒体面基于 LiveKit(Apache-2.0),并与声网 API 兼容。