Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/derived-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52251,6 +52251,8 @@ export interface paths {
read_user: string | null;
read_pass: string | null;
proxy: null | string;
/** @description True if the Media Server currently reports the lease path as ready */
active: boolean;
}[];
};
};
Expand Down
122 changes: 68 additions & 54 deletions api/stateless/lib/control/video-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,17 +221,7 @@ export default class VideoServiceControl {
if (!res.ok) throw new Err(500, null, await res.text());
const body = await res.typed(VideoConfig);

// TODO support paging
const urlPaths = new URL('/path', video.url);
urlPaths.port = '9997';

const resPaths = await fetch(urlPaths, {
headers: Object.fromEntries(headers.entries()),
safeUrlAllow: [new URL(video.url!).hostname],
});
if (!resPaths.ok) throw new Err(500, null, await resPaths.text());

const paths = await resPaths.typed(PathsList);
const paths = await this.paths();

// Special case for supporting internal Docker Compose network
let external = video.url;
Expand All @@ -244,10 +234,32 @@ export default class VideoServiceControl {
url: video.url,
external,
config: body,
paths: paths.items,
paths,
};
}

/**
* List all Paths currently known to the Media Server
*/
async paths(): Promise<Static<typeof PathListItem>[]> {
const video = await this.settings();
if (!video.configured) return [];

const headers = this.headers(video.token);

// TODO support paging
const url = new URL('/path', video.url);
url.port = '9997';

const res = await fetch(url, {
headers: Object.fromEntries(headers.entries()),
safeUrlAllow: [new URL(video.url!).hostname],
});
if (!res.ok) throw new Err(500, null, await res.text());

return (await res.typed(PathsList)).items;
}

async protocols(
lease: Static<typeof VideoLeaseResponse>,
populated = ProtocolPopulation.TEMPLATE,
Expand Down Expand Up @@ -323,29 +335,30 @@ export default class VideoServiceControl {
const url = new URL(c.external.replace(/^http(s)?:/, 'srt:'));
url.port = c.config.srtAddress.replace(':', '');

// MediaMTX streamid format: <read|publish>:<path>[:<user>:<pass>]
let streamid: string;
if (populated === ProtocolPopulation.READ) {
streamid = `read:${lease.path}`;
} else if (populated === ProtocolPopulation.WRITE) {
streamid = `publish:${lease.path}`;
} else {
streamid = `{{mode}}:${lease.path}`;
}

if (lease.stream_user && lease.read_user) {
if (populated === ProtocolPopulation.READ) {
protocols.srt = {
name: 'Secure Reliable Transport (SRT)',
url: String(url) + `?streamid={{mode}}:${lease.path}:${lease.read_user}}:${lease.read_pass}`,
};
streamid += `:${lease.read_user}:${lease.read_pass}`;
} else if (populated === ProtocolPopulation.WRITE) {
protocols.srt = {
name: 'Secure Reliable Transport (SRT)',
url: String(url) + `?streamid={{mode}}:${lease.path}:${lease.stream_user}}:${lease.stream_pass}`,
};
streamid += `:${lease.stream_user}:${lease.stream_pass}`;
} else {
protocols.srt = {
name: 'Secure Reliable Transport (SRT)',
url: String(url) + `?streamid={{mode}}:${lease.path}:{{username}}:{{password}}`,
};
streamid += ':{{username}}:{{password}}';
}
} else {
protocols.srt = {
name: 'Secure Reliable Transport (SRT)',
url: String(url) + `?streamid={{mode}}:${lease.path}`,
};
}

protocols.srt = {
name: 'Secure Reliable Transport (SRT)',
url: String(url) + `?streamid=${streamid}`,
};
}

if (c.config && c.config.hls) {
Expand Down Expand Up @@ -432,6 +445,16 @@ export default class VideoServiceControl {
return protocols;
}

/**
* Feed URL pushed to the TAK Server Video Manager - SRT is preferred
* for low latency with HLS as the fallback
*/
feedUrl(protocols: Static<typeof Protocols>): string {
const feed = protocols.srt || protocols.hls;
if (!feed) throw new Err(400, null, 'Media Server must support SRT or HLS to publish a video stream');
return feed.url;
}

async updateSecure(
lease: Static<typeof VideoLeaseResponse>,
secure: boolean,
Expand Down Expand Up @@ -527,24 +550,18 @@ export default class VideoServiceControl {
);

try {
const protocols = await this.protocols(lease, ProtocolPopulation.READ);

if (protocols.hls) {
await api.Video.create({
await api.Video.create({
uuid: lease.path,
active: true,
alias: lease.name,
groups: [lease.channel!],
feeds: [{
uuid: lease.path,
active: true,
alias: lease.name,
groups: [lease.channel!],
feeds: [{
uuid: lease.path,
active: true,
alias: lease.name,
url: protocols.hls.url,
}],
});
} else {
throw new Err(400, null, 'Only HLS shared video streams are supported at this time');
}
url: this.feedUrl(await this.protocols(lease, ProtocolPopulation.READ)),
}],
});
} catch (err) {
console.error(err);
}
Expand Down Expand Up @@ -734,17 +751,16 @@ export default class VideoServiceControl {
new APIAuthCertificate(auth.cert, auth.key),
);

// Remove any existing connection - covers publish being toggled off
// and channel changes, which the TAK Server Video API cannot apply in place
try {
await api.Video.delete(lease.path);
} catch (err) {
console.error(err);
}

// We can't change channels so just delete and recreate
try {
const protocols = await this.protocols(lease, ProtocolPopulation.READ);

if (protocols.hls) {
if (lease.publish) {
try {
await api.Video.create({
uuid: lease.path,
active: true,
Expand All @@ -754,14 +770,12 @@ export default class VideoServiceControl {
uuid: lease.path,
active: true,
alias: lease.name,
url: protocols.hls.url,
url: this.feedUrl(await this.protocols(lease, ProtocolPopulation.READ)),
}],
});
} else {
throw new Err(400, null, 'Only HLS shared video streams are supported at this time');
} catch (err) {
console.error(err);
}
} catch (err) {
console.error(err);
}
} catch (err) {
console.error(err);
Expand Down
3 changes: 2 additions & 1 deletion api/stateless/routes/profile-videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export default async function router(schema: Schema, config: ConfigStateless) {
}

const media = await videoControl.url();
const uuid = requested.pathname.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
// The lease path lives in the URL path for HLS/WebRTC/RTSP and in the streamid query for SRT
const uuid = req.body.url.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);

if (media && media.hostname === requested.hostname && uuid && uuid[0]) {
try {
Expand Down
43 changes: 37 additions & 6 deletions api/stateless/routes/video-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ export default async function router(schema: Schema, config: ConfigStateless) {
const requested = new URL(req.query.url);

const url = await videoControl.url();
const uuid = requested.pathname.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
// The lease path lives in the URL path for HLS/WebRTC/RTSP and in the streamid query for SRT
const uuid = req.query.url.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);

if (!url) {
res.json({
Expand Down Expand Up @@ -225,7 +226,9 @@ export default async function router(schema: Schema, config: ConfigStateless) {
}),
res: Type.Object({
total: Type.Integer(),
items: Type.Array(VideoLeaseResponse),
items: Type.Array(Type.Composite([VideoLeaseResponse, Type.Object({
active: Type.Boolean({ description: 'True if the Media Server currently reports the lease path as ready' }),
})])),
}),
}, async (req, res) => {
try {
Expand All @@ -239,7 +242,7 @@ export default async function router(schema: Schema, config: ConfigStateless) {
if (req.query.impersonate && (auth instanceof AuthResource || (auth instanceof AuthUser && (auth as AuthUser).is_admin()))) {
const impersonate: string | null = req.query.impersonate === true ? null : req.query.impersonate;

res.json(await config.models.VideoLease.list({
res.json(await withActive(await config.models.VideoLease.list({
limit: req.query.limit,
page: req.query.page,
order: req.query.order,
Expand All @@ -254,7 +257,7 @@ export default async function router(schema: Schema, config: ConfigStateless) {
)
AND (${impersonate}::TEXT IS NULL OR username = ${impersonate}::TEXT)
`,
}));
})));
} else {
const user = await Auth.as_user(config, req);

Expand All @@ -264,7 +267,7 @@ export default async function router(schema: Schema, config: ConfigStateless) {
const groups = (await api.Group.list({ useCache: true }))
.data.map(group => group.name);

res.json(await config.models.VideoLease.list({
res.json(await withActive(await config.models.VideoLease.list({
limit: req.query.limit,
page: req.query.page,
order: req.query.order,
Expand All @@ -279,13 +282,41 @@ export default async function router(schema: Schema, config: ConfigStateless) {
OR (${expired}::BOOLEAN IS False AND expiration > Now())
)
`,
}));
})));
}
} catch (err) {
Err.respond(err, res);
}
});

/**
* Annotate a page of leases with whether the Media Server currently reports the path as ready
*/
async function withActive(list: {
total: number;
items: Static<typeof VideoLeaseResponse>[];
}): Promise<{
total: number;
items: (Static<typeof VideoLeaseResponse> & { active: boolean })[];
}> {
const ready = new Set<string>();

if (list.items.length) {
try {
for (const path of await videoControl.paths()) {
if (path.ready) ready.add(path.name);
}
} catch (err) {
console.error(err);
}
}

return {
total: list.total,
items: list.items.map(lease => ({ ...lease, active: ready.has(lease.path) })),
};
}

await schema.get('/video/lease/:lease', {
name: 'Get Lease',
group: 'VideoLease',
Expand Down
Loading
Loading