-
Notifications
You must be signed in to change notification settings - Fork 5
VIDCS-3699: Implement server-side controls of captions #161
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
Changes from all commits
be3ef5e
27a9ada
aea2131
f12bc11
83f5611
59baf08
de74eef
3930bcc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
declare module 'opentok-jwt' { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: this was needed since I'm using the
therefore not supporting the default export approach There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's some nice magic here 🪄 💪 ! |
||
// eslint-disable-next-line import/prefer-default-export | ||
export function projectToken(apiKey: string, apiSecret: string, expire?: number): string; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; | ||
behei-vonage marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
async enableCaptions(sessionId: string): Promise<EnableCaptionResponse> { | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: maybe link to where this is documented instead? |
||
languageCode: 'en-US', | ||
// The maximum duration of the captions in seconds. The default is 14,400 seconds (4 hours). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same nit above applies here too. |
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Excellent comment! 💪 |
||
partialCaptions: true, | ||
behei-vonage marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
|
||
const captionAxiosPostBody = { | ||
sessionId, | ||
token, | ||
...captionOptions, | ||
}; | ||
behei-vonage marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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<string> { | ||
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 { | ||
behei-vonage marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import { describe, expect, it, jest } from '@jest/globals'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: if you notice some differences between this test file and also you'll notice that in this file I'm also mocking There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, to be fair, for tests we prefer WET over DRY ;-) |
||
|
||
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'); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This error message might be misleading if the room exists but captions couldn't be enabled for some reason.
(Unless
enableCaptions()
can never fail....)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps but I'd like to keep it as-is to be consistent with the other routes we have.
though, let me know if I'm wrong -
if this line throws:
then it will be caught by:
however, if it so happens that
sessionId
is undefined, instead of making theenableCaptions
request, it will go straight to:which seems to be covering all of our scenarios, right?