mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat(cloud-cameras): add EZVIZ, Reolink, Eagle Eye providers
EZVIZ: consumer Hikvision cameras via Open Platform API (appKey/appSecret). Reolink: local HTTP API + RTSP (no cloud API available). Eagle Eye Networks: OAuth2 cloud VMS with HLS relay URLs.
This commit is contained in:
parent
0479cb7b4b
commit
0bb8fb68c9
5 changed files with 407 additions and 3 deletions
125
server/src/shared/cloud-cameras/eagle-eye.ts
Normal file
125
server/src/shared/cloud-cameras/eagle-eye.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* Eagle Eye Networks cloud camera integration.
|
||||||
|
*
|
||||||
|
* Auth: OAuth2 client_credentials → access_token.
|
||||||
|
* API base: https://api.eagleeyenetworks.com/api/v3.0
|
||||||
|
* Camera list: GET /cameras (paginated).
|
||||||
|
* Live view: GET /cameras/{id}/streams → RTSP/HLS URL.
|
||||||
|
*
|
||||||
|
* Eagle Eye is a true cloud VMS — cameras stream to their cloud,
|
||||||
|
* we pull relay URLs. All auth on server.
|
||||||
|
*/
|
||||||
|
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
|
||||||
|
|
||||||
|
const API_BASE = "https://api.eagleeyenetworks.com/api/v3.0";
|
||||||
|
const AUTH_URL = "https://auth.eagleeyenetworks.com/oauth2/token";
|
||||||
|
|
||||||
|
export class EagleEyeProvider implements CloudCameraProvider {
|
||||||
|
vendor: CloudVendor = "eagle_eye";
|
||||||
|
|
||||||
|
credentialFields() {
|
||||||
|
return [
|
||||||
|
{ name: "client_id", label: "Client ID", type: "text" as const, required: true },
|
||||||
|
{ name: "client_secret", label: "Client Secret", type: "password" as const, required: true },
|
||||||
|
{ name: "api_key", label: "API Key", type: "text" as const, required: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const token = await this.getToken(creds);
|
||||||
|
return token ? { ok: true } : { ok: false, error: "Auth failed" };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
|
||||||
|
const token = await this.getToken(creds);
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
|
const cameras: CloudCamera[] = [];
|
||||||
|
let nextPageToken: string | null = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ pageSize: "100" });
|
||||||
|
if (nextPageToken) params.set("pageToken", nextPageToken);
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE}/cameras?${params}`, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) break;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
|
||||||
|
for (const cam of data.results ?? []) {
|
||||||
|
cameras.push({
|
||||||
|
vendor_id: cam.id,
|
||||||
|
name: cam.name ?? `Eagle Eye ${cam.id}`,
|
||||||
|
model: cam.settings?.camera_info?.model ?? null,
|
||||||
|
rtsp_url: null,
|
||||||
|
relay_url: null,
|
||||||
|
online: cam.status === "online",
|
||||||
|
stream_type: "hls",
|
||||||
|
extra: {
|
||||||
|
account_id: cam.accountId ?? null,
|
||||||
|
timezone: cam.timezone ?? null,
|
||||||
|
tags: cam.tags ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPageToken = data.nextPageToken ?? null;
|
||||||
|
} while (nextPageToken);
|
||||||
|
|
||||||
|
return cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
|
||||||
|
const token = await this.getToken(creds);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/cameras/${vendorCameraId}/streams`, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
return data.hlsUrl ?? data.rtspUrl ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getToken(creds: Record<string, string>): Promise<string | null> {
|
||||||
|
const clientId = creds["client_id"];
|
||||||
|
const clientSecret = creds["client_secret"];
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(AUTH_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
return data.access_token ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
server/src/shared/cloud-cameras/ezviz.ts
Normal file
144
server/src/shared/cloud-cameras/ezviz.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/**
|
||||||
|
* EZVIZ Open Platform (consumer Hikvision cameras).
|
||||||
|
*
|
||||||
|
* Auth: appKey + appSecret → accessToken + areaDomain.
|
||||||
|
* Camera list: POST {areaDomain}/api/lapp/camera/list (paginated).
|
||||||
|
* Live view: POST {areaDomain}/api/lapp/live/video/list → HLS URLs.
|
||||||
|
*
|
||||||
|
* Initial auth endpoint: https://open.ezvizlife.com/api/lapp/token/get
|
||||||
|
* Subsequent calls use areaDomain from token response.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Streams encrypted by default (device verification code = key)
|
||||||
|
* - Supports HLS, RTMP, FLV
|
||||||
|
* - OAuth-based accounts (Google/FB) NOT supported — email/password EZVIZ only
|
||||||
|
* - Two-step verification must be disabled
|
||||||
|
*/
|
||||||
|
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
|
||||||
|
|
||||||
|
const AUTH_BASE = "https://open.ezvizlife.com";
|
||||||
|
|
||||||
|
export class EzvizProvider implements CloudCameraProvider {
|
||||||
|
vendor: CloudVendor = "ezviz";
|
||||||
|
|
||||||
|
credentialFields() {
|
||||||
|
return [
|
||||||
|
{ name: "app_key", label: "App Key", type: "text" as const, required: true },
|
||||||
|
{ name: "app_secret", label: "App Secret", type: "password" as const, required: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const auth = await this.getToken(creds);
|
||||||
|
return auth ? { ok: true } : { ok: false, error: "Token request failed" };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
|
||||||
|
const auth = await this.getToken(creds);
|
||||||
|
if (!auth) return [];
|
||||||
|
|
||||||
|
const cameras: CloudCamera[] = [];
|
||||||
|
let pageStart = 0;
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const resp = await fetch(`${auth.areaDomain}/api/lapp/camera/list`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
accessToken: auth.accessToken,
|
||||||
|
pageStart: String(pageStart),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) break;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.code !== "200" && data.code !== 200) break;
|
||||||
|
|
||||||
|
const list = data.data ?? [];
|
||||||
|
if (list.length === 0) break;
|
||||||
|
|
||||||
|
for (const cam of list) {
|
||||||
|
cameras.push({
|
||||||
|
vendor_id: `${cam.deviceSerial}:${cam.channelNo ?? 1}`,
|
||||||
|
name: cam.deviceName ?? cam.channelName ?? `EZVIZ ${cam.deviceSerial}`,
|
||||||
|
model: cam.deviceType ?? null,
|
||||||
|
rtsp_url: null,
|
||||||
|
relay_url: null,
|
||||||
|
online: cam.status === 1 || cam.status === "1",
|
||||||
|
stream_type: "hls",
|
||||||
|
extra: {
|
||||||
|
device_serial: cam.deviceSerial,
|
||||||
|
channel_no: cam.channelNo ?? 1,
|
||||||
|
is_encrypt: cam.isEncrypt ?? 0,
|
||||||
|
pic_url: cam.picUrl ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length < pageSize) break;
|
||||||
|
pageStart += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreamUrl(creds: Record<string, string>, vendorCameraId: string): Promise<string | null> {
|
||||||
|
const auth = await this.getToken(creds);
|
||||||
|
if (!auth) return null;
|
||||||
|
|
||||||
|
const [deviceSerial, channelNo] = vendorCameraId.split(":");
|
||||||
|
|
||||||
|
const resp = await fetch(`${auth.areaDomain}/api/lapp/v2/live/address/get`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
accessToken: auth.accessToken,
|
||||||
|
deviceSerial: deviceSerial!,
|
||||||
|
channelNo: channelNo ?? "1",
|
||||||
|
protocol: "2", // 1=ezopen, 2=HLS, 3=RTMP, 4=FLV
|
||||||
|
quality: "1", // 1=HD, 2=SD
|
||||||
|
expireTime: "86400",
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if ((data.code !== "200" && data.code !== 200) || !data.data?.url) return null;
|
||||||
|
return data.data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getToken(creds: Record<string, string>): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
areaDomain: string;
|
||||||
|
} | null> {
|
||||||
|
const appKey = creds["app_key"];
|
||||||
|
const appSecret = creds["app_secret"];
|
||||||
|
if (!appKey || !appSecret) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${AUTH_BASE}/api/lapp/token/get`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ appKey, appSecret }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if ((data.code !== "200" && data.code !== 200) || !data.data?.accessToken) return null;
|
||||||
|
return {
|
||||||
|
accessToken: data.data.accessToken,
|
||||||
|
areaDomain: data.data.areaDomain ?? AUTH_BASE,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,19 @@ export { type CloudAccount, type CloudCamera, type CloudVendor, type CloudCamera
|
||||||
|
|
||||||
import { registerProvider } from "./types.js";
|
import { registerProvider } from "./types.js";
|
||||||
import { HikConnectProvider } from "./hikconnect.js";
|
import { HikConnectProvider } from "./hikconnect.js";
|
||||||
|
import { EzvizProvider } from "./ezviz.js";
|
||||||
import { DahuaProvider } from "./dahua.js";
|
import { DahuaProvider } from "./dahua.js";
|
||||||
import { TuyaProvider } from "./tuya.js";
|
import { TuyaProvider } from "./tuya.js";
|
||||||
import { UniviewProvider } from "./uniview.js";
|
import { UniviewProvider } from "./uniview.js";
|
||||||
import { TpLinkProvider } from "./tplink.js";
|
import { TpLinkProvider } from "./tplink.js";
|
||||||
|
import { ReolinkProvider } from "./reolink.js";
|
||||||
|
import { EagleEyeProvider } from "./eagle-eye.js";
|
||||||
|
|
||||||
registerProvider(new HikConnectProvider());
|
registerProvider(new HikConnectProvider());
|
||||||
|
registerProvider(new EzvizProvider());
|
||||||
registerProvider(new DahuaProvider());
|
registerProvider(new DahuaProvider());
|
||||||
registerProvider(new TuyaProvider());
|
registerProvider(new TuyaProvider());
|
||||||
registerProvider(new UniviewProvider());
|
registerProvider(new UniviewProvider());
|
||||||
registerProvider(new TpLinkProvider());
|
registerProvider(new TpLinkProvider());
|
||||||
|
registerProvider(new ReolinkProvider());
|
||||||
|
registerProvider(new EagleEyeProvider());
|
||||||
|
|
|
||||||
126
server/src/shared/cloud-cameras/reolink.ts
Normal file
126
server/src/shared/cloud-cameras/reolink.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Reolink camera integration.
|
||||||
|
*
|
||||||
|
* Reolink cameras support local HTTP API + RTSP. No public cloud API
|
||||||
|
* for third-party streaming. Cameras expose:
|
||||||
|
* - RTSP: rtsp://admin:pass@ip/h264Preview_01_main (main)
|
||||||
|
* rtsp://admin:pass@ip/h264Preview_01_sub (sub)
|
||||||
|
* - HTTP API on port 80/443 for device info, PTZ, etc.
|
||||||
|
*
|
||||||
|
* This provider uses the local HTTP API for discovery + RTSP for streams.
|
||||||
|
*/
|
||||||
|
import type { CloudCameraProvider, CloudCamera, CloudVendor } from "./types.js";
|
||||||
|
|
||||||
|
export class ReolinkProvider implements CloudCameraProvider {
|
||||||
|
vendor: CloudVendor = "reolink";
|
||||||
|
|
||||||
|
credentialFields() {
|
||||||
|
return [
|
||||||
|
{ name: "host", label: "Camera IP/Host", type: "text" as const, required: true },
|
||||||
|
{ name: "username", label: "Username", type: "text" as const, required: true },
|
||||||
|
{ name: "password", label: "Password", type: "password" as const, required: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async testCredentials(creds: Record<string, string>): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const host = creds["host"];
|
||||||
|
const username = creds["username"];
|
||||||
|
const password = creds["password"];
|
||||||
|
if (!host || !username || !password) return { ok: false, error: "Missing credentials" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`http://${host}/api.cgi?cmd=Login`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify([{
|
||||||
|
cmd: "Login",
|
||||||
|
param: { User: { userName: username, password } },
|
||||||
|
}]),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
const result = data?.[0];
|
||||||
|
if (result?.code === 0 || result?.value?.Token) return { ok: true };
|
||||||
|
return { ok: false, error: result?.error?.detail ?? "Login failed" };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCameras(creds: Record<string, string>): Promise<CloudCamera[]> {
|
||||||
|
const host = creds["host"];
|
||||||
|
const username = creds["username"];
|
||||||
|
const password = creds["password"];
|
||||||
|
if (!host || !username || !password) return [];
|
||||||
|
|
||||||
|
let name = `Reolink (${host})`;
|
||||||
|
let model: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this.login(host, username, password);
|
||||||
|
if (token) {
|
||||||
|
const info = await this.getDevInfo(host, token);
|
||||||
|
if (info) {
|
||||||
|
name = info.name || name;
|
||||||
|
model = info.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fallback to defaults */ }
|
||||||
|
|
||||||
|
const mainUrl = `rtsp://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}/h264Preview_01_main`;
|
||||||
|
|
||||||
|
return [{
|
||||||
|
vendor_id: host,
|
||||||
|
name,
|
||||||
|
model,
|
||||||
|
rtsp_url: mainUrl,
|
||||||
|
relay_url: null,
|
||||||
|
online: true,
|
||||||
|
stream_type: "rtsp",
|
||||||
|
extra: {
|
||||||
|
sub_url: `rtsp://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}/h264Preview_01_sub`,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreamUrl(creds: Record<string, string>, _vendorCameraId: string): Promise<string | null> {
|
||||||
|
const host = creds["host"];
|
||||||
|
const username = creds["username"];
|
||||||
|
const password = creds["password"];
|
||||||
|
if (!host || !username || !password) return null;
|
||||||
|
return `rtsp://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}/h264Preview_01_main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(host: string, username: string, password: string): Promise<string | null> {
|
||||||
|
const resp = await fetch(`http://${host}/api.cgi?cmd=Login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify([{
|
||||||
|
cmd: "Login",
|
||||||
|
param: { User: { userName: username, password } },
|
||||||
|
}]),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
return data?.[0]?.value?.Token?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDevInfo(host: string, token: string): Promise<{ name: string; model: string } | null> {
|
||||||
|
const resp = await fetch(`http://${host}/api.cgi?cmd=GetDevInfo&token=${token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify([{ cmd: "GetDevInfo", action: 0, param: {} }]),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
const info = data?.[0]?.value?.DevInfo;
|
||||||
|
if (!info) return null;
|
||||||
|
return { name: info.name ?? "", model: info.model ?? "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,18 +18,21 @@ export interface CloudAccount {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink";
|
export type CloudVendor = "hikconnect" | "ezviz" | "dahua" | "tuya" | "uniview" | "tplink" | "reolink" | "eagle_eye";
|
||||||
|
|
||||||
export const CLOUD_VENDORS: readonly CloudVendor[] = [
|
export const CLOUD_VENDORS: readonly CloudVendor[] = [
|
||||||
"hikconnect", "dahua", "tuya", "uniview", "tplink",
|
"hikconnect", "ezviz", "dahua", "tuya", "uniview", "tplink", "reolink", "eagle_eye",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const VENDOR_LABELS: Record<CloudVendor, string> = {
|
export const VENDOR_LABELS: Record<CloudVendor, string> = {
|
||||||
hikconnect: "Hik-Connect (Hikvision)",
|
hikconnect: "Hik-Connect for Teams",
|
||||||
|
ezviz: "EZVIZ (Hikvision Consumer)",
|
||||||
dahua: "Dahua DMSS",
|
dahua: "Dahua DMSS",
|
||||||
tuya: "Tuya IoT",
|
tuya: "Tuya IoT",
|
||||||
uniview: "Uniview Cloud",
|
uniview: "Uniview Cloud",
|
||||||
tplink: "TP-Link (Tapo/VIGI)",
|
tplink: "TP-Link (Tapo/VIGI)",
|
||||||
|
reolink: "Reolink",
|
||||||
|
eagle_eye: "Eagle Eye Networks",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null;
|
export type CloudStreamType = "rtsp" | "hls" | "rtmp" | null;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue