| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485 |
- import { getCockpitConfig } from './cockpitConfig.js'
- import { planCockpitRequest } from './cockpitApi.js'
- export class VideoRequestError extends Error {
- constructor(category, message, uncertain = false) { super(message); this.category = category; this.uncertain = uncertain }
- }
- const messages = {
- INVALID_REQUEST: '视频请求参数不正确', DEVICE_NOT_FOUND: '视频设备不存在', VIDEO_UNAVAILABLE: '此设备暂不支持实时监控',
- SESSION_FORBIDDEN: '播放会话不存在或无权操作', SESSION_EXPIRED: '播放会话已失效', VIDEO_START_FAILED: '视频连接失败,请稍后重试',
- VIDEO_READ_FAILED: '视频能力暂不可用', SESSION_HEARTBEAT_FAILED: '播放会话维护失败', CLEANUP_UNCONFIRMED: '会话释放未确认,等待服务端回收',
- }
- const sessionPath = (id, action) => {
- if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new VideoRequestError('INVALID_RESPONSE', '播放会话标识无效')
- return `/video/sessions/${encodeURIComponent(id)}/${action}`
- }
- /** Dedicated video transport: no business login, no mock fallback, credentials only in the owned-session body. */
- export function createVideoApi(options = {}) {
- const timers = options.timers || globalThis
- const isLive = (path) => path === '/video/live' || /^\/video\/sessions\/[^/]+\/live$/.test(path)
- async function request(path, body, timeoutMs = 10000) {
- const plan = planCockpitRequest(options.config || getCockpitConfig(), path)
- if (plan.mode !== 'real' || plan.error) throw new VideoRequestError('NOT_CONFIGURED', '请先配置真实视频接口')
- const controller = new AbortController()
- let timeout
- const operation = (async () => {
- const response = await (options.fetchImpl || globalThis.fetch)(plan.url, {
- method: body === undefined ? 'GET' : 'POST', credentials: 'omit', cache: 'no-store', signal: controller.signal,
- ...(path.endsWith('/stop') ? { keepalive: true } : {}),
- headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
- })
- let payload
- try { payload = await response.json() } catch { throw new VideoRequestError('INVALID_RESPONSE', '视频接口返回了无效数据') }
- if (!response.ok || payload?.code !== 200) {
- const category = payload?.data?.category
- const known = typeof category === 'string' && Object.hasOwn(messages, category)
- const uncertain = isLive(path) && (!known || ['VIDEO_START_FAILED', 'CLEANUP_UNCONFIRMED'].includes(category))
- throw new VideoRequestError(known ? category : 'REQUEST_FAILED', known ? messages[category] : '视频请求失败', uncertain)
- }
- if (!payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data)) throw new VideoRequestError('INVALID_RESPONSE', '视频接口载荷不完整')
- return payload.data
- })()
- const deadline = new Promise((_, reject) => { timeout = timers.setTimeout(() => {
- controller.abort(); reject(new VideoRequestError('REQUEST_TIMEOUT', '视频请求超时,结果未确认', isLive(path)))
- }, timeoutMs) })
- try { return await Promise.race([operation, deadline]) }
- catch (error) {
- if (error instanceof VideoRequestError) {
- if (isLive(path) && error.category === 'INVALID_RESPONSE') error.uncertain = true
- throw error
- }
- throw new VideoRequestError('NETWORK_ERROR', '视频网络连接失败,结果未确认', isLive(path))
- } finally { timers.clearTimeout(timeout) }
- }
- const sessionReply = (id, statuses) => (data) => {
- const allowed = Array.isArray(statuses) ? statuses : [statuses]
- if (data.sessionId !== id || !allowed.includes(data.status)) throw new VideoRequestError('INVALID_RESPONSE', '会话维护结果与当前观看不匹配')
- return data
- }
- return {
- device: (deviceCode) => request(`/video/device?${new URLSearchParams({ deviceCode })}`),
- prepare: (deviceCode) => request('/video/sessions/prepare', { deviceCode }),
- start: (session) => typeof session === 'string'
- ? request('/video/live', { deviceCode: session }, 100000)
- : request(sessionPath(session.sessionId, 'live'), { sessionToken: session.sessionToken }, 100000)
- .then(sessionReply(session.sessionId, ['STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
- status: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'status'), { sessionToken })
- .then(sessionReply(sessionId, ['PREPARED', 'STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
- heartbeat: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'heartbeat'), { sessionToken }).then(sessionReply(sessionId, 'ACTIVE')),
- stop: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'stop'), { sessionToken }).then(sessionReply(sessionId, ['STOPPED', 'FAILED', 'CLEANUP_PENDING'])),
- }
- }
- /** Only the explicitly selected browser-native transports; FLV is not a native video URL. */
- export function selectVideoMedia(media) {
- const kind = media?.native === true ? 'native' : 'webrtc'
- const value = kind === 'native' ? media?.url : media?.webrtc
- let url
- try { url = new URL(value) } catch { throw new VideoRequestError('UNSUPPORTED_MEDIA', '当前播放方案不支持返回的媒体协议') }
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /\.(?:flv|html?)(?:$)/i.test(url.pathname)) {
- throw new VideoRequestError('UNSUPPORTED_MEDIA', '媒体地址不是可用的原生播放地址')
- }
- return { kind, url: url.href }
- }
|