@@ -1,28 +1,20 @@ | |||
type ProcessEvent = { type: string, phase: string, command?: string }; | |||
type ProcessEventCallback = (event: ProcessEvent) => void; | |||
type ErrorEventCallback = (event: Error) => void; | |||
type SuccessEvent = { contentType: string, content: Buffer }; | |||
type SuccessEventCallback = (event: SuccessEvent) => void; | |||
export interface VideoClipEventEmitter extends NodeJS.EventEmitter { | |||
process(): void; | |||
on(eventType: 'process', callback: ProcessEventCallback): this; | |||
on(eventType: 'error', callback: ErrorEventCallback): this; | |||
on(eventType: 'end', callback: () => void): this; | |||
on(eventType: 'success', callback: SuccessEventCallback): this; | |||
export interface ClippedVideo { | |||
contentType: string; | |||
content: Buffer; | |||
} | |||
export type ClipFunction = (clipVideoParams: ClipVideoParams) => Promise<ClippedVideo>; | |||
export const FILE_TYPES: Record<string, string> = { | |||
mkv: 'video/x-matroska', | |||
webm: 'video/webm', | |||
mp4: 'video/mp4', | |||
}; | |||
export interface CreateBaseClipperParams { | |||
export interface CreateClipperParams { | |||
downloaderExecutablePath: string; | |||
} | |||
export interface ClipVideoParams { | |||
url: string; | |||
start?: number | string; | |||
end?: number | string; | |||
@@ -1,36 +1,27 @@ | |||
import { CreateYouTubeClipperParams, YouTubeVideoClipEventEmitter } from './video-types/youtube'; | |||
import { VideoClipEventEmitter } from './common'; | |||
import * as YouTubeImpl from './video-types/youtube'; | |||
export enum VideoType { | |||
YOUTUBE = 'youtube', | |||
} | |||
const SUPPORTED_VIDEO_TYPES = [ | |||
YouTubeImpl, | |||
] as const; | |||
export interface CreateVideoClipperParams extends CreateYouTubeClipperParams { | |||
type: VideoType, | |||
} | |||
export type CreateClipperParams = ( | |||
YouTubeImpl.CreateClipperParams | |||
); | |||
export const createVideoClipper = (params: CreateVideoClipperParams): VideoClipEventEmitter => { | |||
const { | |||
type: videoType, | |||
url, | |||
start, | |||
end, | |||
downloaderExecutablePath, | |||
} = params; | |||
export type VideoType = typeof YouTubeImpl.VIDEO_TYPE; | |||
export * as YouTube from './video-types/youtube'; | |||
export * from './common'; | |||
switch (videoType as string) { | |||
case VideoType.YOUTUBE: | |||
return new YouTubeVideoClipEventEmitter({ | |||
downloaderExecutablePath, | |||
url, | |||
start, | |||
end, | |||
}); | |||
default: | |||
break; | |||
export const createVideoClipper = (params: CreateClipperParams) => { | |||
const { type: videoType, ...etcParams } = params; | |||
const theVideoTypeModule = SUPPORTED_VIDEO_TYPES | |||
.find((videoTypeModule) => videoTypeModule.VIDEO_TYPE === videoType); | |||
if (!theVideoTypeModule) { | |||
const validVideoTypes = SUPPORTED_VIDEO_TYPES.map((videoTypeModule) => videoTypeModule.VIDEO_TYPE).join(', '); | |||
throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${validVideoTypes}`); | |||
} | |||
throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`); | |||
return theVideoTypeModule.createVideoClipper(etcParams); | |||
}; | |||
export { VideoClipEventEmitter }; |
@@ -0,0 +1,47 @@ | |||
import { spawnSync } from 'child_process'; | |||
import { readFileSync, unlinkSync } from 'fs'; | |||
import { | |||
ClipVideoParams as BaseClipVideoParams, | |||
CreateClipperParams as CreateBaseClipperParams, | |||
FILE_TYPES, | |||
} from '../../common'; | |||
import { VIDEO_TYPE } from './common'; | |||
import { constructDefaultDownloadArgs, getFileExtension } from './downloader'; | |||
import { DownloaderFailedToStartError, DownloaderNotFoundError } from './errors'; | |||
export interface CreateClipperParams extends CreateBaseClipperParams { | |||
type: typeof VIDEO_TYPE; | |||
} | |||
export interface ClipVideoParams extends BaseClipVideoParams {} | |||
export const createVideoClipper = (createClipperParams: Omit<CreateClipperParams, 'type'>) => (clipVideoParams: ClipVideoParams) => { | |||
if (!createClipperParams.downloaderExecutablePath) { | |||
throw new DownloaderNotFoundError('Downloader not found.'); | |||
} | |||
const fileExtension = getFileExtension( | |||
createClipperParams.downloaderExecutablePath, | |||
clipVideoParams.url, | |||
); | |||
const cacheFilename = `output.${fileExtension}`; // todo label this on the cache | |||
const downloadArgs = constructDefaultDownloadArgs( | |||
cacheFilename, | |||
clipVideoParams.url, | |||
clipVideoParams.start, | |||
clipVideoParams.end, | |||
); | |||
const downloaderProcess = spawnSync( | |||
createClipperParams.downloaderExecutablePath, | |||
downloadArgs, | |||
); | |||
if (downloaderProcess.error) { | |||
throw new DownloaderFailedToStartError('Downloader failed to start.', { cause: downloaderProcess.error }); | |||
} | |||
const output = readFileSync(cacheFilename); | |||
unlinkSync(cacheFilename); | |||
return Promise.resolve({ | |||
contentType: FILE_TYPES[fileExtension], | |||
content: output, | |||
}); | |||
} |
@@ -0,0 +1 @@ | |||
export const VIDEO_TYPE = 'youtube' as const; |
@@ -1,7 +1,7 @@ | |||
import { spawnSync } from 'child_process'; | |||
import { convertDurationToSeconds, convertSecondsToDuration } from '../../duration'; | |||
export const normalizeStartSeconds = (start?: number | string) => { | |||
const normalizeStartSeconds = (start?: number | string) => { | |||
if (typeof start === 'undefined') { | |||
return 0; | |||
} | |||
@@ -17,25 +17,7 @@ const FORMAT_DEFAULT_ARGS = [ | |||
'-f', 'bestvideo+bestaudio', | |||
]; | |||
export const getFileExtension = (downloaderExecutablePath: string, url: string) => { | |||
const result = spawnSync( | |||
downloaderExecutablePath, | |||
[ | |||
...FORMAT_DEFAULT_ARGS, | |||
'--print', 'filename', | |||
'-o', '%(ext)s', | |||
url, | |||
], | |||
); | |||
if (result.error) { | |||
throw result.error; | |||
} | |||
return result.stdout.toString('utf-8').trim(); | |||
}; | |||
export const constructDownloadSectionsRegex = (start?: number | string, end?: number | string) => { | |||
const constructDownloadSectionsRegex = (start?: number | string, end?: number | string) => { | |||
const startSeconds = normalizeStartSeconds(start); | |||
if (typeof end !== 'undefined') { | |||
const endSeconds = ( | |||
@@ -54,6 +36,24 @@ export const constructDownloadSectionsRegex = (start?: number | string, end?: nu | |||
return null; | |||
}; | |||
export const getFileExtension = (downloaderExecutablePath: string, url: string) => { | |||
const result = spawnSync( | |||
downloaderExecutablePath, | |||
[ | |||
...FORMAT_DEFAULT_ARGS, | |||
'--print', 'filename', | |||
'-o', '%(ext)s', | |||
url, | |||
], | |||
); | |||
if (result.error) { | |||
throw result.error; | |||
} | |||
return result.stdout.toString('utf-8').trim(); | |||
}; | |||
export const constructDefaultDownloadArgs = ( | |||
outputFilename: string, | |||
url: string, |
@@ -0,0 +1,3 @@ | |||
export class DownloaderNotFoundError extends Error {} | |||
export class DownloaderFailedToStartError extends Error {} |
@@ -1,62 +1,3 @@ | |||
import { EventEmitter } from 'events'; | |||
import { spawnSync } from 'child_process'; | |||
import { readFileSync, unlinkSync } from 'fs'; | |||
import { CreateBaseClipperParams, FILE_TYPES, VideoClipEventEmitter } from '../../common'; | |||
import { getFileExtension, constructDefaultDownloadArgs } from './utilities'; | |||
export interface CreateYouTubeClipperParams extends CreateBaseClipperParams { | |||
downloaderExecutablePath?: string; | |||
} | |||
export class YouTubeVideoClipEventEmitter extends EventEmitter implements VideoClipEventEmitter { | |||
constructor(private readonly params: CreateYouTubeClipperParams) { | |||
super(); | |||
} | |||
process() { | |||
if (!this.params.downloaderExecutablePath) { | |||
this.emit('error', new Error('Downloader not found.')); | |||
this.emit('end'); | |||
return; | |||
} | |||
const fileExtension = getFileExtension( | |||
this.params.downloaderExecutablePath, | |||
this.params.url, | |||
); | |||
const cacheFilename = `output.${fileExtension}`; // todo label this on the cache | |||
const downloadArgs = constructDefaultDownloadArgs( | |||
cacheFilename, | |||
this.params.url, | |||
this.params.start, | |||
this.params.end, | |||
); | |||
this.emit('process', { | |||
type: 'download', | |||
phase: 'start', | |||
command: `${this.params.downloaderExecutablePath} ${downloadArgs.join(' ')}`, | |||
}); | |||
const downloaderProcess = spawnSync( | |||
this.params.downloaderExecutablePath, | |||
downloadArgs, | |||
); | |||
if (downloaderProcess.error) { | |||
this.emit('error', downloaderProcess.error); | |||
this.emit('end'); | |||
return; | |||
} | |||
this.emit('process', { | |||
type: 'download', | |||
phase: 'success', | |||
}); | |||
const output = readFileSync(cacheFilename); | |||
unlinkSync(cacheFilename); | |||
this.emit('success', { | |||
contentType: FILE_TYPES[fileExtension], | |||
content: output, | |||
}); | |||
this.emit('end'); | |||
} | |||
} | |||
export * from './common'; | |||
export * from './clipper'; | |||
export * from './errors'; |
@@ -21,14 +21,11 @@ describe('createVideoClipper', () => { | |||
}); | |||
describe('without postprocessing', () => { | |||
let clipper: webVideoClipCore.VideoClipEventEmitter; | |||
let clipper: webVideoClipCore.ClipFunction; | |||
beforeEach(() => { | |||
clipper = webVideoClipCore.createVideoClipper({ | |||
downloaderExecutablePath: 'yt-dlp', | |||
type: webVideoClipCore.VideoType.YOUTUBE, | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
type: webVideoClipCore.YouTube.VIDEO_TYPE, | |||
}); | |||
}); | |||
@@ -36,51 +33,67 @@ describe('createVideoClipper', () => { | |||
(spawnSync as Mock).mockReset(); | |||
}); | |||
it('calls the downloader function', () => new Promise<void>((done) => { | |||
it('calls the downloader function', async () => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | |||
clipper.on('end', () => { done(); }); | |||
clipper.process(); | |||
await clipper({ | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
}); | |||
expect(spawnSync).toBeCalledTimes(2); | |||
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.arrayContaining(['--print'])); | |||
expect(spawnSync).nthCalledWith(2, 'yt-dlp', expect.anything()); | |||
})); | |||
}); | |||
it('emits downloader errors', () => new Promise<void>((done) => { | |||
it('emits downloader errors', async () => { | |||
const causeError = new Error('generic downloader message'); | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ error: causeError }); | |||
clipper.on('error', (err: Error) => { | |||
expect(err).toHaveProperty('message', causeError.message); | |||
}); | |||
clipper.on('end', () => { done(); }); | |||
clipper.process(); | |||
})); | |||
try { | |||
await clipper({ | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
}) | |||
} catch (errRaw) { | |||
const err = errRaw as Error; | |||
expect(err).toBeInstanceOf(webVideoClipCore.YouTube.DownloaderFailedToStartError); | |||
expect(err.cause).toBe(causeError); | |||
expect(err).toHaveProperty('message', 'Downloader failed to start.'); | |||
} | |||
}); | |||
it('calls the buffer extract function', () => new Promise<void>((done) => { | |||
it('calls the buffer extract function', async () => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | |||
clipper.on('end', () => { done(); }); | |||
clipper.process(); | |||
await clipper({ | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
}); | |||
expect(readFileSync).toBeCalled(); | |||
})); | |||
}); | |||
it('calls the cleanup function', () => new Promise<void>((done) => { | |||
it('calls the cleanup function', async () => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | |||
clipper.on('end', () => { done(); }); | |||
clipper.process(); | |||
await clipper({ | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
}); | |||
expect(unlinkSync).toBeCalled(); | |||
})); | |||
}); | |||
}); | |||
}); |