Skip to content

Commit f11eec0

Browse files
authored
VIDCS-3699: Implement server-side controls of captions (#161)
1 parent 560a8a0 commit f11eec0

10 files changed

+442
-22
lines changed

backend/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"express": "^4.21.2",
2626
"form-data": "^4.0.1",
2727
"opentok": "^2.16.0",
28-
"tsx": "^4.10.5"
28+
"tsx": "^4.10.5",
29+
"opentok-jwt": "^0.1.5"
2930
},
3031
"devDependencies": {
3132
"@jest/globals": "^29.7.0",

backend/routes/session.ts

+38
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,42 @@ sessionRouter.get('/:room/archives', async (req: Request<{ room: string }>, res:
8484
}
8585
});
8686

87+
sessionRouter.post(
88+
'/:room/enableCaptions',
89+
async (req: Request<{ room: string }>, res: Response) => {
90+
try {
91+
const { room: roomName } = req.params;
92+
const sessionId = await sessionService.getSession(roomName);
93+
if (sessionId) {
94+
const captions = await videoService.enableCaptions(sessionId);
95+
res.json({
96+
captions,
97+
status: 200,
98+
});
99+
} else {
100+
res.status(404).json({ message: 'Room not found' });
101+
}
102+
} catch (error: unknown) {
103+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
104+
res.status(500).json({ message: errorMessage });
105+
}
106+
}
107+
);
108+
109+
sessionRouter.post(
110+
'/:room/:captionId/disableCaptions',
111+
async (req: Request<{ room: string; captionId: string }>, res: Response) => {
112+
try {
113+
const { captionId } = req.params;
114+
const responseCaptionId = await videoService.disableCaptions(captionId);
115+
res.json({
116+
captionId: responseCaptionId,
117+
status: 200,
118+
});
119+
} catch (error: unknown) {
120+
res.status(500).send({ message: (error as Error).message ?? error });
121+
}
122+
}
123+
);
124+
87125
export default sessionRouter;

backend/tests/session.test.ts

+59-19
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ await jest.unstable_mockModule('../videoService/opentokVideoService.ts', () => {
2222
return {
2323
startArchive: jest.fn<() => Promise<string>>().mockResolvedValue('archiveId'),
2424
stopArchive: jest.fn<() => Promise<string>>().mockRejectedValue('invalid archive'),
25+
enableCaptions: jest.fn<() => Promise<string>>().mockResolvedValue('captionId'),
26+
disableCaptions: jest.fn<() => Promise<string>>().mockResolvedValue('invalid caption'),
2527
generateToken: jest
2628
.fn<() => Promise<{ token: string; apiKey: string }>>()
2729
.mockResolvedValue({
@@ -70,28 +72,66 @@ describe.each([
7072
});
7173

7274
describe('POST requests', () => {
73-
it('returns a 200 when starting an archive in a room', async () => {
74-
const res = await request(server)
75-
.post(`/session/${roomName}/startArchive`)
76-
.set('Content-Type', 'application/json');
77-
expect(res.statusCode).toEqual(200);
78-
});
75+
describe('archiving', () => {
76+
it('returns a 200 when starting an archive in a room', async () => {
77+
const res = await request(server)
78+
.post(`/session/${roomName}/startArchive`)
79+
.set('Content-Type', 'application/json');
80+
expect(res.statusCode).toEqual(200);
81+
});
7982

80-
it('returns a 404 when starting an archive in a non-existent room', async () => {
81-
const invalidRoomName = 'nonExistingRoomName';
82-
const res = await request(server)
83-
.post(`/session/${invalidRoomName}/startArchive`)
84-
.set('Content-Type', 'application/json');
85-
expect(res.statusCode).toEqual(404);
83+
it('returns a 404 when starting an archive in a non-existent room', async () => {
84+
const invalidRoomName = 'nonExistingRoomName';
85+
const res = await request(server)
86+
.post(`/session/${invalidRoomName}/startArchive`)
87+
.set('Content-Type', 'application/json');
88+
expect(res.statusCode).toEqual(404);
89+
});
90+
91+
it('returns a 500 when stopping an invalid archive in a room', async () => {
92+
const archiveId = 'b8-c9-d10';
93+
const res = await request(server)
94+
.post(`/session/${roomName}/${archiveId}/stopArchive`)
95+
.set('Content-Type', 'application/json')
96+
.set('Accept', 'application/json');
97+
expect(res.statusCode).toEqual(500);
98+
});
8699
});
87100

88-
it('returns a 500 when stopping an invalid archive in a room', async () => {
89-
const archiveId = 'b8-c9-d10';
90-
const res = await request(server)
91-
.post(`/session/${roomName}/${archiveId}/stopArchive`)
92-
.set('Content-Type', 'application/json')
93-
.set('Accept', 'application/json');
94-
expect(res.statusCode).toEqual(500);
101+
describe('captions', () => {
102+
it('returns a 200 when enabling captions in a room', async () => {
103+
const res = await request(server)
104+
.post(`/session/${roomName}/enableCaptions`)
105+
.set('Content-Type', 'application/json');
106+
expect(res.statusCode).toEqual(200);
107+
});
108+
109+
it('returns a 200 when disabling captions in a room', async () => {
110+
const captionId = 'someCaptionId';
111+
const res = await request(server)
112+
.post(`/session/${roomName}/${captionId}/disableCaptions`)
113+
.set('Content-Type', 'application/json');
114+
expect(res.statusCode).toEqual(200);
115+
});
116+
117+
it('returns a 404 when starting captions in a non-existent room', async () => {
118+
const invalidRoomName = 'randomRoomName';
119+
const res = await request(server)
120+
.post(`/session/${invalidRoomName}/enableCaptions`)
121+
.set('Content-Type', 'application/json');
122+
expect(res.statusCode).toEqual(404);
123+
});
124+
125+
it('returns an invalid caption message when stopping an invalid captions in a room', async () => {
126+
const invalidCaptionId = 'wrongCaptionId';
127+
const res = await request(server)
128+
.post(`/session/${roomName}/${invalidCaptionId}/disableCaptions`)
129+
.set('Content-Type', 'application/json')
130+
.set('Accept', 'application/json');
131+
132+
const responseBody = JSON.parse(res.text);
133+
expect(responseBody.captionId).toEqual('invalid caption');
134+
});
95135
});
96136
});
97137
});

backend/types/opentok-jwt-d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module 'opentok-jwt' {
2+
// eslint-disable-next-line import/prefer-default-export
3+
export function projectToken(apiKey: string, apiSecret: string, expire?: number): string;
4+
}

backend/videoService/opentokVideoService.ts

+76
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import OpenTok, { Archive, Role } from 'opentok';
2+
import axios from 'axios';
3+
import { projectToken } from 'opentok-jwt';
24
import { VideoService } from './videoServiceInterface';
35
import { OpentokConfig } from '../types/config';
46

7+
export type EnableCaptionResponse = {
8+
captionsId: string;
9+
};
10+
511
class OpenTokVideoService implements VideoService {
612
private readonly opentok: OpenTok;
713

@@ -87,6 +93,76 @@ class OpenTokVideoService implements VideoService {
8793
});
8894
});
8995
}
96+
97+
// The OpenTok API does not support enabling captions directly through the OpenTok SDK.
98+
// Instead, we need to make a direct HTTP request to the OpenTok API endpoint to enable captions.
99+
// This is not the case for Vonage Video Node SDK, which has a built-in method for enabling captions.
100+
readonly API_URL = 'https://api.opentok.com/v2/project';
101+
102+
async enableCaptions(sessionId: string): Promise<EnableCaptionResponse> {
103+
const expires = Math.floor(new Date().getTime() / 1000) + 24 * 60 * 60;
104+
// Note that the project token is different from the session token.
105+
// The project token is used to authenticate the request to the OpenTok API.
106+
const projectJWT = projectToken(this.config.apiKey, this.config.apiSecret, expires);
107+
const captionURL = `${this.API_URL}/${this.config.apiKey}/captions`;
108+
109+
const { token } = this.generateToken(sessionId);
110+
const captionOptions = {
111+
// The following language codes are supported: en-US, en-AU, en-GB, fr-FR, fr-CA, de-DE, hi-IN, it-IT, pt-BR, ja-JP, ko-KR, zh-CN, zh-TW
112+
languageCode: 'en-US',
113+
// The maximum duration of the captions in seconds. The default is 14,400 seconds (4 hours).
114+
maxDuration: 1800,
115+
// Enabling partial captions allows for more frequent updates to the captions.
116+
// This is useful for real-time applications where the captions need to be updated frequently.
117+
// However, it may also increase the number of inaccuracies in the captions.
118+
partialCaptions: true,
119+
};
120+
121+
const captionAxiosPostBody = {
122+
sessionId,
123+
token,
124+
...captionOptions,
125+
};
126+
127+
try {
128+
const {
129+
data: { captionsId },
130+
} = await axios.post(captionURL, captionAxiosPostBody, {
131+
headers: {
132+
'X-OPENTOK-AUTH': projectJWT,
133+
'Content-Type': 'application/json',
134+
},
135+
});
136+
return { captionsId };
137+
} catch (error: unknown) {
138+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
139+
throw new Error(`Failed to enable captions: ${errorMessage}`);
140+
}
141+
}
142+
143+
async disableCaptions(captionId: string): Promise<string> {
144+
const expires = Math.floor(new Date().getTime() / 1000) + 24 * 60 * 60;
145+
// Note that the project token is different from the session token.
146+
// The project token is used to authenticate the request to the OpenTok API.
147+
const projectJWT = projectToken(this.config.apiKey, this.config.apiSecret, expires);
148+
const captionURL = `${this.API_URL}/${this.config.apiKey}/captions/${captionId}/stop`;
149+
try {
150+
await axios.post(
151+
captionURL,
152+
{},
153+
{
154+
headers: {
155+
'X-OPENTOK-AUTH': projectJWT,
156+
'Content-Type': 'application/json',
157+
},
158+
}
159+
);
160+
return 'Captions stopped successfully';
161+
} catch (error: unknown) {
162+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
163+
throw new Error(`Failed to disable captions: ${errorMessage}`);
164+
}
165+
}
90166
}
91167

92168
export default OpenTokVideoService;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, expect, it, jest } from '@jest/globals';
2+
3+
const mockSessionId = 'mockSessionId';
4+
const mockToken = 'mockToken';
5+
const mockApiKey = 'mockApplicationId';
6+
const mockArchiveId = 'mockArchiveId';
7+
const mockRoomName = 'awesomeRoomName';
8+
const mockCaptionId = 'mockCaptionId';
9+
const mockApiSecret = 'mockApiSecret';
10+
11+
await jest.unstable_mockModule('opentok', () => ({
12+
default: jest.fn().mockImplementation(() => ({
13+
createSession: jest.fn(
14+
(_options: unknown, callback: (err: unknown, session: { sessionId: string }) => void) => {
15+
callback(null, { sessionId: mockSessionId });
16+
}
17+
),
18+
generateToken: jest.fn<() => { token: string; apiKey: string }>().mockReturnValue({
19+
token: mockToken,
20+
apiKey: mockApiKey,
21+
}),
22+
startArchive: jest.fn(
23+
(
24+
_sessionId: string,
25+
_options: unknown,
26+
callback: (
27+
err: unknown,
28+
session: {
29+
archive: {
30+
id: string;
31+
};
32+
}
33+
) => void
34+
) => {
35+
callback(null, {
36+
archive: {
37+
id: mockArchiveId,
38+
},
39+
});
40+
}
41+
),
42+
stopArchive: jest.fn(
43+
(_archiveId: string, callback: (err: unknown, archive: { archiveId: string }) => void) => {
44+
callback(null, { archiveId: mockArchiveId });
45+
}
46+
),
47+
listArchives: jest.fn(
48+
(_options: unknown, callback: (err: unknown, archives: [{ archiveId: string }]) => void) => {
49+
callback(null, [{ archiveId: mockArchiveId }]);
50+
}
51+
),
52+
})),
53+
mediaMode: 'routed',
54+
}));
55+
56+
await jest.unstable_mockModule('axios', () => ({
57+
default: {
58+
post: jest.fn<() => Promise<{ data: { captionsId: string } }>>().mockResolvedValue({
59+
data: { captionsId: mockCaptionId },
60+
}),
61+
},
62+
}));
63+
64+
const { default: OpenTokVideoService } = await import('../opentokVideoService');
65+
66+
describe('OpentokVideoService', () => {
67+
let opentokVideoService: typeof OpenTokVideoService.prototype;
68+
69+
beforeEach(() => {
70+
opentokVideoService = new OpenTokVideoService({
71+
apiKey: mockApiKey,
72+
apiSecret: mockApiSecret,
73+
provider: 'opentok',
74+
});
75+
});
76+
77+
it('creates a session', async () => {
78+
const session = await opentokVideoService.createSession();
79+
expect(session).toBe(mockSessionId);
80+
});
81+
82+
it('generates a token', () => {
83+
const result = opentokVideoService.generateToken(mockSessionId);
84+
expect(result.token).toEqual({
85+
apiKey: mockApiKey,
86+
token: mockToken,
87+
});
88+
});
89+
90+
it('starts an archive', async () => {
91+
const response = await opentokVideoService.startArchive(mockRoomName, mockSessionId);
92+
expect(response).toMatchObject({
93+
archive: {
94+
id: mockArchiveId,
95+
},
96+
});
97+
});
98+
99+
it('stops an archive', async () => {
100+
const archiveResponse = await opentokVideoService.stopArchive(mockArchiveId);
101+
expect(archiveResponse).toBe(mockArchiveId);
102+
});
103+
104+
it('generates a list of archives', async () => {
105+
const archiveResponse = await opentokVideoService.listArchives(mockSessionId);
106+
expect(archiveResponse).toEqual([{ archiveId: mockArchiveId }]);
107+
});
108+
109+
it('enables captions', async () => {
110+
const captionResponse = await opentokVideoService.enableCaptions(mockSessionId);
111+
expect(captionResponse.captionsId).toBe(mockCaptionId);
112+
});
113+
114+
it('disables captions', async () => {
115+
const captionResponse = await opentokVideoService.disableCaptions(mockCaptionId);
116+
expect(captionResponse).toBe('Captions stopped successfully');
117+
});
118+
});

0 commit comments

Comments
 (0)