videoApi.js 5.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { getCockpitConfig } from './cockpitConfig.js'
  2. import { planCockpitRequest } from './cockpitApi.js'
  3. export class VideoRequestError extends Error {
  4. constructor(category, message, uncertain = false) { super(message); this.category = category; this.uncertain = uncertain }
  5. }
  6. const messages = {
  7. INVALID_REQUEST: '视频请求参数不正确', DEVICE_NOT_FOUND: '视频设备不存在', VIDEO_UNAVAILABLE: '此设备暂不支持实时监控',
  8. SESSION_FORBIDDEN: '播放会话不存在或无权操作', SESSION_EXPIRED: '播放会话已失效', VIDEO_START_FAILED: '视频连接失败,请稍后重试',
  9. VIDEO_READ_FAILED: '视频能力暂不可用', SESSION_HEARTBEAT_FAILED: '播放会话维护失败', CLEANUP_UNCONFIRMED: '会话释放未确认,等待服务端回收',
  10. }
  11. const sessionPath = (id, action) => {
  12. if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new VideoRequestError('INVALID_RESPONSE', '播放会话标识无效')
  13. return `/video/sessions/${encodeURIComponent(id)}/${action}`
  14. }
  15. /** Dedicated video transport: no business login, no mock fallback, credentials only in the owned-session body. */
  16. export function createVideoApi(options = {}) {
  17. const timers = options.timers || globalThis
  18. const isLive = (path) => path === '/video/live' || /^\/video\/sessions\/[^/]+\/live$/.test(path)
  19. async function request(path, body, timeoutMs = 10000) {
  20. const plan = planCockpitRequest(options.config || getCockpitConfig(), path)
  21. if (plan.mode !== 'real' || plan.error) throw new VideoRequestError('NOT_CONFIGURED', '请先配置真实视频接口')
  22. const controller = new AbortController()
  23. let timeout
  24. const operation = (async () => {
  25. const response = await (options.fetchImpl || globalThis.fetch)(plan.url, {
  26. method: body === undefined ? 'GET' : 'POST', credentials: 'omit', cache: 'no-store', signal: controller.signal,
  27. ...(path.endsWith('/stop') ? { keepalive: true } : {}),
  28. headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
  29. ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  30. })
  31. let payload
  32. try { payload = await response.json() } catch { throw new VideoRequestError('INVALID_RESPONSE', '视频接口返回了无效数据') }
  33. if (!response.ok || payload?.code !== 200) {
  34. const category = payload?.data?.category
  35. const known = typeof category === 'string' && Object.hasOwn(messages, category)
  36. const uncertain = isLive(path) && (!known || ['VIDEO_START_FAILED', 'CLEANUP_UNCONFIRMED'].includes(category))
  37. throw new VideoRequestError(known ? category : 'REQUEST_FAILED', known ? messages[category] : '视频请求失败', uncertain)
  38. }
  39. if (!payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data)) throw new VideoRequestError('INVALID_RESPONSE', '视频接口载荷不完整')
  40. return payload.data
  41. })()
  42. const deadline = new Promise((_, reject) => { timeout = timers.setTimeout(() => {
  43. controller.abort(); reject(new VideoRequestError('REQUEST_TIMEOUT', '视频请求超时,结果未确认', isLive(path)))
  44. }, timeoutMs) })
  45. try { return await Promise.race([operation, deadline]) }
  46. catch (error) {
  47. if (error instanceof VideoRequestError) {
  48. if (isLive(path) && error.category === 'INVALID_RESPONSE') error.uncertain = true
  49. throw error
  50. }
  51. throw new VideoRequestError('NETWORK_ERROR', '视频网络连接失败,结果未确认', isLive(path))
  52. } finally { timers.clearTimeout(timeout) }
  53. }
  54. const sessionReply = (id, statuses) => (data) => {
  55. const allowed = Array.isArray(statuses) ? statuses : [statuses]
  56. if (data.sessionId !== id || !allowed.includes(data.status)) throw new VideoRequestError('INVALID_RESPONSE', '会话维护结果与当前观看不匹配')
  57. return data
  58. }
  59. return {
  60. device: (deviceCode) => request(`/video/device?${new URLSearchParams({ deviceCode })}`),
  61. prepare: (deviceCode) => request('/video/sessions/prepare', { deviceCode }),
  62. start: (session) => typeof session === 'string'
  63. ? request('/video/live', { deviceCode: session }, 100000)
  64. : request(sessionPath(session.sessionId, 'live'), { sessionToken: session.sessionToken }, 100000)
  65. .then(sessionReply(session.sessionId, ['STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
  66. status: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'status'), { sessionToken })
  67. .then(sessionReply(sessionId, ['PREPARED', 'STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
  68. heartbeat: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'heartbeat'), { sessionToken }).then(sessionReply(sessionId, 'ACTIVE')),
  69. stop: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'stop'), { sessionToken }).then(sessionReply(sessionId, ['STOPPED', 'FAILED', 'CLEANUP_PENDING'])),
  70. }
  71. }
  72. /** Only the explicitly selected browser-native transports; FLV is not a native video URL. */
  73. export function selectVideoMedia(media) {
  74. const kind = media?.native === true ? 'native' : 'webrtc'
  75. const value = kind === 'native' ? media?.url : media?.webrtc
  76. let url
  77. try { url = new URL(value) } catch { throw new VideoRequestError('UNSUPPORTED_MEDIA', '当前播放方案不支持返回的媒体协议') }
  78. if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /\.(?:flv|html?)(?:$)/i.test(url.pathname)) {
  79. throw new VideoRequestError('UNSUPPORTED_MEDIA', '媒体地址不是可用的原生播放地址')
  80. }
  81. return { kind, url: url.href }
  82. }