@@ -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'; | |||
SERVER.route({ | |||
method: 'POST', | |||
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'; | |||
const SERVER = fastify({ | |||
logger: true, | |||
logger: process.env.NODE_ENV !== 'test', | |||
}); | |||
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); | |||
}); | |||
}); |