@@ -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<string, unknown>) => { | |||||
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<string, unknown>; | |||||
clipper.on('success', (result: Record<string, unknown>) => { | |||||
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(); | |||||
}; |
@@ -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'; | import SERVER from './server'; | ||||
SERVER.route({ | SERVER.route({ | ||||
method: 'POST', | method: 'POST', | ||||
url: '/clip', | url: '/clip', | ||||
handler: async (request, reply) => { | |||||
const { | |||||
url, | |||||
start, | |||||
end, | |||||
} = request.body as Record<string, unknown>; | |||||
const { postprocess = false } = request.query as Record<string, unknown>; | |||||
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<string, unknown>; | |||||
clipper.on('success', (result: Record<string, unknown>) => { | |||||
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, | |||||
}); | }); |
@@ -1,7 +1,7 @@ | |||||
import fastify from 'fastify'; | import fastify from 'fastify'; | ||||
const SERVER = fastify({ | const SERVER = fastify({ | ||||
logger: true, | |||||
logger: process.env.NODE_ENV !== 'test', | |||||
}); | }); | ||||
export default SERVER; | export default SERVER; |
@@ -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); | |||||
}); | |||||
}); |
@@ -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); | |||||
}); | |||||
}); |