diff --git a/backend/package.json b/backend/package.json index 04f5712e..e67a8fbe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,8 @@ "express": "^4.21.2", "form-data": "^4.0.1", "opentok": "^2.16.0", - "tsx": "^4.10.5" + "tsx": "^4.10.5", + "opentok-jwt": "^0.1.5" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/backend/routes/session.ts b/backend/routes/session.ts index 04ada6d0..3d3f770d 100644 --- a/backend/routes/session.ts +++ b/backend/routes/session.ts @@ -84,4 +84,42 @@ sessionRouter.get('/:room/archives', async (req: Request<{ room: string }>, res: } }); +sessionRouter.post( + '/:room/enableCaptions', + async (req: Request<{ room: string }>, res: Response) => { + try { + const { room: roomName } = req.params; + const sessionId = await sessionService.getSession(roomName); + if (sessionId) { + const captions = await videoService.enableCaptions(sessionId); + res.json({ + captions, + status: 200, + }); + } else { + res.status(404).json({ message: 'Room not found' }); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + res.status(500).json({ message: errorMessage }); + } + } +); + +sessionRouter.post( + '/:room/:captionId/disableCaptions', + async (req: Request<{ room: string; captionId: string }>, res: Response) => { + try { + const { captionId } = req.params; + const responseCaptionId = await videoService.disableCaptions(captionId); + res.json({ + captionId: responseCaptionId, + status: 200, + }); + } catch (error: unknown) { + res.status(500).send({ message: (error as Error).message ?? error }); + } + } +); + export default sessionRouter; diff --git a/backend/tests/session.test.ts b/backend/tests/session.test.ts index 8309d589..748853bd 100644 --- a/backend/tests/session.test.ts +++ b/backend/tests/session.test.ts @@ -22,6 +22,8 @@ await jest.unstable_mockModule('../videoService/opentokVideoService.ts', () => { return { startArchive: jest.fn<() => Promise>().mockResolvedValue('archiveId'), stopArchive: jest.fn<() => Promise>().mockRejectedValue('invalid archive'), + enableCaptions: jest.fn<() => Promise>().mockResolvedValue('captionId'), + disableCaptions: jest.fn<() => Promise>().mockResolvedValue('invalid caption'), generateToken: jest .fn<() => Promise<{ token: string; apiKey: string }>>() .mockResolvedValue({ @@ -70,28 +72,66 @@ describe.each([ }); describe('POST requests', () => { - it('returns a 200 when starting an archive in a room', async () => { - const res = await request(server) - .post(`/session/${roomName}/startArchive`) - .set('Content-Type', 'application/json'); - expect(res.statusCode).toEqual(200); - }); + describe('archiving', () => { + it('returns a 200 when starting an archive in a room', async () => { + const res = await request(server) + .post(`/session/${roomName}/startArchive`) + .set('Content-Type', 'application/json'); + expect(res.statusCode).toEqual(200); + }); - it('returns a 404 when starting an archive in a non-existent room', async () => { - const invalidRoomName = 'nonExistingRoomName'; - const res = await request(server) - .post(`/session/${invalidRoomName}/startArchive`) - .set('Content-Type', 'application/json'); - expect(res.statusCode).toEqual(404); + it('returns a 404 when starting an archive in a non-existent room', async () => { + const invalidRoomName = 'nonExistingRoomName'; + const res = await request(server) + .post(`/session/${invalidRoomName}/startArchive`) + .set('Content-Type', 'application/json'); + expect(res.statusCode).toEqual(404); + }); + + it('returns a 500 when stopping an invalid archive in a room', async () => { + const archiveId = 'b8-c9-d10'; + const res = await request(server) + .post(`/session/${roomName}/${archiveId}/stopArchive`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + expect(res.statusCode).toEqual(500); + }); }); - it('returns a 500 when stopping an invalid archive in a room', async () => { - const archiveId = 'b8-c9-d10'; - const res = await request(server) - .post(`/session/${roomName}/${archiveId}/stopArchive`) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - expect(res.statusCode).toEqual(500); + describe('captions', () => { + it('returns a 200 when enabling captions in a room', async () => { + const res = await request(server) + .post(`/session/${roomName}/enableCaptions`) + .set('Content-Type', 'application/json'); + expect(res.statusCode).toEqual(200); + }); + + it('returns a 200 when disabling captions in a room', async () => { + const captionId = 'someCaptionId'; + const res = await request(server) + .post(`/session/${roomName}/${captionId}/disableCaptions`) + .set('Content-Type', 'application/json'); + expect(res.statusCode).toEqual(200); + }); + + it('returns a 404 when starting captions in a non-existent room', async () => { + const invalidRoomName = 'randomRoomName'; + const res = await request(server) + .post(`/session/${invalidRoomName}/enableCaptions`) + .set('Content-Type', 'application/json'); + expect(res.statusCode).toEqual(404); + }); + + it('returns an invalid caption message when stopping an invalid captions in a room', async () => { + const invalidCaptionId = 'wrongCaptionId'; + const res = await request(server) + .post(`/session/${roomName}/${invalidCaptionId}/disableCaptions`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const responseBody = JSON.parse(res.text); + expect(responseBody.captionId).toEqual('invalid caption'); + }); }); }); }); diff --git a/backend/types/opentok-jwt-d.ts b/backend/types/opentok-jwt-d.ts new file mode 100644 index 00000000..bb1c7b3c --- /dev/null +++ b/backend/types/opentok-jwt-d.ts @@ -0,0 +1,4 @@ +declare module 'opentok-jwt' { + // eslint-disable-next-line import/prefer-default-export + export function projectToken(apiKey: string, apiSecret: string, expire?: number): string; +} diff --git a/backend/videoService/opentokVideoService.ts b/backend/videoService/opentokVideoService.ts index b1906357..493cc86d 100644 --- a/backend/videoService/opentokVideoService.ts +++ b/backend/videoService/opentokVideoService.ts @@ -1,7 +1,13 @@ import OpenTok, { Archive, Role } from 'opentok'; +import axios from 'axios'; +import { projectToken } from 'opentok-jwt'; import { VideoService } from './videoServiceInterface'; import { OpentokConfig } from '../types/config'; +export type EnableCaptionResponse = { + captionsId: string; +}; + class OpenTokVideoService implements VideoService { private readonly opentok: OpenTok; @@ -87,6 +93,76 @@ class OpenTokVideoService implements VideoService { }); }); } + + // The OpenTok API does not support enabling captions directly through the OpenTok SDK. + // Instead, we need to make a direct HTTP request to the OpenTok API endpoint to enable captions. + // This is not the case for Vonage Video Node SDK, which has a built-in method for enabling captions. + readonly API_URL = 'https://api.opentok.com/v2/project'; + + async enableCaptions(sessionId: string): Promise { + const expires = Math.floor(new Date().getTime() / 1000) + 24 * 60 * 60; + // Note that the project token is different from the session token. + // The project token is used to authenticate the request to the OpenTok API. + const projectJWT = projectToken(this.config.apiKey, this.config.apiSecret, expires); + const captionURL = `${this.API_URL}/${this.config.apiKey}/captions`; + + const { token } = this.generateToken(sessionId); + const captionOptions = { + // 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 + languageCode: 'en-US', + // The maximum duration of the captions in seconds. The default is 14,400 seconds (4 hours). + maxDuration: 1800, + // Enabling partial captions allows for more frequent updates to the captions. + // This is useful for real-time applications where the captions need to be updated frequently. + // However, it may also increase the number of inaccuracies in the captions. + partialCaptions: true, + }; + + const captionAxiosPostBody = { + sessionId, + token, + ...captionOptions, + }; + + try { + const { + data: { captionsId }, + } = await axios.post(captionURL, captionAxiosPostBody, { + headers: { + 'X-OPENTOK-AUTH': projectJWT, + 'Content-Type': 'application/json', + }, + }); + return { captionsId }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + throw new Error(`Failed to enable captions: ${errorMessage}`); + } + } + + async disableCaptions(captionId: string): Promise { + const expires = Math.floor(new Date().getTime() / 1000) + 24 * 60 * 60; + // Note that the project token is different from the session token. + // The project token is used to authenticate the request to the OpenTok API. + const projectJWT = projectToken(this.config.apiKey, this.config.apiSecret, expires); + const captionURL = `${this.API_URL}/${this.config.apiKey}/captions/${captionId}/stop`; + try { + await axios.post( + captionURL, + {}, + { + headers: { + 'X-OPENTOK-AUTH': projectJWT, + 'Content-Type': 'application/json', + }, + } + ); + return 'Captions stopped successfully'; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + throw new Error(`Failed to disable captions: ${errorMessage}`); + } + } } export default OpenTokVideoService; diff --git a/backend/videoService/tests/opentokVideoService.test.ts b/backend/videoService/tests/opentokVideoService.test.ts new file mode 100644 index 00000000..75fdb057 --- /dev/null +++ b/backend/videoService/tests/opentokVideoService.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +const mockSessionId = 'mockSessionId'; +const mockToken = 'mockToken'; +const mockApiKey = 'mockApplicationId'; +const mockArchiveId = 'mockArchiveId'; +const mockRoomName = 'awesomeRoomName'; +const mockCaptionId = 'mockCaptionId'; +const mockApiSecret = 'mockApiSecret'; + +await jest.unstable_mockModule('opentok', () => ({ + default: jest.fn().mockImplementation(() => ({ + createSession: jest.fn( + (_options: unknown, callback: (err: unknown, session: { sessionId: string }) => void) => { + callback(null, { sessionId: mockSessionId }); + } + ), + generateToken: jest.fn<() => { token: string; apiKey: string }>().mockReturnValue({ + token: mockToken, + apiKey: mockApiKey, + }), + startArchive: jest.fn( + ( + _sessionId: string, + _options: unknown, + callback: ( + err: unknown, + session: { + archive: { + id: string; + }; + } + ) => void + ) => { + callback(null, { + archive: { + id: mockArchiveId, + }, + }); + } + ), + stopArchive: jest.fn( + (_archiveId: string, callback: (err: unknown, archive: { archiveId: string }) => void) => { + callback(null, { archiveId: mockArchiveId }); + } + ), + listArchives: jest.fn( + (_options: unknown, callback: (err: unknown, archives: [{ archiveId: string }]) => void) => { + callback(null, [{ archiveId: mockArchiveId }]); + } + ), + })), + mediaMode: 'routed', +})); + +await jest.unstable_mockModule('axios', () => ({ + default: { + post: jest.fn<() => Promise<{ data: { captionsId: string } }>>().mockResolvedValue({ + data: { captionsId: mockCaptionId }, + }), + }, +})); + +const { default: OpenTokVideoService } = await import('../opentokVideoService'); + +describe('OpentokVideoService', () => { + let opentokVideoService: typeof OpenTokVideoService.prototype; + + beforeEach(() => { + opentokVideoService = new OpenTokVideoService({ + apiKey: mockApiKey, + apiSecret: mockApiSecret, + provider: 'opentok', + }); + }); + + it('creates a session', async () => { + const session = await opentokVideoService.createSession(); + expect(session).toBe(mockSessionId); + }); + + it('generates a token', () => { + const result = opentokVideoService.generateToken(mockSessionId); + expect(result.token).toEqual({ + apiKey: mockApiKey, + token: mockToken, + }); + }); + + it('starts an archive', async () => { + const response = await opentokVideoService.startArchive(mockRoomName, mockSessionId); + expect(response).toMatchObject({ + archive: { + id: mockArchiveId, + }, + }); + }); + + it('stops an archive', async () => { + const archiveResponse = await opentokVideoService.stopArchive(mockArchiveId); + expect(archiveResponse).toBe(mockArchiveId); + }); + + it('generates a list of archives', async () => { + const archiveResponse = await opentokVideoService.listArchives(mockSessionId); + expect(archiveResponse).toEqual([{ archiveId: mockArchiveId }]); + }); + + it('enables captions', async () => { + const captionResponse = await opentokVideoService.enableCaptions(mockSessionId); + expect(captionResponse.captionsId).toBe(mockCaptionId); + }); + + it('disables captions', async () => { + const captionResponse = await opentokVideoService.disableCaptions(mockCaptionId); + expect(captionResponse).toBe('Captions stopped successfully'); + }); +}); diff --git a/backend/videoService/tests/vonageVideoService.test.ts b/backend/videoService/tests/vonageVideoService.test.ts new file mode 100644 index 00000000..9b7ecb78 --- /dev/null +++ b/backend/videoService/tests/vonageVideoService.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.mock('@vonage/auth'); + +const mockSessionId = 'mockSessionId'; +const mockToken = 'mockToken'; +const mockApplicationId = 'mockApplicationId'; +const mockArchiveId = 'mockArchiveId'; +const mockRoomName = 'awesomeRoomName'; +const mockCaptionId = 'mockCaptionId'; +const mockPrivateKey = 'mockPrivateKey'; + +await jest.unstable_mockModule('@vonage/video', () => { + return { + Video: jest.fn().mockImplementation(() => ({ + createSession: jest.fn<() => Promise<{ sessionId: string }>>().mockResolvedValue({ + sessionId: mockSessionId, + }), + generateClientToken: jest.fn<() => { token: string; apiKey: string }>().mockReturnValue({ + token: mockToken, + apiKey: mockApplicationId, + }), + startArchive: jest.fn<() => Promise<{ id: string }>>().mockResolvedValue({ + id: mockArchiveId, + }), + stopArchive: jest.fn<() => Promise<{ status: number }>>().mockResolvedValue({ + status: 200, + }), + enableCaptions: jest.fn<() => Promise<{ captionsId: string }>>().mockResolvedValue({ + captionsId: mockCaptionId, + }), + disableCaptions: jest.fn<() => Promise<{ status: number }>>().mockResolvedValue({ + status: 200, + }), + })), + LayoutType: { + BEST_FIT: 'bestFit', + HORIZONTAL_PRESENTATION: 'horizontalPresentation', + CUSTOM: 'custom', + }, + MediaMode: { + ROUTED: 'routed', + }, + Resolution: { + FHD_LANDSCAPE: '1920x1080', + }, + }; +}); + +const { default: VonageVideoService } = await import('../vonageVideoService'); + +describe('VonageVideoService', () => { + let vonageVideoService: typeof VonageVideoService.prototype; + + beforeEach(() => { + vonageVideoService = new VonageVideoService({ + applicationId: mockApplicationId, + privateKey: mockPrivateKey, + provider: 'vonage', + }); + }); + + it('creates a session', async () => { + const session = await vonageVideoService.createSession(); + expect(session).toBe(mockSessionId); + }); + + it('generates a token', () => { + const result = vonageVideoService.generateToken(mockSessionId); + expect(result.token).toEqual({ + apiKey: mockApplicationId, + token: mockToken, + }); + }); + + it('starts an archive', async () => { + const archive = await vonageVideoService.startArchive(mockRoomName, mockSessionId); + expect(archive.id).toBe(mockArchiveId); + }); + + it('stops an archive', async () => { + const archiveResponse = await vonageVideoService.stopArchive(mockArchiveId); + expect(archiveResponse).toBe('Archive stopped successfully'); + }); + + it('enables captions', async () => { + const captionResponse = await vonageVideoService.enableCaptions(mockSessionId); + expect(captionResponse.captionsId).toBe(mockCaptionId); + }); + + it('disables captions', async () => { + const captionResponse = await vonageVideoService.disableCaptions(mockCaptionId); + expect(captionResponse).toBe('Captions stopped successfully'); + }); +}); diff --git a/backend/videoService/videoServiceInterface.ts b/backend/videoService/videoServiceInterface.ts index 4e3d333e..bacb548d 100644 --- a/backend/videoService/videoServiceInterface.ts +++ b/backend/videoService/videoServiceInterface.ts @@ -1,4 +1,4 @@ -import { SingleArchiveResponse } from '@vonage/video'; +import { SingleArchiveResponse, EnableCaptionResponse } from '@vonage/video'; import { Archive } from 'opentok'; export interface VideoService { @@ -7,4 +7,6 @@ export interface VideoService { startArchive(roomName: string, sessionId: string): Promise; stopArchive(archiveId: string): Promise; listArchives(sessionId: string): Promise; + enableCaptions(sessionId: string): Promise; + disableCaptions(captionId: string): Promise; } diff --git a/backend/videoService/vonageVideoService.ts b/backend/videoService/vonageVideoService.ts index d54bde1c..75890a43 100644 --- a/backend/videoService/vonageVideoService.ts +++ b/backend/videoService/vonageVideoService.ts @@ -1,6 +1,14 @@ /* eslint-disable no-underscore-dangle */ import { Auth } from '@vonage/auth'; -import { LayoutType, MediaMode, Resolution, SingleArchiveResponse, Video } from '@vonage/video'; +import { + LayoutType, + MediaMode, + Resolution, + SingleArchiveResponse, + Video, + EnableCaptionResponse, + CaptionOptions, +} from '@vonage/video'; import { VideoService } from './videoServiceInterface'; import { VonageConfig } from '../types/config'; @@ -59,6 +67,37 @@ class VonageVideoService implements VideoService { await this.vonageVideo.stopArchive(archiveId); return 'Archive stopped successfully'; } + + async enableCaptions(sessionId: string): Promise { + const requestToken = this.generateToken(sessionId); + const { token } = requestToken; + + try { + const captionOptions: CaptionOptions = { + // The maximum duration of the captions in seconds. The default is 14,400 seconds (4 hours). + maxDuration: 1800, + // Enabling partial captions allows for more frequent updates to the captions. + // This is useful for real-time applications where the captions need to be updated frequently. + // However, it may also increase the number of inaccuracies in the captions. + partialCaptions: 'true', + }; + const captionsId = await this.vonageVideo.enableCaptions(sessionId, token, captionOptions); + return captionsId; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + throw new Error(`Failed to enable captions: ${errorMessage}`); + } + } + + async disableCaptions(captionId: string): Promise { + try { + await this.vonageVideo.disableCaptions(captionId); + return 'Captions stopped successfully'; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + throw new Error(`Failed to disable captions: ${errorMessage}`); + } + } } export default VonageVideoService; diff --git a/yarn.lock b/yarn.lock index b1924385..570fc79a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6890,6 +6890,13 @@ oniguruma-to-js@0.4.3: dependencies: regex "^4.3.2" +opentok-jwt@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/opentok-jwt/-/opentok-jwt-0.1.5.tgz#c7c84724fc4d287f3a08f4264f97935cbbdb624e" + integrity sha512-Ub3iQEYava3oHK9Xp+UePFw9mratzp98LuDx46qtfkAFzsIKePlwBbj6UZ4pGuJDXZ28BJWpnXZyRWMJK9IW6w== + dependencies: + jsonwebtoken "^9.0.0" + opentok-layout-js@^5.4.0: version "5.4.0" resolved "https://registry.npmjs.org/opentok-layout-js/-/opentok-layout-js-5.4.0.tgz"