Browse Source
Upgrade Monitor refreshPlayURL to session coalesce + TTL reuse, and add Media opening lock with the same ensure pattern.main
1 changed files with 454 additions and 0 deletions
@ -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 | |
||||
Loading…
Reference in new issue