You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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 + refreshPlayURL to 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:

  • openLive streaming branch: await refreshPlayURL()
  • pollPhaseOnce streaming 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_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

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 ensurePlayUrl helper

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 openLive with 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:

  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):

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
  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