From 8a0d4c08bc26f71945bd1359564eb89372057c72 Mon Sep 17 00:00:00 2001 From: xiaosi <2652281683@qq.com> Date: Thu, 3 Sep 2026 15:54:09 +0800 Subject: [PATCH] docs: add live play-url session cache implementation plan Upgrade Monitor refreshPlayURL to session coalesce + TTL reuse, and add Media opening lock with the same ensure pattern. --- .../2026-09-03-live-playurl-session-cache.md | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-live-playurl-session-cache.md diff --git a/docs/superpowers/plans/2026-09-03-live-playurl-session-cache.md b/docs/superpowers/plans/2026-09-03-live-playurl-session-cache.md new file mode 100644 index 0000000..50f88d2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-live-playurl-session-cache.md @@ -0,0 +1,454 @@ +# Live play-url Session Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Coalesce in-flight `/play-url` by `streamSessionId`, reuse addresses until 30s before expiry, and stop duplicate fetches from open/phase/heartbeat on Monitor and Media. + +**Architecture:** Keep page-local logic (no shared helper). Upgrade `useMonitorLive.refreshPlayURL` to session-scoped ensure semantics (`force` for retry/renewal). Add Media `opening` lock plus the same ensure pattern with `playUrlExpiresAt`. LivePlayer startup-404 same-URL behavior stays unchanged. + +**Tech Stack:** Vue 3, existing `@/api/live` `getLivePlayURL` + +**Spec:** `docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md` + +--- + +### Task 1: Monitor ensurePlayUrl via refreshPlayURL upgrade + +**Files:** +- Modify: `src/composables/useMonitorLive.js` + +- [ ] **Step 1: Add fresh-window alias and session-scoped promise fields** + +Near existing constants, keep `PLAY_REFRESH_LEAD_MS = 30_000` and add: + +```js +const PLAY_URL_FRESH_MS = PLAY_REFRESH_LEAD_MS +``` + +Replace: + +```js + let playURLRequest = null +``` + +with: + +```js + let playURLRequest = null + let playURLRequestSessionId = '' +``` + +In `clearPlaybackOnly` and `resetLocal`, clear both: + +```js + playURLRequest = null + playURLRequestSessionId = '' +``` + +(Keep existing clears of `live.playUrl` / `live.playUrlExpiresAt`.) + +- [ ] **Step 2: Rewrite `refreshPlayURLOnce` + `refreshPlayURL` to ensure semantics** + +Replace the current `refreshPlayURLOnce` / `refreshPlayURL` pair with: + +```js + async function refreshPlayURLOnce({ fromRetry = false, force = false } = {}) { + if (disposed || live.stopping || !live.dockId || !live.session?.id) return false + const dockId = live.dockId + const sessionId = live.session.id + const forceFetch = force || fromRetry + + if ( + !forceFetch && + live.playUrl && + live.playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS + ) { + return true + } + + const play = await getLivePlayURL(dockId, sessionId) + if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return false + live.playUrl = play.playUrl || '' + const nowSec = Math.floor(Date.now() / 1000) + live.playUrlExpiresAt = Number(play.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC + if (!fromRetry) live.playRetryCount = 0 + schedulePlayRefresh() + return true + } + + function refreshPlayURL(options = {}) { + const sessionId = live.session?.id + if (!sessionId) return Promise.resolve(false) + + const forceFetch = Boolean(options.force || options.fromRetry) + if ( + !forceFetch && + live.playUrl && + live.playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS + ) { + return Promise.resolve(true) + } + + if (playURLRequest && playURLRequestSessionId === sessionId) { + return playURLRequest + } + + playURLRequestSessionId = sessionId + playURLRequest = refreshPlayURLOnce(options).finally(() => { + if (playURLRequestSessionId === sessionId) { + playURLRequest = null + playURLRequestSessionId = '' + } + }) + return playURLRequest + } +``` + +- [ ] **Step 3: Force renewals on schedule + error rebuild; leave cold paths default** + +In `schedulePlayRefresh` timer callback, change: + +```js + await refreshPlayURL() +``` + +to: + +```js + await refreshPlayURL({ force: true }) +``` + +Keep these as default (non-force) so they coalesce/reuse: + +- `openLive` streaming branch: `await refreshPlayURL()` +- `pollPhaseOnce` streaming branch: `await refreshPlayURL()` +- heartbeat when `streaming && !live.playUrl`: `await refreshPlayURL()` + +Keep error rebuild as: + +```js + await refreshPlayURL({ fromRetry: true }) +``` + +(`fromRetry` already implies force in Step 2.) + +Do **not** change `live.opening` guards, stop gate, 57006, or TLS/403 paths. + +- [ ] **Step 4: Static check** + +```bash +rg -n "PLAY_URL_FRESH_MS|playURLRequestSessionId|force: true|fromRetry|refreshPlayURL" src/composables/useMonitorLive.js +``` + +Expected: + +- `PLAY_URL_FRESH_MS` defined +- coalesce keyed by `playURLRequestSessionId` +- TTL short-circuit in `refreshPlayURL` +- `schedulePlayRefresh` uses `{ force: true }` +- `onPlayError` still `{ fromRetry: true }` +- open/phase/heartbeat cold paths still `refreshPlayURL()` without force + +- [ ] **Step 5: Commit** + +```bash +git add src/composables/useMonitorLive.js +git commit -m "feat: coalesce monitor play-url by session with TTL reuse" +``` + +--- + +### Task 2: MediaView opening lock + ensurePlayUrl + +**Files:** +- Modify: `src/views/MediaView/MediaView.vue` + +- [ ] **Step 1: Extend activeLive + module locals** + +In `activeLive` reactive, add: + +```js + playUrlExpiresAt: 0, +``` + +Near other module lets (`heartbeatTimer`, `openSeq`, …), add: + +```js +const PLAY_URL_FRESH_MS = 30_000 +const PLAY_EXPIRE_FALLBACK_SEC = 270 +let playUrlPromise = null +let playUrlSessionId = '' +let opening = false +``` + +Update `resetActiveLive` to also clear cache keys and expiry: + +```js +function resetActiveLive() { + clearHeartbeat() + playUrlPromise = null + playUrlSessionId = '' + opening = false + activeLive.id = '' + activeLive.dockId = '' + activeLive.sessionId = '' + activeLive.playUrl = '' + activeLive.playUrlExpiresAt = 0 + activeLive.phase = '' + activeLive.leaseExpiresAt = 0 +} +``` + +(`closeActiveLive` already bumps `openSeq` then `resetActiveLive` — that clears promise/cache.) + +- [ ] **Step 2: Add `ensurePlayUrl` helper** + +Place after `resetActiveLive` / before `scheduleHeartbeat`: + +```js +async function ensurePlayUrl(dockId, sessionId, { force = false } = {}) { + if (!dockId || !sessionId) return null + + if ( + !force && + activeLive.sessionId === sessionId && + activeLive.playUrl && + activeLive.playUrlExpiresAt * 1000 - Date.now() > PLAY_URL_FRESH_MS + ) { + return { + playUrl: activeLive.playUrl, + expiresAt: activeLive.playUrlExpiresAt, + } + } + + if (playUrlPromise && playUrlSessionId === sessionId) { + return playUrlPromise + } + + const seq = openSeq + playUrlSessionId = sessionId + playUrlPromise = getLivePlayURL(dockId, sessionId) + .then((play) => { + if (disposed || seq !== openSeq || activeLive.sessionId !== sessionId) return play + const nowSec = Math.floor(Date.now() / 1000) + const expiresAt = Number(play?.expiresAt) || nowSec + PLAY_EXPIRE_FALLBACK_SEC + if (play?.playUrl) { + activeLive.playUrl = play.playUrl + activeLive.playUrlExpiresAt = expiresAt + } + return play + }) + .finally(() => { + if (playUrlSessionId === sessionId) { + playUrlPromise = null + playUrlSessionId = '' + } + }) + + return playUrlPromise +} +``` + +- [ ] **Step 3: Wire `openLive` with opening lock + ensure** + +Replace `openLive` with: + +```js +async function openLive(stream) { + if (disposed || !stream?.dockId || opening) return + if (activeLive.id === stream.id && activeLive.playUrl) { + await closeActiveLive() + return + } + + opening = true + try { + // closeActiveLive 会 bump openSeq,使旧 in-flight join 失效;之后再取本次 seq + await closeActiveLive() + const seq = ++openSeq + + try { + const result = await joinLiveWithRetry(stream.dockId, {}, { + shouldContinue: () => seq === openSeq && !disposed, + }) + const session = result?.session + const sessionId = session?.id + + if (seq !== openSeq || disposed) { + if (sessionId) await safeLeave(stream.dockId, sessionId) + return + } + + if (!sessionId) { + ui.toast('直播会话无效') + return + } + + activeLive.id = stream.id + activeLive.dockId = stream.dockId + activeLive.sessionId = sessionId + activeLive.phase = session.phase || '' + activeLive.playUrl = '' + activeLive.playUrlExpiresAt = 0 + scheduleHeartbeat(result.leaseExpiresAt) + + if (session.phase !== 'streaming') { + ui.toast('直播正在启动,请稍后重试') + return + } + + const play = await ensurePlayUrl(stream.dockId, sessionId) + if (seq !== openSeq || disposed || activeLive.sessionId !== sessionId) return + if (play?.playUrl && !play.playUrl.startsWith('fake://')) { + // ensurePlayUrl already wrote playUrl/expiresAt when session matched; + // keep toast branch for empty/fake + if (!activeLive.playUrl) { + activeLive.playUrl = play.playUrl + } + } else if (!activeLive.playUrl) { + ui.toast('控制面已验证,当前未配置可播放媒体流') + } + } catch (e) { + if (seq !== openSeq || disposed || e?.code === 'ABORTED') return + if (isLeaseInvalidError(e)) { + await rejoinAfterLeaseInvalid(stream.id, stream.dockId) + return + } + await closeActiveLive() + ui.toast(e.message || '打开直播失败') + } + } finally { + opening = false + } +} +``` + +Note: `closeActiveLive` clears `opening` via `resetActiveLive`. That is OK **before** the outer `finally` sets `opening = false` again; do **not** rely on reset to keep opening true across join — the outer `opening = true` must be set **before** `closeActiveLive`, and `resetActiveLive` currently clears `opening`. + +**Important implementation detail:** either: + +1. **Preferred:** do **not** clear `opening` inside `resetActiveLive`; only clear it in `openLive` finally / unmount, **or** +2. Set `opening = true` **after** `await closeActiveLive()`. + +Use option **2** to minimize surprise (toggle-close path unaffected): + +```js +async function openLive(stream) { + if (disposed || !stream?.dockId || opening) return + if (activeLive.id === stream.id && activeLive.playUrl) { + await closeActiveLive() + return + } + + await closeActiveLive() + if (disposed) return + opening = true + const seq = ++openSeq + try { + const result = await joinLiveWithRetry(stream.dockId, {}, { + shouldContinue: () => seq === openSeq && !disposed && opening, + }) + // ... same session handling as above from session extract onward ... + } catch (e) { + // same as above + } finally { + opening = false + } +} +``` + +And in `resetActiveLive`, **do clear** `playUrlPromise` / `playUrlSessionId` / `playUrlExpiresAt`, but **do not** clear `opening` (leave that to `openLive` finally). Explicitly: + +```js +function resetActiveLive() { + clearHeartbeat() + playUrlPromise = null + playUrlSessionId = '' + activeLive.id = '' + activeLive.dockId = '' + activeLive.sessionId = '' + activeLive.playUrl = '' + activeLive.playUrlExpiresAt = 0 + activeLive.phase = '' + activeLive.leaseExpiresAt = 0 +} +``` + +- [ ] **Step 4: Heartbeat may ensure when streaming and missing playUrl** + +Inside `scheduleHeartbeat` success path, after updating phase, add: + +```js + if (result?.session?.phase === 'streaming' && !activeLive.playUrl) { + try { + await ensurePlayUrl(dockId, sessionId) + } catch (playErr) { + if (disposed || activeLive.sessionId !== sessionId) return + if (isLeaseInvalidError(playErr)) { + await rejoinAfterLeaseInvalid(streamId, dockId) + return + } + console.warn('[media] ensure play-url after heartbeat failed', playErr) + } + } +``` + +- [ ] **Step 5: Static check** + +```bash +rg -n "PLAY_URL_FRESH_MS|ensurePlayUrl|playUrlPromise|playUrlSessionId|opening|playUrlExpiresAt" src/views/MediaView/MediaView.vue +``` + +Expected: opening guard; ensurePlayUrl TTL + coalesce; openLive uses ensure; reset clears promise/session/expiry; heartbeat can ensure when missing playUrl. + +- [ ] **Step 6: Commit** + +```bash +git add src/views/MediaView/MediaView.vue +git commit -m "feat: cache media play-url by session and debounce open" +``` + +--- + +### Task 3: Build, deploy, smoke + +**Files:** none (verify only) + +- [ ] **Step 1: Build** + +```bash +npm run build +``` + +Expected: success. + +- [ ] **Step 2: Push + deploy** + +```bash +git push origin main +tar -C dist -czf - . | ssh -o BatchMode=yes jg-serv1 'rm -rf /usr/share/nginx/laic-frontend/dist/* && tar -C /usr/share/nginx/laic-frontend/dist -xzf -' +``` + +- [ ] **Step 3: Manual smoke** + +1. Monitor 开播:进入 streaming 后,短时间同一 `streamSessionId` 的 `/play-url` 不应出现多份并发;未过期不要无故重拉。 +2. Media 开播:同样;连点播放不会并行多个 join。 +3. 启动期 HLS 404:仅同 URL 重载,不因此刷 `/play-url`。 +4. 临期或播放错误 force/fromRetry 才会重新 GET。 +5. 切走直播 / 关页后无过期回写;stop / 57006 / TLS·403 回归正常。 + +--- + +## Self-review vs spec + +| Spec requirement | Task | +|------------------|------| +| Session in-flight coalesce | Task 1 + 2 | +| TTL >30s reuse | Task 1 + 2 (`PLAY_URL_FRESH_MS`) | +| open / phase / heartbeat unified ensure | Task 1; Media open + heartbeat gap fill in Task 2 | +| force only for renew / retry | Task 1 Step 3 | +| opening debounce on Media | Task 2 | +| clear promise on close/dispose | Task 1 clears; Task 2 resetActiveLive | +| write only if session still matches | both tasks | +| LivePlayer 404 unchanged | no LivePlayer edits | +| build/deploy/smoke | Task 3 |