# 直播 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 const PLAY_URL_FRESH_MS = 30_000 let playUrlPromise = null let playUrlSessionId = '' async function ensurePlayUrl(sessionId, { force = false } = {}) { 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) => { // 回写前调用方仍须再校验 session;此处仅作提示性守卫 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 校验)。 ### 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` 次数。