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