videoLiveSession.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /*!
  2. * Live-session helper adapted from city-life-line/src/utils/videoLiveSession.js.
  3. * Copyright (c) 2018 RuoYi — MIT License.
  4. * Permission is hereby granted, free of charge, to any person obtaining a copy of
  5. * this software and associated documentation files (the "Software"), to deal in
  6. * the Software without restriction, including without limitation the rights to
  7. * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  8. * the Software, and to permit persons to whom the Software is furnished to do so,
  9. * subject to the following conditions:
  10. * The above copyright notice and this permission notice shall be included in all
  11. * copies or substantial portions of the Software.
  12. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  13. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  14. * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  15. * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
  16. * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  17. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  18. */
  19. import { selectVideoMedia, VideoRequestError } from './videoApi.js'
  20. /** One visible attempt, owned preparation before media, a bounded retry budget and cleanup observer. */
  21. export function createVideoLiveSession(api, update, timers = globalThis) {
  22. let generation = 0, current = null, cleanupTimer, cleanupChecks = 0, disposed = false
  23. const releases = new Set()
  24. const cleanupMessage = '会话释放未确认,等待服务端回收;可重试清理'
  25. // Retry policy: five automatic attempts after the first normal play, spaced 2/4/8/16/30s.
  26. const retryDelays = [2000, 4000, 8000, 16000, 30000]
  27. const stablePlaybackMs = 30000
  28. // VideoRequestError carries `category`; the player and accepted errors use `code`.
  29. const unrecoverable = new Set(['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'SESSION_EXPIRED',
  30. 'INVALID_RESPONSE', 'NOT_CONFIGURED', 'INVALID_REQUEST', 'UNSUPPORTED_MEDIA', 'VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED'])
  31. let retryBudget = retryDelays.length, retryTimer, retryAttempt = 0, stableTimer
  32. const publish = (patch) => { if (!disposed) update(patch) }
  33. const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
  34. const active = (attempt) => !disposed && current === attempt && attempt.version === generation
  35. const clear = (attempt) => {
  36. if (!attempt) return
  37. timers.clearTimeout(attempt.startTimer); timers.clearTimeout(attempt.readyTimer); timers.clearInterval(attempt.heartbeat)
  38. }
  39. const cancelRetry = () => {
  40. timers.clearTimeout(retryTimer); retryTimer = undefined
  41. timers.clearTimeout(stableTimer); stableTimer = undefined
  42. }
  43. function scheduleCleanup() {
  44. if (disposed || cleanupTimer || !releases.size || cleanupChecks >= 40) return
  45. cleanupTimer = timers.setTimeout(async () => {
  46. cleanupTimer = null; cleanupChecks += 1
  47. for (const session of [...releases]) {
  48. try {
  49. const result = await api.status(session)
  50. if (result.sessionId === session.sessionId && ['STOPPED', 'FAILED'].includes(result.status)) {
  51. session.released = true; releases.delete(session)
  52. }
  53. } catch { /* Unknown status is not release evidence. */ }
  54. }
  55. reportCleanup(); scheduleCleanup()
  56. }, 3000)
  57. }
  58. async function release(session) {
  59. if (!session || session.released) return true
  60. if (session.releasing) return session.releasing
  61. releases.add(session)
  62. session.releasing = Promise.resolve().then(async () => {
  63. try {
  64. const result = await api.stop(session)
  65. if (result.sessionId !== session.sessionId || !['STOPPED', 'FAILED'].includes(result.status)) throw new Error('Cleanup not confirmed')
  66. session.released = true; releases.delete(session); return true
  67. } catch {
  68. if (session.released) return true
  69. publish({ cleanupError: cleanupMessage }); scheduleCleanup(); return false
  70. } finally { session.releasing = null }
  71. })
  72. return session.releasing
  73. }
  74. async function stop() {
  75. ++generation
  76. cancelRetry()
  77. const attempt = current; current = null; clear(attempt)
  78. publish({ media: null, phase: 'idle' })
  79. // An already released identity must never re-enter the pending set: release() returns early
  80. // for it, so the set would never shrink again and every later start would be refused.
  81. if (attempt?.identity && !attempt.identity.released) releases.add(attempt.identity)
  82. cleanupChecks = 0
  83. for (const session of [...releases]) await release(session)
  84. reportCleanup()
  85. return releases.size === 0
  86. }
  87. /** Only a temporary failure with budget left is retried; capability/ownership errors are final. */
  88. function shouldRetry(error) {
  89. if (disposed || !current || releases.size || retryBudget <= 0) return false
  90. // VideoRequestError carries `category`; accepted errors use `code`.
  91. const reason = error?.category || error?.code
  92. return !unrecoverable.has(reason)
  93. }
  94. /** Spends one attempt of the budget and schedules the next attempt after its own delay. */
  95. function scheduleRetry(deviceCode, message) {
  96. const attempt = current
  97. // Spend the budget here, before the timer exists, so the last retry cannot schedule another.
  98. retryBudget -= 1
  99. retryAttempt += 1
  100. const delay = retryDelays[retryDelays.length - retryBudget - 1] ?? retryDelays[retryDelays.length - 1]
  101. publish({ phase: 'retrying', error: message, retryAttempt, retryDelayMs: delay, retriesLeft: retryBudget })
  102. retryTimer = timers.setTimeout(() => {
  103. retryTimer = undefined
  104. if (!active(attempt) || releases.size) return
  105. void begin(deviceCode)
  106. }, delay)
  107. }
  108. async function fail(message, media) {
  109. if (!current || (media && current.media !== media)) return
  110. const error = message instanceof Error ? message : Object.assign(new Error(message || '视频播放失败'), {})
  111. const deviceCode = current.deviceCode
  112. publish({ error: error.message || '视频播放失败' })
  113. const attempt = current
  114. const retryable = shouldRetry(error)
  115. const stopping = stop(), version = generation
  116. const released = await stopping
  117. if (disposed || version !== generation) return
  118. if (!retryable || !released) {
  119. // Running the budget down is actionable information, distinct from a permanent capability error.
  120. const exhausted = retryAttempt >= retryDelays.length && !unrecoverable.has(error?.category || error?.code)
  121. const reason = exhausted ? `${error.message}(已自动重试 ${retryDelays.length} 次仍失败)` : error.message
  122. publish({ phase: 'failed', error: reason, retryAttempt: 0, retryDelayMs: 0, retriesLeft: 0 })
  123. return
  124. }
  125. // `released` proved the old attempt is fully gone; only its cancellation token is restored so the
  126. // scheduled retry still belongs to this viewing session. A fresh attempt is built by begin().
  127. current = { version, deviceCode, identity: null, media: null }
  128. scheduleRetry(deviceCode, error.message)
  129. }
  130. function ready(media) {
  131. if (!current?.media || current.identity?.released || (media && current.media !== media)) return
  132. timers.clearTimeout(current.readyTimer)
  133. publish({ phase: 'playing' })
  134. // A retry budget is earned only after real, sustained playback.
  135. timers.clearTimeout(stableTimer)
  136. const attempt = current
  137. stableTimer = timers.setTimeout(() => {
  138. if (!active(attempt)) return
  139. retryBudget = retryDelays.length; retryAttempt = 0
  140. }, stablePlaybackMs)
  141. }
  142. async function begin(deviceCode) {
  143. if (disposed) return
  144. if (releases.size) {
  145. publish({ phase: 'failed', error: '上次清理结果尚未确认,暂不能重复建流' }); return
  146. }
  147. const version = generation
  148. const attempt = { version, deviceCode, identity: null, media: null }
  149. current = attempt
  150. publish({ phase: 'starting', media: null, error: '', cleanupError: '' })
  151. attempt.startTimer = timers.setTimeout(() => { if (active(attempt)) void fail('视频建立超时') }, 35000)
  152. try {
  153. const identity = await api.prepare(deviceCode)
  154. if (typeof identity.sessionId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(identity.sessionId)
  155. || typeof identity.sessionToken !== 'string' || !identity.sessionToken)
  156. throw new VideoRequestError('INVALID_RESPONSE', '媒体启动前未取得完整观看身份')
  157. attempt.identity = { sessionId: identity.sessionId, sessionToken: identity.sessionToken, deviceCode, mode: 'LIVE' }
  158. if (!active(attempt)) { await release(attempt.identity); return }
  159. if (identity.deviceCode !== deviceCode || identity.mode !== 'LIVE' || identity.status !== 'PREPARED')
  160. throw new VideoRequestError('INVALID_RESPONSE', '观看身份与当前设备不匹配')
  161. let data = await api.start(attempt.identity)
  162. while (active(attempt) && data.status === 'STARTING') {
  163. if (data.sessionId !== attempt.identity.sessionId) throw new VideoRequestError('INVALID_RESPONSE', '返回的观看会话不匹配')
  164. await new Promise((resolve) => timers.setTimeout(resolve, 1000))
  165. if (!active(attempt)) break
  166. data = await api.status(attempt.identity)
  167. }
  168. if (!active(attempt)) { await release(attempt.identity); return }
  169. if (data.sessionId !== attempt.identity.sessionId) throw new VideoRequestError('INVALID_RESPONSE', '返回的观看会话不匹配')
  170. if (['STOPPED', 'FAILED'].includes(data.status)) {
  171. attempt.identity.released = true; clear(attempt); current = null
  172. publish({ media: null, phase: 'failed', error: '观看会话未建立或已停止' }); return
  173. }
  174. if (data.mode !== 'LIVE') throw new VideoRequestError('INVALID_RESPONSE', '返回的播放会话与当前设备不匹配')
  175. // 后端 preparedResult 只回 sessionId/status/mode/media;deviceCode 可选,只有在确实回了且不一致时才算不匹配。
  176. if (data.deviceCode !== undefined && data.deviceCode !== deviceCode)
  177. throw new VideoRequestError('INVALID_RESPONSE', '返回的播放会话与当前设备不匹配')
  178. if (data.status !== 'ACTIVE') throw new VideoRequestError('SESSION_PENDING', '媒体尚未就绪,结束本次观看')
  179. attempt.media = selectVideoMedia(data.media)
  180. timers.clearTimeout(attempt.startTimer)
  181. publish({ phase: 'connecting', media: attempt.media })
  182. attempt.readyTimer = timers.setTimeout(() => { if (active(attempt)) void fail('播放器就绪超时') }, 20000)
  183. let beating = false
  184. attempt.heartbeat = timers.setInterval(async () => {
  185. if (beating || !active(attempt)) return
  186. beating = true
  187. try { await api.heartbeat(attempt.identity) }
  188. catch { if (active(attempt)) await fail('播放会话已断开,请重新播放') }
  189. finally { beating = false }
  190. }, 30000)
  191. } catch (error) {
  192. if (active(attempt)) await fail(error)
  193. else if (attempt.identity) await release(attempt.identity)
  194. } finally {
  195. timers.clearTimeout(attempt.startTimer)
  196. reportCleanup()
  197. }
  198. }
  199. async function start(deviceCode) {
  200. const stopping = stop(), version = generation
  201. await stopping
  202. if (disposed || version !== generation) return
  203. // A manual replay starts a fresh budget; automatic retries never call this.
  204. retryBudget = retryDelays.length; retryAttempt = 0
  205. publish({ retryAttempt: 0, retryDelayMs: 0, retriesLeft: retryDelays.length })
  206. await begin(deviceCode)
  207. }
  208. async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }
  209. return { start, stop, dispose, ready, fail }
  210. }