diff --git a/docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md b/docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md new file mode 100644 index 0000000..92e879f --- /dev/null +++ b/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` 次数。