Browse Source

docs: add live play-url session cache design

Coalesce in-flight play-url by session, reuse until 30s before
expiry, and align Monitor + Media open paths.
main
xiaosi 2 weeks ago
parent
commit
31e9c0ff8e
  1. 149
      docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md

149
docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md

@ -0,0 +1,149 @@
# 直播 play-url 按 session 缓存与合并
日期:2026-09-03
状态:已确认(待写实现计划)
范围:仅前端 `laic-frontend`(Monitor + Media)
## 1. 背景
开播进入 `streaming` 后,`openLive`、phase poll、heartbeat 都可能触发 `GET /v1/live/{dockId}/play-url`。现状:
| 位置 | 已有能力 | 缺口 |
|------|----------|------|
| `useMonitorLive` | `live.opening` 防抖;`playURLRequest` 全局 in-flight 合并 | **不按 sessionId**;**不复用未过期** playUrl;多入口仍可能在短窗口内各打一次 |
| `MediaView` | open 后单次 `getLivePlayURL`;`openSeq` 丢弃过期回写 | **无 opening 锁**;**无 in-flight 合并**;**无 TTL 缓存** |
| `LivePlayer` | 启动期 404 同 URL 退避,不上冒刷 play-url | 已满足,本轮仅回归 |
后端建议前端按 session 做 `ensurePlayUrl`:合并同 session 的 in-flight,并在地址仍有效时直接复用。
## 2. 目标
1. 同一 `streamSessionId``/play-url` **in-flight coalesce**(并发只打一枪)。
2. 当前 session 已有 `playUrl`,且 `expiresAt * 1000 - Date.now() > 30_000`**直接复用**,不重打。
3. Monitor 的 `openLive`(streaming)、`pollPhaseOnce`、heartbeat(需补地址时)、临期续期、有限错误重建,**统一**走 `ensurePlayUrl`
4. Media 同样按 session 缓存/合并;`openLive` 增加 **opening 连点忽略**锁。
5. 仅 `force`、临期续期、或明确需要强制刷新的路径才重新 GET。
6. 请求完成后再次确认当前 session 未变再写入;页面关闭/切换清空 promise 与缓存。
## 3. 非目标
- 不改后端 `/play-url` 契约与鉴权。
- 不抽 Monitor/Media 公共模块(两页各自实现同构逻辑)。
- 不改 `LivePlayer` 启动 404 同 URL 策略、stop phase gate、`57006` rejoin、TLS/403 致命不刷 URL。
- 不改设备状态轮询等无关模块。
## 4. 方案
### 4.1 核心语义 `ensurePlayUrl(sessionId, { force = false })`
伪代码(两页各自落地,字段名可本地化):
```js
let playUrlPromise = null
let playUrlSessionId = ''
async function ensurePlayUrl(sessionId, { force = false } = {}) {
if (
!force &&
activeSessionId === sessionId &&
playUrl &&
playUrlExpiresAt * 1000 - Date.now() > 30_000
) {
return { playUrl, expiresAt: playUrlExpiresAt } // 或直接 true / 沿用现有写入形态
}
if (
!force &&
activeSessionId === sessionId &&
playUrl &&
playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS
) {
return { playUrl, expiresAt: playUrlExpiresAt }
}
if (playUrlPromise && playUrlSessionId === sessionId) {
return playUrlPromise
}
playUrlSessionId = sessionId
playUrlPromise = getLivePlayURL(dockId, sessionId)
.then((play) => {
if (activeSessionId !== sessionId) return play // 调用方仍须再校验后再写入本地状态
return play
})
.finally(() => {
if (playUrlSessionId === sessionId) playUrlPromise = null
})
return playUrlPromise
常量:`PLAY_URL_FRESH_MS = 30_000`(与现有临期续期 lead 对齐)。**写入** `playUrl` / `expiresAt` / `schedulePlayRefresh` 由调用方在 ensure resolve 且 session 仍匹配后执行(Monitor 可把写入收进 ensure 内部,但必须含二次 session 校验)。
```
常量:`PLAY_URL_FRESH_MS = 30_000`(与现有临期续期 lead 对齐)。
### 4.2 Monitor:`useMonitorLive.js`
1. 将现有 `refreshPlayURL` / `playURLRequest` **升级**为按 session 的 ensure 语义:
- 默认 `force=false`(复用 + coalesce)
- `fromRetry`、临期 `schedulePlayRefresh`、显式需要换址 → `force=true`
2. 调用点统一:
- `openLive``phase === 'streaming'`
- `pollPhaseOnce` 进入 streaming 后取址
- heartbeat:仅当 `phase === 'streaming' && !live.playUrl`(现状),内部走 ensure(通常命中缓存或 in-flight)
- `schedulePlayRefresh` 到期 → force
- `onPlayError` 有限次重建 → force
3. 保留 `live.opening` 锁(已有)。
4. `clearPlaybackOnly` / `resetLocal` / dispose:清空 `playUrlPromise`、`playUrlSessionId`,并清 `playUrl` / `expiresAt`(与现有一致)。
5. ensure 返回后若 `live.session.id !== sessionId` 或 disposed/stopping → **不写入**
可选:对外仍导出/保留函数名 `refreshPlayURL({ force, fromRetry })`,内部转 ensure,减少调用点改名噪音。
### 4.3 Media:`MediaView.vue`
1. 增加 `opening`(或复用 busy 标志):`openLive` 入口若已 opening → **直接 return**;finally 解锁。
2. 增加与 Monitor 同构的 `playUrlPromise` / `playUrlSessionId` / `playUrlExpiresAt`(若尚未存 expiresAt,从 play-url 响应写入)。
3. `openLive` 取址改为 `ensurePlayUrl(sessionId)`;若后续 heartbeat 在无 playUrl 时补址,同样走 ensure。
4. `closeActiveLive` / tab 切离 live / `pagehide`:清空 promise、session 键、playUrl、expiresAt(bump `openSeq` 逻辑保留)。
5. 回写前校验 `openSeq` + `activeLive.sessionId === sessionId`
### 4.4 与 LivePlayer 的边界
- 启动期 m3u8 404:仍由 `LivePlayer` 同 URL 窗口消化,`recoverable` 不上冒 → **不**触发 ensure/force。
- TLS/403:Monitor 仍 `markPlayFatal`,禁止刷 play-url。
- 仅非 HlsError 的有限重试 / 临期 / 显式 force 才重新 GET。
## 5. 错误与并发
| 场景 | 行为 |
|------|------|
| 同 session 并发 ensure | 共享同一 promise |
| 不同 session | 不复用旧 promise;旧回写被 session 校验丢弃 |
| TTL 内非 force | 返回缓存,零 HTTP |
| force / 临期 | 新 GET;可与旧 in-flight 并存时以新 session 键为准,旧结果丢弃 |
| ensure 失败 | 抛给现有 onPlayError / Media toast 路径;不清掉「仍有效」的旧地址,除非调用方 close |
| 401 | 现有 http 拦截器 |
## 6. 验收
1. Monitor 开播至出画:同一 session 短时间内 `/play-url` 不应被 open + phase + heartbeat **打成多份并发**;未过期无故不重拉。
2. Media 开播同样;连点 open 不会并行多个 join。
3. 启动期 HLS 404 仍只同 URL 重载,Network 无因此刷出的 `/play-url`
4. 临期(剩余 ≤30s)或 force 重建才会重新 GET;完成后 session 已变则不写入。
5. 离开监控直播 / Media 直播 tab / 关页后,无过期 in-flight 回写到新会话。
6. stop gate、`57006`、TLS/403 行为与改前一致。
7. `npm run build` 通过。
## 7. 风险
- `30s` 新鲜阈值与临期续期 lead 相同;若后端 `expiresAt` 很短,force 会更频繁——可接受。
- Monitor / Media 两份同构逻辑可能漂移;本轮不抽公共模块,实现计划里用同一段伪代码约束。
## 8. 实现落点(预告)
| 文件 | 变更 |
|------|------|
| `src/composables/useMonitorLive.js` | ensure 语义升级 `refreshPlayURL`;统一调用点;清理 promise |
| `src/views/MediaView/MediaView.vue` | opening 锁 + ensurePlayUrl + expiresAt + 清理 |
| `src/components/LivePlayer.vue` | 不改;回归启动 404 |
完成后:实现计划 → 编码 → 构建部署 → Network 人工看开播 `/play-url` 次数。
Loading…
Cancel
Save