Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Video In Browser #339

Merged
merged 19 commits into from
Sep 16, 2024
Merged
184 changes: 171 additions & 13 deletions api/lib/control/video-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ import { Type, Static } from '@sinclair/typebox';
import { VideoLeaseResponse } from '../types.js';
import fetch from '../fetch.js';

export const Protocols = Type.Object({
rtmp: Type.Optional(Type.Object({
name: Type.String(),
url: Type.String()
})),
rtsp: Type.Optional(Type.Object({
name: Type.String(),
url: Type.String()
})),
webrtc: Type.Optional(Type.Object({
name: Type.String(),
url: Type.String()
})),
hls: Type.Optional(Type.Object({
name: Type.String(),
url: Type.String()
})),
srt: Type.Optional(Type.Object({
name: Type.String(),
url: Type.String()
}))
})

export const VideoConfigUpdate = Type.Object({
api: Type.Optional(Type.Boolean()),
metrics: Type.Optional(Type.Boolean()),
pprof: Type.Optional(Type.Boolean()),
playback: Type.Optional(Type.Boolean()),
rtsp: Type.Optional(Type.Boolean()),
rtmp: Type.Optional(Type.Boolean()),
hls: Type.Optional(Type.Boolean()),
webrtc: Type.Optional(Type.Boolean()),
srt: Type.Optional(Type.Boolean()),
})

export const VideoConfig = Type.Object({
api: Type.Boolean(),
apiAddress: Type.String(),
Expand Down Expand Up @@ -37,6 +72,18 @@ export const VideoConfig = Type.Object({
})

export const PathConfig = Type.Object({
name: Type.String(),
source: Type.String(),
sourceFingerprint: Type.String(),
sourceOnDemand: Type.Boolean(),
sourceOnDemandStartTimeout: Type.String(),
sourceOnDemandCloseAfter: Type.String(),
maxReaders: Type.Integer(),

record: Type.Boolean(),
});

export const PathListConfig = Type.Object({
name: Type.String(),
confName: Type.String(),
source: Type.Union([
Expand All @@ -57,17 +104,17 @@ export const PathConfig = Type.Object({
}))
})

export const PathsConfig = Type.Object({
export const PathsListConfig = Type.Object({
pageCount: Type.Integer(),
itemCount: Type.Integer(),
items: Type.Array(PathConfig)
items: Type.Array(PathListConfig)
})

export const Configuration = Type.Object({
configured: Type.Boolean(),
url: Type.Optional(Type.String()),
config: Type.Optional(VideoConfig),
paths: Type.Optional(Type.Array(PathConfig))
paths: Type.Optional(Type.Array(PathListConfig))
});

export default class VideoServiceControl {
Expand Down Expand Up @@ -112,9 +159,31 @@ export default class VideoServiceControl {
if (username && password) {
headers.append('Authorization', `Basic ${Buffer.from(username + ':' + password).toString('base64')}`);
}

return headers;
}

async configure(config: Static<typeof VideoConfigUpdate>): Promise<Static<typeof Configuration>> {
const video = await this.settings();
if (!video.configured) return video;

const headers = this.headers(video.username, video.password);
headers.append('Content-Type', 'application/json');

const url = new URL('/v3/config/global/patch', video.url);
url.port = '9997';

const res = await fetch(url, {
method: 'PATCH',
headers,
body: JSON.stringify(config)
});

if (!res.ok) throw new Err(500, null, await res.text())

return this.configuration();
}

async configuration(): Promise<Static<typeof Configuration>> {
const video = await this.settings();

Expand All @@ -135,7 +204,7 @@ export default class VideoServiceControl {
const resPaths = await fetch(urlPaths, { headers })
if (!resPaths.ok) throw new Err(500, null, await resPaths.text())

const paths = await resPaths.typed(PathsConfig);
const paths = await resPaths.typed(PathsListConfig);

return {
configured: video.configured,
Expand All @@ -145,11 +214,68 @@ export default class VideoServiceControl {
};
}

async protocols(lease: Static<typeof VideoLeaseResponse>): Promise<Static<typeof Protocols>> {
const protocols: Static<typeof Protocols> = {};
const c = await this.configuration();

if (!c.configured || !c.url) return protocols;

if (c.config && c.config.rtsp) {
// Format: rtsp://localhost:8554/mystream
const url = new URL(`/${lease.path}`, c.url.replace(/^http(s)?:/, 'rtsp:'))
url.port = c.config.rtspAddress.replace(':', '');

protocols.rtsp = {
name: 'Real-Time Streaming Protocol (RTSP)',
url: String(url)
}
}

if (c.config && c.config.rtmp) {
// Format: rtmp://localhost/mystream
const url = new URL(`/${lease.path}`, c.url.replace(/^http(s)?:/, 'rtmp:'))
url.port = '';

if (lease.stream_user) url.searchParams.append('user', lease.stream_user);
if (lease.stream_pass) url.searchParams.append('pass', lease.stream_pass);

protocols.rtmp = {
name: 'Real-Time Messaging Protocol (RTMP)',
url: String(url)
}
}

if (c.config && c.config.hls) {
// Format: http://localhost:8888/mystream/index.m3u8
const url = new URL(`/${lease.path}/index.m3u8`, c.url);
url.port = c.config.hlsAddress.replace(':', '');

protocols.hls = {
name: 'HTTP Live Streaming (HLS)',
url: String(url)
}
}

if (c.config && c.config.webrtc) {
// Format: http://localhost:8889/mystream
const url = new URL(`/${lease.path}`, c.url);
url.port = c.config.webrtcAddress.replace(':', '');

protocols.webrtc = {
name: 'Web Real-Time Communication (WebRTC)',
url: String(url)
}
}

return protocols;
}

async generate(opts: {
name: string;
expiration: string;
path: string;
username: string;
proxy?: string;
}): Promise<Static<typeof VideoLeaseResponse>> {
const video = await this.settings();

Expand All @@ -161,25 +287,57 @@ export default class VideoServiceControl {
name: opts.name,
expiration: opts.expiration,
path: opts.path,
username: opts.username
username: opts.username,
proxy: opts.proxy
});

const url = new URL(`/v3/config/paths/add/${lease.path}`, video.url);
url.port = '9997';

headers.append('Content-Type', 'application/json');

if (lease.proxy) {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
name: opts.path,
source: opts.proxy,
sourceOnDemand: true
}),
})

if (!res.ok) throw new Err(500, null, await res.text())
} else {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
name: opts.path
}),
})

if (!res.ok) throw new Err(500, null, await res.text())
}

return lease;
}

async path(pathid: string): Promise<Static<typeof PathConfig>> {
const video = await this.settings();
if (!video.configured) throw new Err(400, null, 'Media Integration is not configured');

const headers = this.headers(video.username, video.password);

const url = new URL(`/v3/config/paths/get/${pathid}`, video.url);
url.port = '9997';

const res = await fetch(url, {
method: 'POST',
method: 'GET',
headers,
body: JSON.stringify({
name: opts.path
}),
})

if (!res.ok) throw new Err(500, null, await res.text())
});

return lease;
return await res.typed(PathConfig);
}

async delete(leaseid: string): Promise<void> {
Expand Down
6 changes: 5 additions & 1 deletion api/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,14 @@ export const VideoLease = pgTable('video_lease', {
created: timestamp('created', { withTimezone: true, mode: 'string' }).notNull().default(sql`Now()`),
updated: timestamp('updated', { withTimezone: true, mode: 'string' }).notNull().default(sql`Now()`),
username: text('username').notNull().references(() => Profile.username),

expiration: timestamp('expiration', { withTimezone: true, mode: 'string' }).notNull().default(sql`Now() + INTERVAL 1 HOUR;`),
path: text('path').notNull(),
stream_user: text('stream_user'),
stream_pass: text('stream_pass')
stream_pass: text('stream_pass'),

// Optional Proxy Mode
proxy: text('proxy'),
});

export const ProfileFeature = pgTable('profile_features', {
Expand Down
2 changes: 2 additions & 0 deletions api/migrations/0060_clever_wildside.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "profile" ALTER COLUMN "display_text" SET DEFAULT 'Medium';--> statement-breakpoint
ALTER TABLE "video_lease" ADD COLUMN "proxy" text;
Loading
Loading