13 KiB
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:
const PLAY_URL_FRESH_MS = PLAY_REFRESH_LEAD_MS
Replace:
let playURLRequest = null
with:
let playURLRequest = null
let playURLRequestSessionId = ''
In clearPlaybackOnly and resetLocal, clear both:
playURLRequest = null
playURLRequestSessionId = ''
(Keep existing clears of live.playUrl / live.playUrlExpiresAt.)
- Step 2: Rewrite
refreshPlayURLOnce+refreshPlayURLto ensure semantics
Replace the current refreshPlayURLOnce / refreshPlayURL pair with:
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:
await refreshPlayURL()
to:
await refreshPlayURL({ force: true })
Keep these as default (non-force) so they coalesce/reuse:
openLivestreaming branch:await refreshPlayURL()pollPhaseOncestreaming branch:await refreshPlayURL()- heartbeat when
streaming && !live.playUrl:await refreshPlayURL()
Keep error rebuild as:
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
rg -n "PLAY_URL_FRESH_MS|playURLRequestSessionId|force: true|fromRetry|refreshPlayURL" src/composables/useMonitorLive.js
Expected:
-
PLAY_URL_FRESH_MSdefined -
coalesce keyed by
playURLRequestSessionId -
TTL short-circuit in
refreshPlayURL -
schedulePlayRefreshuses{ force: true } -
onPlayErrorstill{ fromRetry: true } -
open/phase/heartbeat cold paths still
refreshPlayURL()without force -
Step 5: Commit
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:
playUrlExpiresAt: 0,
Near other module lets (heartbeatTimer, openSeq, …), add:
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:
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
ensurePlayUrlhelper
Place after resetActiveLive / before scheduleHeartbeat:
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
openLivewith opening lock + ensure
Replace openLive with:
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:
- Preferred: do not clear
openinginsideresetActiveLive; only clear it inopenLivefinally / unmount, or - Set
opening = trueafterawait closeActiveLive().
Use option 2 to minimize surprise (toggle-close path unaffected):
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:
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:
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
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
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
npm run build
Expected: success.
- Step 2: Push + deploy
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
- Monitor 开播:进入 streaming 后,短时间同一
streamSessionId的/play-url不应出现多份并发;未过期不要无故重拉。 - Media 开播:同样;连点播放不会并行多个 join。
- 启动期 HLS 404:仅同 URL 重载,不因此刷
/play-url。 - 临期或播放错误 force/fromRetry 才会重新 GET。
- 切走直播 / 关页后无过期回写;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 |