diff --git a/src/controllers/ClipController.ts b/src/controllers/ClipController.ts new file mode 100644 index 0000000..fb1f899 --- /dev/null +++ b/src/controllers/ClipController.ts @@ -0,0 +1,114 @@ +import { + createVideoClipper, + VideoType, + CreateVideoClipperParams, +} from '@modal/webvideo-clip-core'; +import { constants } from 'http2'; +import { RouteHandlerMethod } from 'fastify'; + +export type ClipArgs = { + url?: unknown, + start?: string | number, + end?: string | number, +} + +const DURATION_STRING_REGEXP = /^\d\d:[0-5]\d:[0-5]\d(\.\d+)?$/; + +const validateRequestBody = (body: ClipArgs) => { + const messages = [] as string[]; + const { url, start, end } = body; + if (typeof url !== 'string') { + messages.push('URL is required.'); + } + + const typeofStart = typeof start; + if (typeofStart !== 'undefined') { + if (!['string', 'number'].includes(typeofStart)) { + messages.push('Invalid end value.'); + } else if (typeofStart === 'string' && !DURATION_STRING_REGEXP.test(start as string)) { + messages.push('Invalid start value.'); + } + } + + const typeofEnd = typeof end; + if (typeofEnd !== 'undefined') { + if (!['string', 'number'].includes(typeofEnd)) { + messages.push('Invalid end value.'); + } else if (typeofEnd === 'string' && !DURATION_STRING_REGEXP.test(end as string)) { + messages.push('Invalid end value.'); + } + } + + return messages; +}; + +const getVideoType = (url: string) => { + if (url.startsWith('https://www.youtube.com')) { + return VideoType.YOUTUBE; + } + + return null; +}; + +export const clip: RouteHandlerMethod = async (request, reply) => { + const validationMessages = validateRequestBody(request.body as ClipArgs); + if (validationMessages.length > 0) { + reply + .status(constants.HTTP_STATUS_BAD_REQUEST) + .send({ + errors: validationMessages, + }); + return; + } + const videoType = getVideoType((request.body as ClipArgs).url as string); + if (videoType === null) { + reply + .status(constants.HTTP_STATUS_UNPROCESSABLE_ENTITY) + .send({ + message: 'Unsupported URL.', + }); + } + + const { url, start, end } = request.body as ClipArgs; + const videoClipperArgs = { + type: videoType, + url, + start, + end, + downloaderExecutablePath: process.env.YOUTUBE_DOWNLOADER_EXECUTABLE_PATH, + } as CreateVideoClipperParams; + const clipper = createVideoClipper(videoClipperArgs); + clipper.on('process', (arg: Record) => { + request.server.log.info(`${arg.type as string}:${arg.phase as string}`); + if (typeof arg.command === 'string') { + request.server.log.debug(`> ${arg.command}`); + } + }); + + let clipResult: Record; + clipper.on('success', (result: Record) => { + clipResult = result; + }); + + let theError: Error; + clipper.on('error', (error: Error) => { + theError = error; + }); + + clipper.on('end', () => { + if (theError) { + reply + .status(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR) + .send({ + message: theError.message, + }); + return; + } + + reply + .header('Content-Type', clipResult.type as string) + .send(clipResult.output as Buffer); + }); + + clipper.process(); +}; diff --git a/src/routes.ts b/src/routes.ts index 265a60d..d3662be 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,67 +1,8 @@ -import { - createVideoClipper, - VideoType, - CreateVideoClipperParams, -} from '@modal/webvideo-clip-core'; -import { constants } from 'http2'; +import * as ClipController from './controllers/ClipController'; import SERVER from './server'; SERVER.route({ method: 'POST', url: '/clip', - handler: async (request, reply) => { - const { - url, - start, - end, - } = request.body as Record; - - const { postprocess = false } = request.query as Record; - - let videoType: string = ''; - - if (url.startsWith('https://www.youtube.com')) { - videoType = VideoType.YOUTUBE; - } - - const videoClipperArgs = { - type: videoType, - url, - start, - end, - downloaderExecutablePath: process.env.YOUTUBE_DOWNLOADER_EXECUTABLE_PATH, - } as CreateVideoClipperParams; - if (postprocess) { - videoClipperArgs.postprocessorExecutablePath = process.env.POSTPROCESSOR_EXECUTABLE_PATH; - } - - const clipper = createVideoClipper(videoClipperArgs); - - let clipResult: Record; - clipper.on('success', (result: Record) => { - clipResult = result; - }); - - let theError: Error; - clipper.on('error', (error: Error) => { - theError = error; - }); - - clipper.on('end', () => { - if (theError) { - reply - .status(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR) - .send({ - message: theError.message, - }); - return; - } - - reply - .header('Content-Type', clipResult.type as string) - .send(clipResult.output as Buffer); - }); - - clipper.process(); - }, + handler: ClipController.clip, }); diff --git a/src/server.ts b/src/server.ts index 8dbaf39..3dfbf3d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import fastify from 'fastify'; const SERVER = fastify({ - logger: true, + logger: process.env.NODE_ENV !== 'test', }); export default SERVER; diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..0513fc7 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,92 @@ +import { + describe, it, expect, vi, Mock, beforeAll, afterEach, +} from 'vitest'; +import { EventEmitter } from 'events'; +import { createVideoClipper, VideoClipEventEmitter } from '@modal/webvideo-clip-core'; +import SERVER from '../src/server'; +import '../src/routes'; +import { constants } from 'http2'; + +class MockEventEmitter extends EventEmitter { + process = vi.fn(); +} + +vi.mock('@modal/webvideo-clip-core'); + +describe('ClipController.clip: POST /clip', () => { + let mockEventEmitter: VideoClipEventEmitter; + beforeAll(() => { + mockEventEmitter = new MockEventEmitter(); + (createVideoClipper as Mock).mockReturnValue(mockEventEmitter); + }); + + afterEach(() => { + (mockEventEmitter.process as Mock).mockReset(); + }); + + it('returns the clip', async () => { + const dummyOutput = 'string content'; + (mockEventEmitter.process as Mock).mockImplementationOnce( + function mockProcess(this: VideoClipEventEmitter) { + this.emit('success', { + type: 'video/webm', + output: Buffer.from(dummyOutput), + }); + this.emit('end'); + }, + ); + + const response = await SERVER + .inject() + .post('/clip') + .body({ + url: 'https://www.youtube.com/watch?v=BaW_jenozKc', + start: '00:00:00', + end: '00:00:05', + }); + + expect(response.statusCode).toBe(constants.HTTP_STATUS_OK); + expect(response.headers['content-type']).toBe('video/webm'); + expect(response.headers['content-length']).toBe(dummyOutput.length.toString()); + }); + + it('returns an error when the clip function throws', async () => { + (mockEventEmitter.process as Mock).mockImplementationOnce( + function mockProcess(this: VideoClipEventEmitter) { + this.emit('error', new Error()); + this.emit('end'); + }, + ); + + const response = await SERVER + .inject() + .post('/clip') + .body({ + url: 'https://www.youtube.com/watch?v=BaW_jenozKc', + start: '00:00:00', + end: '00:00:05', + }); + + expect(response.statusCode).toBe(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('returns an error when the URL could not be found', async () => { + const response = await SERVER + .inject() + .post('/clip') + .body({}); + + expect(response.statusCode).toBe(constants.HTTP_STATUS_BAD_REQUEST); + }); + + it('returns an error when the URL is unsupported', async () => { + const response = await SERVER + .inject() + .post('/clip') + .body({ + url: 'https://unsupported.com', + }); + + expect(response.statusCode).toBe(constants.HTTP_STATUS_UNPROCESSABLE_ENTITY); + }); +}); diff --git a/test/index.test.tsx b/test/index.test.tsx deleted file mode 100644 index 77cf2d5..0000000 --- a/test/index.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import SERVER from '../src/server'; -import '../src/routes'; - -describe('Example', () => { - it('should have the expected content', async () => { - const response = await SERVER - .inject() - .get('/') - .headers({ - 'Accept': 'application/json', - }); - expect(response.statusCode).toBe(200); - }); -});