@@ -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> = { | export const FILE_TYPES: Record<string, string> = { | ||||
mkv: 'video/x-matroska', | mkv: 'video/x-matroska', | ||||
webm: 'video/webm', | webm: 'video/webm', | ||||
mp4: 'video/mp4', | mp4: 'video/mp4', | ||||
}; | }; | ||||
export interface CreateBaseClipperParams { | |||||
export interface CreateClipperParams { | |||||
downloaderExecutablePath: string; | |||||
} | |||||
export interface ClipVideoParams { | |||||
url: string; | url: string; | ||||
start?: number | string; | start?: number | string; | ||||
end?: 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 { spawnSync } from 'child_process'; | ||||
import { convertDurationToSeconds, convertSecondsToDuration } from '../../duration'; | import { convertDurationToSeconds, convertSecondsToDuration } from '../../duration'; | ||||
export const normalizeStartSeconds = (start?: number | string) => { | |||||
const normalizeStartSeconds = (start?: number | string) => { | |||||
if (typeof start === 'undefined') { | if (typeof start === 'undefined') { | ||||
return 0; | return 0; | ||||
} | } | ||||
@@ -17,25 +17,7 @@ const FORMAT_DEFAULT_ARGS = [ | |||||
'-f', 'bestvideo+bestaudio', | '-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); | const startSeconds = normalizeStartSeconds(start); | ||||
if (typeof end !== 'undefined') { | if (typeof end !== 'undefined') { | ||||
const endSeconds = ( | const endSeconds = ( | ||||
@@ -54,6 +36,24 @@ export const constructDownloadSectionsRegex = (start?: number | string, end?: nu | |||||
return null; | 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 = ( | export const constructDefaultDownloadArgs = ( | ||||
outputFilename: string, | outputFilename: string, | ||||
url: 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', () => { | describe('without postprocessing', () => { | ||||
let clipper: webVideoClipCore.VideoClipEventEmitter; | |||||
let clipper: webVideoClipCore.ClipFunction; | |||||
beforeEach(() => { | beforeEach(() => { | ||||
clipper = webVideoClipCore.createVideoClipper({ | clipper = webVideoClipCore.createVideoClipper({ | ||||
downloaderExecutablePath: 'yt-dlp', | 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(); | (spawnSync as Mock).mockReset(); | ||||
}); | }); | ||||
it('calls the downloader function', () => new Promise<void>((done) => { | |||||
it('calls the downloader function', async () => { | |||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | .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).toBeCalledTimes(2); | ||||
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.arrayContaining(['--print'])); | expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.arrayContaining(['--print'])); | ||||
expect(spawnSync).nthCalledWith(2, 'yt-dlp', expect.anything()); | 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'); | const causeError = new Error('generic downloader message'); | ||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ error: causeError }); | .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) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | .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(); | expect(readFileSync).toBeCalled(); | ||||
})); | |||||
}); | |||||
it('calls the cleanup function', () => new Promise<void>((done) => { | |||||
it('calls the cleanup function', async () => { | |||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | .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(); | expect(unlinkSync).toBeCalled(); | ||||
})); | |||||
}); | |||||
}); | }); | ||||
}); | }); |