Compare commits

...

6 Commits

Author SHA1 Message Date
xiaosi 8237204293 fix: tighten Media openLive opening lock around close and rejoin 2 weeks ago
xiaosi 78f387c890 feat: cache media play-url by session and debounce open 2 weeks ago
xiaosi 91e88fb2aa feat: coalesce monitor play-url by session with TTL reuse 2 weeks ago
xiaosi 8a0d4c08bc docs: add live play-url session cache implementation plan 2 weeks ago
xiaosi 0bfcd9cd67 docs: repair play-url cache spec code fence 2 weeks ago
xiaosi 31e9c0ff8e docs: add live play-url session cache design 2 weeks ago
  1. 454
      docs/superpowers/plans/2026-09-03-live-playurl-session-cache.md
  2. 143
      docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md
  3. 39
      src/composables/useMonitorLive.js
  4. 84
      src/views/MediaView/MediaView.vue

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

143
docs/superpowers/specs/2026-09-03-live-playurl-session-cache-design.md

@ -0,0 +1,143 @@
# 直播 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` 次数。

39
src/composables/useMonitorLive.js

@ -16,6 +16,7 @@ const STOP_TIMEOUT_MS = 60_000
const PLAY_RETRY_MAX = 3 const PLAY_RETRY_MAX = 3
const PLAY_EXPIRE_FALLBACK_SEC = 270 const PLAY_EXPIRE_FALLBACK_SEC = 270
const PLAY_REFRESH_LEAD_MS = 30_000 const PLAY_REFRESH_LEAD_MS = 30_000
const PLAY_URL_FRESH_MS = PLAY_REFRESH_LEAD_MS
const LEASE_INVALID_CODE = 57006 const LEASE_INVALID_CODE = 57006
function isFatalPlayMediaError(err) { function isFatalPlayMediaError(err) {
@ -73,6 +74,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
}) })
let playURLRequest = null let playURLRequest = null
let playURLRequestSessionId = ''
let disposed = false let disposed = false
let openGen = 0 let openGen = 0
let rejoinBusy = false let rejoinBusy = false
@ -108,6 +110,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
live.heartbeatTimer = null live.heartbeatTimer = null
live.playRefreshTimer = null live.playRefreshTimer = null
playURLRequest = null playURLRequest = null
playURLRequestSessionId = ''
live.playUrl = '' live.playUrl = ''
live.playUrlExpiresAt = 0 live.playUrlExpiresAt = 0
live.playRetryCount = 0 live.playRetryCount = 0
@ -116,6 +119,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
function resetLocal() { function resetLocal() {
clearTimers() clearTimers()
playURLRequest = null playURLRequest = null
playURLRequestSessionId = ''
live.session = null live.session = null
live.dockId = '' live.dockId = ''
live.opening = false live.opening = false
@ -257,10 +261,20 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
// 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归) // 成功返回 true;失败抛错,由调用方决定是否 onPlayError(避免互相递归)
// fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建 // fromRetry: 播放错误触发的刷新不重置重试计数,避免无限重建
async function refreshPlayURLOnce({ fromRetry = false } = {}) {
async function refreshPlayURLOnce({ fromRetry = false, force = false } = {}) {
if (disposed || live.stopping || !live.dockId || !live.session?.id) return false if (disposed || live.stopping || !live.dockId || !live.session?.id) return false
const dockId = live.dockId const dockId = live.dockId
const sessionId = live.session.id 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) const play = await getLivePlayURL(dockId, sessionId)
if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return false if (disposed || live.stopping || !live.session || live.session.id !== sessionId) return false
live.playUrl = play.playUrl || '' live.playUrl = play.playUrl || ''
@ -272,9 +286,28 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
} }
function refreshPlayURL(options = {}) { function refreshPlayURL(options = {}) {
if (playURLRequest) return playURLRequest
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(() => { playURLRequest = refreshPlayURLOnce(options).finally(() => {
if (playURLRequestSessionId === sessionId) {
playURLRequest = null playURLRequest = null
playURLRequestSessionId = ''
}
}) })
return playURLRequest return playURLRequest
} }
@ -286,7 +319,7 @@ export function useMonitorLive({ getDockId, ui, getLivePlayer }) {
const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS) const delay = Math.max(5000, live.playUrlExpiresAt * 1000 - Date.now() - PLAY_REFRESH_LEAD_MS)
live.playRefreshTimer = window.setTimeout(async () => { live.playRefreshTimer = window.setTimeout(async () => {
try { try {
await refreshPlayURL()
await refreshPlayURL({ force: true })
} catch (e) { } catch (e) {
if (disposed || live.stopping) return if (disposed || live.stopping) return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {

84
src/views/MediaView/MediaView.vue

@ -102,13 +102,19 @@ const activeLive = reactive({
dockId: '', dockId: '',
sessionId: '', sessionId: '',
playUrl: '', playUrl: '',
playUrlExpiresAt: 0,
phase: '', phase: '',
leaseExpiresAt: 0, leaseExpiresAt: 0,
}) })
const PLAY_URL_FRESH_MS = 30_000
const PLAY_EXPIRE_FALLBACK_SEC = 270
let heartbeatTimer = null let heartbeatTimer = null
let openSeq = 0 let openSeq = 0
let disposed = false let disposed = false
let rejoinBusy = false let rejoinBusy = false
let playUrlPromise = null
let playUrlSessionId = ''
let opening = false
const LEASE_INVALID_CODE = 57006 const LEASE_INVALID_CODE = 57006
@ -144,14 +150,59 @@ function clearHeartbeat() {
function resetActiveLive() { function resetActiveLive() {
clearHeartbeat() clearHeartbeat()
playUrlPromise = null
playUrlSessionId = ''
activeLive.id = '' activeLive.id = ''
activeLive.dockId = '' activeLive.dockId = ''
activeLive.sessionId = '' activeLive.sessionId = ''
activeLive.playUrl = '' activeLive.playUrl = ''
activeLive.playUrlExpiresAt = 0
activeLive.phase = '' activeLive.phase = ''
activeLive.leaseExpiresAt = 0 activeLive.leaseExpiresAt = 0
} }
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
}
function scheduleHeartbeat(expiresAt) { function scheduleHeartbeat(expiresAt) {
clearHeartbeat() clearHeartbeat()
if (disposed || !activeLive.dockId || !activeLive.sessionId) return if (disposed || !activeLive.dockId || !activeLive.sessionId) return
@ -165,6 +216,18 @@ function scheduleHeartbeat(expiresAt) {
const result = await heartbeatLive(dockId, sessionId) const result = await heartbeatLive(dockId, sessionId)
if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return
if (result?.session?.phase) activeLive.phase = result.session.phase if (result?.session?.phase) activeLive.phase = result.session.phase
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)
}
}
scheduleHeartbeat(result.leaseExpiresAt) scheduleHeartbeat(result.leaseExpiresAt)
} catch (e) { } catch (e) {
if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return if (disposed || activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return
@ -236,24 +299,24 @@ async function load() {
} }
async function openLive(stream) { async function openLive(stream) {
if (disposed || !stream?.dockId) return
if (disposed || !stream?.dockId || opening) return
if (activeLive.id === stream.id && activeLive.playUrl) { if (activeLive.id === stream.id && activeLive.playUrl) {
await closeActiveLive() await closeActiveLive()
return return
} }
// closeActiveLive bump openSeq使 in-flight join seq
opening = true
try {
await closeActiveLive() await closeActiveLive()
if (disposed) return
const seq = ++openSeq const seq = ++openSeq
try { try {
const result = await joinLiveWithRetry(stream.dockId, {}, { const result = await joinLiveWithRetry(stream.dockId, {}, {
shouldContinue: () => seq === openSeq && !disposed,
shouldContinue: () => seq === openSeq && !disposed && opening,
}) })
const session = result?.session const session = result?.session
const sessionId = session?.id const sessionId = session?.id
// / viewer lease
if (seq !== openSeq || disposed) { if (seq !== openSeq || disposed) {
if (sessionId) await safeLeave(stream.dockId, sessionId) if (sessionId) await safeLeave(stream.dockId, sessionId)
return return
@ -269,6 +332,7 @@ async function openLive(stream) {
activeLive.sessionId = sessionId activeLive.sessionId = sessionId
activeLive.phase = session.phase || '' activeLive.phase = session.phase || ''
activeLive.playUrl = '' activeLive.playUrl = ''
activeLive.playUrlExpiresAt = 0
scheduleHeartbeat(result.leaseExpiresAt) scheduleHeartbeat(result.leaseExpiresAt)
if (session.phase !== 'streaming') { if (session.phase !== 'streaming') {
@ -276,22 +340,28 @@ async function openLive(stream) {
return return
} }
const play = await getLivePlayURL(stream.dockId, sessionId)
const play = await ensurePlayUrl(stream.dockId, sessionId)
if (seq !== openSeq || disposed || activeLive.sessionId !== sessionId) return if (seq !== openSeq || disposed || activeLive.sessionId !== sessionId) return
if (play?.playUrl && !play.playUrl.startsWith('fake://')) { if (play?.playUrl && !play.playUrl.startsWith('fake://')) {
if (!activeLive.playUrl) {
activeLive.playUrl = play.playUrl activeLive.playUrl = play.playUrl
} else {
}
} else if (!activeLive.playUrl) {
ui.toast('控制面已验证,当前未配置可播放媒体流') ui.toast('控制面已验证,当前未配置可播放媒体流')
} }
} catch (e) { } catch (e) {
if (seq !== openSeq || disposed || e?.code === 'ABORTED') return if (seq !== openSeq || disposed || e?.code === 'ABORTED') return
if (isLeaseInvalidError(e)) { if (isLeaseInvalidError(e)) {
opening = false // unlock before nested openLive via rejoin
await rejoinAfterLeaseInvalid(stream.id, stream.dockId) await rejoinAfterLeaseInvalid(stream.id, stream.dockId)
return return
} }
await closeActiveLive() await closeActiveLive()
ui.toast(e.message || '打开直播失败') ui.toast(e.message || '打开直播失败')
} }
} finally {
opening = false
}
} }
function onActivePlayError(err) { function onActivePlayError(err) {

Loading…
Cancel
Save