NextGB, web demo powerd by vue
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type * as Types from './types'
|
||||
import type { MediaServer } from '@/api/mediaserver/types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体服务器相关 API
|
||||
export const mediaServerApi = {
|
||||
// 获取媒体服务器列表
|
||||
getMediaServers: () =>
|
||||
api.get<Types.ApiResponse<MediaServer[]>>('/srs-sip/v1/media-servers'),
|
||||
|
||||
// 添加媒体服务器
|
||||
addMediaServer: (data: Omit<MediaServer, 'id' | 'status' | 'created_at'>) =>
|
||||
api.post<Types.ApiResponse<{ msg: string }>>('/srs-sip/v1/media-servers', data),
|
||||
|
||||
// 删除媒体服务器
|
||||
deleteMediaServer: (id: number) =>
|
||||
api.delete<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/${id}`),
|
||||
|
||||
// 设置默认媒体服务器
|
||||
setDefaultMediaServer: (id: number) =>
|
||||
api.post<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/default/${id}`),
|
||||
}
|
||||
|
||||
// 设备相关 API
|
||||
export const deviceApi = {
|
||||
// 获取设备列表
|
||||
getDevices: () => api.get<Types.ApiResponse<Types.Device[]>>('/srs-sip/v1/devices'),
|
||||
|
||||
// 获取设备通道
|
||||
getDeviceChannels: (deviceId: string) =>
|
||||
api.get<Types.ApiResponse<Types.ChannelInfo[]>>(`/srs-sip/v1/devices/${deviceId}/channels`),
|
||||
|
||||
// 添加 invite API
|
||||
invite: (params: Types.InviteRequest) =>
|
||||
api.post<Types.ApiResponse<Types.InviteResponse>>('/srs-sip/v1/invite', params),
|
||||
|
||||
// 停止播放
|
||||
bye: (params: Types.ByeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/bye', params),
|
||||
|
||||
// 暂停播放
|
||||
pause: (params: Types.PauseRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/pause', params),
|
||||
|
||||
// 恢复播放
|
||||
resume: (params: Types.ResumeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/resume', params),
|
||||
|
||||
// 设置播放速度
|
||||
speed: (params: Types.SpeedRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/speed', params),
|
||||
|
||||
// 云台控制
|
||||
controlPTZ: (params: Types.PTZControlRequest) =>
|
||||
api.post<Types.ApiResponse<any>>('/srs-sip/v1/ptz', params),
|
||||
|
||||
// 查询录像
|
||||
queryRecord: (params: Types.RecordInfoRequest) =>
|
||||
api.post<Types.ApiResponse<Types.RecordInfoResponse[]>>('/srs-sip/v1/query-record', params),
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// 配置处理逻辑
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data as Types.ApiResponse<any>
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error('请求失败')
|
||||
return Promise.reject(new Error('请求失败'))
|
||||
}
|
||||
response.data = res.data
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
ElMessage.error(error.response?.data?.message || '网络错误')
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from './types'
|
||||
import { MediaServerType } from './types'
|
||||
|
||||
|
||||
/**
|
||||
* 媒体服务器接口
|
||||
*/
|
||||
export interface IMediaServer {
|
||||
type: MediaServerType
|
||||
getVersion(): Promise<VersionInfo>
|
||||
getStreamInfo(): Promise<StreamInfo[]>
|
||||
getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
|
||||
createRtcPlayer(): RtcPlayer
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体服务器基础实现类
|
||||
*/
|
||||
export abstract class BaseMediaServer implements IMediaServer {
|
||||
type: MediaServerType
|
||||
|
||||
constructor(type: MediaServerType) {
|
||||
this.type = type
|
||||
}
|
||||
|
||||
abstract getVersion(): Promise<VersionInfo>
|
||||
abstract getStreamInfo(): Promise<StreamInfo[]>
|
||||
abstract getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
|
||||
abstract createRtcPlayer(): RtcPlayer
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MediaServer } from './types'
|
||||
import { MediaServerType } from './types'
|
||||
import type { BaseMediaServer } from './base'
|
||||
import { SRSServer } from './srs/srs'
|
||||
import { ZLMServer } from './zlm/zlm'
|
||||
|
||||
/**
|
||||
* 创建媒体服务器实例的工厂函数
|
||||
*/
|
||||
export const createMediaServer = (config: MediaServer): BaseMediaServer => {
|
||||
// 统一转换为小写进行比较
|
||||
const serverType = config.type.toLowerCase()
|
||||
|
||||
switch (serverType) {
|
||||
case MediaServerType.SRS:
|
||||
return new SRSServer(config.ip, config.port)
|
||||
case MediaServerType.ZLM:
|
||||
return new ZLMServer(config.ip, config.port, config.secret)
|
||||
default:
|
||||
throw new Error(`Unsupported media server type: ${config.type}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from '@/api/mediaserver/types'
|
||||
import { MediaServerType } from '@/api/mediaserver/types'
|
||||
import { BaseMediaServer } from '@/api/mediaserver/base'
|
||||
import axios from 'axios'
|
||||
|
||||
interface SRSVersionResponse {
|
||||
code: number
|
||||
server: string
|
||||
service: string
|
||||
pid: string
|
||||
data: {
|
||||
major: number
|
||||
minor: number
|
||||
revision: number
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SRSClientsResponse {
|
||||
code: number
|
||||
server: string
|
||||
service: string
|
||||
pid: string
|
||||
clients: {
|
||||
id: string
|
||||
vhost: string
|
||||
stream: string
|
||||
ip: string
|
||||
pageUrl: string
|
||||
swfUrl: string
|
||||
tcUrl: string
|
||||
url: string
|
||||
name: string
|
||||
type: string
|
||||
publish: boolean
|
||||
alive: number
|
||||
send_bytes: number
|
||||
recv_bytes: number
|
||||
kbps: {
|
||||
recv_30s: number
|
||||
send_30s: number
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
interface SRSStreamResponse {
|
||||
code: number
|
||||
server: string
|
||||
service: string
|
||||
pid: string
|
||||
streams: {
|
||||
id: string
|
||||
name: string
|
||||
vhost: string
|
||||
app: string
|
||||
tcUrl: string
|
||||
url: string
|
||||
live_ms: number
|
||||
clients: number
|
||||
frames: number
|
||||
send_bytes: number
|
||||
recv_bytes: number
|
||||
kbps: {
|
||||
recv_30s: number
|
||||
send_30s: number
|
||||
}
|
||||
publish: {
|
||||
active: boolean
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
codec: string
|
||||
profile: string
|
||||
level: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
audio?: {
|
||||
codec: string
|
||||
sample_rate: number
|
||||
channel: number
|
||||
profile: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
interface UserQuery {
|
||||
[key: string]: string | undefined
|
||||
schema?: string
|
||||
play?: string
|
||||
}
|
||||
|
||||
interface ParsedUrl {
|
||||
url: string
|
||||
schema: string
|
||||
server: string
|
||||
port: number
|
||||
vhost: string
|
||||
app: string
|
||||
stream: string
|
||||
user_query: UserQuery
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class SRSServer extends BaseMediaServer {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(host: string, port: number) {
|
||||
super(MediaServerType.SRS)
|
||||
this.baseUrl = `http://${host}:${port}`
|
||||
}
|
||||
|
||||
async getVersion(): Promise<VersionInfo> {
|
||||
try {
|
||||
const response = await axios.get<SRSVersionResponse>(`${this.baseUrl}/api/v1/versions`)
|
||||
|
||||
return {
|
||||
version: response.data.data.version,
|
||||
buildDate: undefined, // SRS API 没有提供构建日期
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get SRS version: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getStreamInfo(): Promise<StreamInfo[]> {
|
||||
try {
|
||||
const response = await axios.get<SRSStreamResponse>(`${this.baseUrl}/api/v1/streams/`)
|
||||
|
||||
return response.data.streams.map((stream) => ({
|
||||
id: stream.id,
|
||||
name: stream.name,
|
||||
vhost: stream.vhost,
|
||||
url: stream.tcUrl,
|
||||
clients: stream.clients - 1,
|
||||
active: stream.publish.active,
|
||||
send_bytes: stream.send_bytes,
|
||||
recv_bytes: stream.recv_bytes,
|
||||
video: stream.video
|
||||
? {
|
||||
codec: stream.video.codec,
|
||||
width: stream.video.width,
|
||||
height: stream.video.height,
|
||||
fps: 0, // SRS API 没有直接提供 fps 信息
|
||||
}
|
||||
: undefined,
|
||||
audio: stream.audio
|
||||
? {
|
||||
codec: stream.audio.codec,
|
||||
sampleRate: stream.audio.sample_rate,
|
||||
channels: stream.audio.channel,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get SRS streams info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
|
||||
try {
|
||||
const response = await axios.get<SRSClientsResponse>(`${this.baseUrl}/api/v1/clients/`)
|
||||
let clients = response.data.clients.filter((client) => !client.publish)
|
||||
|
||||
// 如果指定了 stream_id,则过滤出对应的流
|
||||
if (params?.stream_id) {
|
||||
clients = clients.filter(client => client.stream === params.stream_id)
|
||||
}
|
||||
|
||||
return clients.map((client) => {
|
||||
console.log('Client alive value:', client.alive, typeof client.alive)
|
||||
return {
|
||||
id: client.id,
|
||||
vhost: client.vhost,
|
||||
stream: client.stream,
|
||||
ip: client.ip,
|
||||
url: client.url,
|
||||
alive: Math.round(client.alive * 1000), // 转换为毫秒并四舍五入
|
||||
type: client.type,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get SRS clients info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async kickClient(clientId: string) {
|
||||
const response = await axios.post(`${this.baseUrl}/api/v1/clients/${clientId}/kick`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
createRtcPlayer(): RtcPlayer {
|
||||
const self = {
|
||||
pc: new RTCPeerConnection({
|
||||
iceServers: [],
|
||||
}),
|
||||
|
||||
async play(url: string) {
|
||||
const conf = this.__internal.prepareUrl(url)
|
||||
this.pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
this.pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
|
||||
const session = await fetch(conf.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api: conf.apiUrl,
|
||||
streamurl: conf.streamUrl,
|
||||
clientip: null,
|
||||
sdp: offer.sdp,
|
||||
}),
|
||||
}).then((res) => res.json())
|
||||
|
||||
if (session.code) {
|
||||
throw session
|
||||
}
|
||||
|
||||
await this.pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: 'answer', sdp: session.sdp }),
|
||||
)
|
||||
return session
|
||||
},
|
||||
|
||||
async close() {
|
||||
this.pc.close()
|
||||
},
|
||||
|
||||
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
||||
|
||||
__internal: {
|
||||
defaultPath: '/rtc/v1/play/',
|
||||
|
||||
prepareUrl(webrtcUrl: string) {
|
||||
const urlObject = this.parse(webrtcUrl) as ParsedUrl
|
||||
const schema = urlObject.user_query.schema
|
||||
? urlObject.user_query.schema + ':'
|
||||
: window.location.protocol
|
||||
|
||||
let port = urlObject.port || 1985
|
||||
if (schema === 'https:') {
|
||||
port = urlObject.port || 443
|
||||
}
|
||||
|
||||
let api = urlObject.user_query.play || this.defaultPath
|
||||
if (api.lastIndexOf('/') !== api.length - 1) {
|
||||
api += '/'
|
||||
}
|
||||
|
||||
let apiUrl = schema + '//' + urlObject.server + ':' + port + api
|
||||
for (const key in urlObject.user_query) {
|
||||
if (key !== 'api' && key !== 'play') {
|
||||
apiUrl += '&' + key + '=' + urlObject.user_query[key]
|
||||
}
|
||||
}
|
||||
apiUrl = apiUrl.replace(api + '&', api + '?')
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
streamUrl: urlObject.url,
|
||||
schema,
|
||||
urlObject,
|
||||
port,
|
||||
}
|
||||
},
|
||||
|
||||
parse(url: string): ParsedUrl {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
.replace('rtmp://', 'http://')
|
||||
.replace('webrtc://', 'http://')
|
||||
.replace('rtc://', 'http://')
|
||||
|
||||
let vhost = a.hostname
|
||||
let app = a.pathname.substring(1, a.pathname.lastIndexOf('/'))
|
||||
const stream = a.pathname.slice(a.pathname.lastIndexOf('/') + 1)
|
||||
|
||||
app = app.replace('...vhost...', '?vhost=')
|
||||
if (app.indexOf('?') >= 0) {
|
||||
const params = app.slice(app.indexOf('?'))
|
||||
app = app.slice(0, app.indexOf('?'))
|
||||
|
||||
if (params.indexOf('vhost=') > 0) {
|
||||
vhost = params.slice(params.indexOf('vhost=') + 'vhost='.length)
|
||||
if (vhost.indexOf('&') > 0) {
|
||||
vhost = vhost.slice(0, vhost.indexOf('&'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (a.hostname === vhost) {
|
||||
const re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
|
||||
if (re.test(a.hostname)) {
|
||||
vhost = '__defaultVhost__'
|
||||
}
|
||||
}
|
||||
|
||||
let schema = 'rtmp'
|
||||
if (url.indexOf('://') > 0) {
|
||||
schema = url.slice(0, url.indexOf('://'))
|
||||
}
|
||||
|
||||
let port = parseInt(a.port)
|
||||
if (!port) {
|
||||
if (schema === 'http') {
|
||||
port = 80
|
||||
} else if (schema === 'https') {
|
||||
port = 443
|
||||
} else if (schema === 'rtmp') {
|
||||
port = 1935
|
||||
}
|
||||
}
|
||||
|
||||
const ret: ParsedUrl = {
|
||||
url,
|
||||
schema,
|
||||
server: a.hostname,
|
||||
port,
|
||||
vhost,
|
||||
app,
|
||||
stream,
|
||||
user_query: {},
|
||||
}
|
||||
|
||||
this.fill_query(a.search, ret)
|
||||
return ret
|
||||
},
|
||||
|
||||
fill_query(query_string: string, obj: ParsedUrl) {
|
||||
if (query_string.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (query_string.indexOf('?') >= 0) {
|
||||
query_string = query_string.split('?')[1]
|
||||
}
|
||||
|
||||
const queries = query_string.split('&')
|
||||
for (const elem of queries) {
|
||||
const query = elem.split('=')
|
||||
obj.user_query[query[0]] = query[1]
|
||||
}
|
||||
|
||||
if (obj.user_query.domain) {
|
||||
obj.vhost = obj.user_query.domain
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
self.pc.ontrack = (event: RTCTrackEvent) => {
|
||||
if (self.ontrack) {
|
||||
self.ontrack(event)
|
||||
}
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 流媒体服务器类型枚举
|
||||
*/
|
||||
export enum MediaServerType {
|
||||
ZLM = 'zlm', // ZLMediaKit
|
||||
SRS = 'srs', // SRS
|
||||
CUSTOM = 'custom', // 自定义服务器
|
||||
}
|
||||
|
||||
export interface RtcPlayer {
|
||||
pc: RTCPeerConnection
|
||||
play(url: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
ontrack: ((event: RTCTrackEvent) => void) | null
|
||||
}
|
||||
|
||||
export interface MediaServer {
|
||||
id: number
|
||||
name: string
|
||||
ip: string
|
||||
port: number
|
||||
type: string
|
||||
username?: string
|
||||
password?: string
|
||||
secret?: string
|
||||
status: number
|
||||
created_at: string
|
||||
isDefault?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本信息接口
|
||||
*/
|
||||
export interface VersionInfo {
|
||||
version: string
|
||||
buildDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频编码信息
|
||||
*/
|
||||
export interface VideoCodecInfo {
|
||||
codec: string // 视频编码格式,如 H264, H265
|
||||
width: number // 视频宽度
|
||||
height: number // 视频高度
|
||||
fps: number // 帧率
|
||||
bitrate?: number // 比特率 (kbps)
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频编码信息
|
||||
*/
|
||||
export interface AudioCodecInfo {
|
||||
codec: string // 音频编码格式,如 AAC, G711
|
||||
sampleRate: number // 采样率
|
||||
channels: number // 声道数
|
||||
bitrate?: number // 比特率 (kbps)
|
||||
}
|
||||
|
||||
export interface StreamInfo {
|
||||
id: string
|
||||
name: string // 流名称
|
||||
vhost: string // 虚拟主机
|
||||
url: string // 流地址
|
||||
clients: number // 客户端连接数
|
||||
active: boolean // 是否活跃
|
||||
video?: VideoCodecInfo // 视频编码信息
|
||||
audio?: AudioCodecInfo // 音频编码信息
|
||||
send_bytes?: number // 已传输字节数
|
||||
recv_bytes?: number // 已接收字节数
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
id: string
|
||||
vhost: string
|
||||
stream: string
|
||||
ip: string
|
||||
url: string
|
||||
alive: number
|
||||
type: string
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { ClientInfo, StreamInfo, VersionInfo, MediaServer, RtcPlayer } from '@/api/mediaserver/types'
|
||||
import { MediaServerType } from '@/api/mediaserver/types'
|
||||
import { BaseMediaServer } from '@/api/mediaserver/base'
|
||||
import axios from 'axios'
|
||||
|
||||
// {
|
||||
// "code": 0,
|
||||
// "data": {
|
||||
// "branchName": "master",
|
||||
// "buildTime": "2023-04-19T10:34:34",
|
||||
// "commitHash": "f143898"
|
||||
// }
|
||||
// }
|
||||
interface ZLMVersionResponse {
|
||||
code: number
|
||||
data: {
|
||||
branchName: string
|
||||
buildTime: string
|
||||
commitHash: string
|
||||
}
|
||||
}
|
||||
|
||||
// {
|
||||
// "code" : 0,
|
||||
// "data" : [
|
||||
// {
|
||||
// "app" : "live", # 应用名
|
||||
// "readerCount" : 0, # 本协议观看人数
|
||||
// "totalReaderCount" : 0, # 观看总人数,包括hls/rtsp/rtmp/http-flv/ws-flv
|
||||
// "schema" : "rtsp", # 协议
|
||||
// "stream" : "obs", # 流id
|
||||
// "originSock": { # 客户端和服务器网络信息,可能为null类型
|
||||
// "identifier": "140241931428384",
|
||||
// "local_ip": "127.0.0.1",
|
||||
// "local_port": 1935,
|
||||
// "peer_ip": "127.0.0.1",
|
||||
// "peer_port": 50097
|
||||
// },
|
||||
// "originType": 1, # 产生源类型,包括 unknown = 0,rtmp_push=1,rtsp_push=2,rtp_push=3,pull=4,ffmpeg_pull=5,mp4_vod=6,device_chn=7
|
||||
// "originTypeStr": "MediaOriginType::rtmp_push",
|
||||
// "originUrl": "rtmp://127.0.0.1:1935/live/hks2", #产生源的url
|
||||
// "createStamp": 1602205811, #GMT unix系统时间戳,单位秒
|
||||
// "aliveSecond": 100, #存活时间,单位秒
|
||||
// "bytesSpeed": 12345, #数据产生速度,单位byte/s
|
||||
// "tracks" : [ # 音视频轨道
|
||||
// {
|
||||
// "channels" : 1, # 音频通道数
|
||||
// "codec_id" : 2, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
|
||||
// "codec_id_name" : "CodecAAC", # 编码类型名称
|
||||
// "codec_type" : 1, # Video = 0, Audio = 1
|
||||
// "ready" : true, # 轨道是否准备就绪
|
||||
// "frames" : 1119, #累计接收帧数
|
||||
// "sample_bit" : 16, # 音频采样位数
|
||||
// "sample_rate" : 8000 # 音频采样率
|
||||
// },
|
||||
// {
|
||||
// "codec_id" : 0, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
|
||||
// "codec_id_name" : "CodecH264", # 编码类型名称
|
||||
// "codec_type" : 0, # Video = 0, Audio = 1
|
||||
// "fps" : 59, # 视频fps
|
||||
// "frames" : 1119, #累计接收帧数,不包含sei/aud/sps/pps等不能解码的帧
|
||||
// "gop_interval_ms" : 1993, #gop间隔时间,单位毫秒
|
||||
// "gop_size" : 60, #gop大小,单位帧数
|
||||
// "key_frames" : 21, #累计接收关键帧数
|
||||
// "height" : 720, # 视频高
|
||||
// "ready" : true, # 轨道是否准备就绪
|
||||
// "width" : 1280 # 视频宽
|
||||
// }
|
||||
// ],
|
||||
// "vhost" : "__defaultVhost__" # 虚拟主机名
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
interface ZLMTrackInfo {
|
||||
channels?: number // 音频通道数
|
||||
codec_id: number // 编码器ID
|
||||
codec_id_name: string // 编码器名称
|
||||
codec_type: number // 编码类型 (0: Video, 1: Audio)
|
||||
ready: boolean // 轨道是否就绪
|
||||
frames: number // 累计接收帧数
|
||||
sample_bit?: number // 音频采样位数
|
||||
sample_rate?: number // 音频采样率
|
||||
// 视频特有属性
|
||||
fps?: number // 视频帧率
|
||||
width?: number // 视频宽度
|
||||
height?: number // 视频高度
|
||||
gop_interval_ms?: number // GOP间隔时间
|
||||
gop_size?: number // GOP大小
|
||||
key_frames?: number // 关键帧数
|
||||
bytesSpeed?: number // 数据速率
|
||||
}
|
||||
|
||||
interface ZLMStreamInfo {
|
||||
app: string
|
||||
readerCount: number
|
||||
totalReaderCount: number
|
||||
schema: string
|
||||
stream: string
|
||||
originSock: {
|
||||
identifier: string
|
||||
local_ip: string
|
||||
local_port: number
|
||||
peer_ip: string
|
||||
peer_port: number
|
||||
}
|
||||
originType: number
|
||||
originTypeStr: string
|
||||
originUrl: string
|
||||
createStamp: number
|
||||
aliveSecond: number
|
||||
bytesSpeed: number
|
||||
tracks: ZLMTrackInfo[]
|
||||
vhost: string
|
||||
}
|
||||
|
||||
interface ZLMStreamResponse {
|
||||
code: number
|
||||
data: ZLMStreamInfo[]
|
||||
}
|
||||
|
||||
// {
|
||||
// "code": 0,
|
||||
// "data": [
|
||||
// {
|
||||
// "identifier": "3-309",
|
||||
// "local_ip": "::",
|
||||
// "local_port": 8000,
|
||||
// "peer_ip": "172.18.190.159",
|
||||
// "peer_port": 52996,
|
||||
// "typeid": "mediakit::WebRtcSession"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
interface ZLMClientInfo {
|
||||
identifier: string
|
||||
local_ip: string
|
||||
local_port: number
|
||||
peer_ip: string
|
||||
peer_port: number
|
||||
typeid: string
|
||||
}
|
||||
|
||||
interface ZLMClientInfoResponse {
|
||||
code: number
|
||||
data: ZLMClientInfo[]
|
||||
}
|
||||
|
||||
interface ZLMRtcResponse {
|
||||
code: number
|
||||
id: string
|
||||
sdp: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export class ZLMServer extends BaseMediaServer {
|
||||
private baseUrl: string
|
||||
private secret: string
|
||||
|
||||
constructor(host: string, port: number, secret: string = '') {
|
||||
super(MediaServerType.ZLM)
|
||||
this.baseUrl = `http://${host}:${port}`
|
||||
this.secret = secret
|
||||
}
|
||||
|
||||
async getVersion(): Promise<VersionInfo> {
|
||||
try {
|
||||
const response = await axios.get<ZLMVersionResponse>(`${this.baseUrl}/index/api/version${this.secret ? '?secret=' + this.secret : ''}`)
|
||||
return {
|
||||
version: response.data.data.buildTime,
|
||||
buildDate: response.data.data.buildTime,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get ZLM version: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// /index/api/getMediaList?schema=rtsp&secret=
|
||||
async getStreamInfo(): Promise<StreamInfo[]> {
|
||||
try {
|
||||
const response = await axios.get<ZLMStreamResponse>(`${this.baseUrl}/index/api/getMediaList?schema=rtsp&secret=${this.secret}`)
|
||||
return response.data.data.map((stream) => {
|
||||
const videoTrack = stream.tracks.find((track) => track.codec_type === 0)
|
||||
const audioTrack = stream.tracks.find((track) => track.codec_type === 1)
|
||||
|
||||
return {
|
||||
id: stream.stream,
|
||||
name: stream.stream,
|
||||
vhost: stream.vhost,
|
||||
url: stream.originUrl,
|
||||
clients: stream.readerCount,
|
||||
active: stream.aliveSecond > 0,
|
||||
send_bytes: 0,
|
||||
recv_bytes: stream.bytesSpeed,
|
||||
video: videoTrack?.codec_type === 0 ? {
|
||||
codec: videoTrack.codec_id_name,
|
||||
width: videoTrack.width ?? 0,
|
||||
height: videoTrack.height ?? 0,
|
||||
fps: videoTrack.fps ?? 0,
|
||||
bitrate: videoTrack.bytesSpeed ?? 0,
|
||||
} : undefined,
|
||||
audio: audioTrack?.codec_type === 1 ? {
|
||||
codec: audioTrack.codec_id_name,
|
||||
sampleRate: audioTrack.sample_rate ?? 0,
|
||||
channels: audioTrack.channels ?? 0,
|
||||
bitrate: audioTrack.bytesSpeed ?? 0,
|
||||
} : undefined,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get ZLM streams info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// /index/api/getMediaPlayerList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&schema=rtsp&vhost=defaultVhost&app=live&stream=test
|
||||
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
|
||||
try {
|
||||
const streamParam = params?.stream_id ? `&stream=${params.stream_id}` : ''
|
||||
const response = await axios.get<ZLMClientInfoResponse>(
|
||||
`${this.baseUrl}/index/api/getMediaPlayerList?secret=${this.secret}&schema=rtsp&vhost=defaultVhost&app=rtp${streamParam}`
|
||||
)
|
||||
|
||||
return response.data.data.map((client) => ({
|
||||
id: client.identifier,
|
||||
vhost: '__defaultVhost__', // ZLM默认虚拟主机
|
||||
stream: 'null',
|
||||
ip: client.peer_ip,
|
||||
url: `${client.local_ip}:${client.local_port}`,
|
||||
alive: 1, // 在线状态
|
||||
type: client.typeid.replace('mediakit::', '') // 移除 mediakit:: 前缀
|
||||
}))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get ZLM clients info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
createRtcPlayer(): RtcPlayer {
|
||||
const self = {
|
||||
pc: new RTCPeerConnection({
|
||||
iceServers: [],
|
||||
}),
|
||||
|
||||
async play(url: string): Promise<void> {
|
||||
this.pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
this.pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
|
||||
const response = await axios.post<ZLMRtcResponse>(
|
||||
url,
|
||||
offer.sdp,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/plain;charset=utf-8'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error('创建WebRTC播放器失败')
|
||||
}
|
||||
|
||||
await this.pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: 'answer', sdp: response.data.sdp })
|
||||
)
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.pc.close()
|
||||
},
|
||||
|
||||
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
||||
}
|
||||
|
||||
self.pc.ontrack = (event: RTCTrackEvent) => {
|
||||
if (self.ontrack) {
|
||||
self.ontrack(event)
|
||||
}
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface InviteRequest {
|
||||
media_server_id: number
|
||||
device_id: string
|
||||
channel_id: string
|
||||
sub_stream: number
|
||||
play_type: number
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
export interface InviteResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ByeRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface PauseRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ResumeRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface SpeedRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
speed: number
|
||||
}
|
||||
|
||||
// 通道状态类型
|
||||
export type ChannelStatus = 'ON' | 'OFF'
|
||||
|
||||
// 通道信息类型
|
||||
export interface ChannelInfo {
|
||||
device_id: string
|
||||
parent_id: string
|
||||
name: string
|
||||
manufacturer: string
|
||||
model: string
|
||||
owner: string
|
||||
civil_code: string
|
||||
address: string
|
||||
port: number
|
||||
parental: number
|
||||
safety_way: number
|
||||
register_way: number
|
||||
secrecy: number
|
||||
ip_address: string
|
||||
status: ChannelStatus
|
||||
longitude: number
|
||||
latitude: number
|
||||
info: {
|
||||
ptz_type: number
|
||||
resolution: string
|
||||
download_speed: string
|
||||
}
|
||||
ssrc: string
|
||||
}
|
||||
|
||||
export interface RecordInfoRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
export interface RecordInfoResponse {
|
||||
device_id: string
|
||||
name: string
|
||||
file_path: string
|
||||
address: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
secrecy: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
device_id: string
|
||||
source_addr: string
|
||||
network_type: string
|
||||
status: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface PTZControlRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
ptz: string
|
||||
speed: string
|
||||
}
|
||||
|
||||
// 媒体服务器类型
|
||||
|
||||
Reference in New Issue
Block a user