From 35e49a899446f94a41d5d2c15c4fc495debbe9ea Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 1 May 2023 13:23:38 +0800 Subject: [PATCH] Refactor core Use simple Promise-based API. --- src/common.ts | 26 +++----- src/index.ts | 49 ++++++-------- src/video-types/youtube/clipper.ts | 47 ++++++++++++++ src/video-types/youtube/common.ts | 1 + .../youtube/{utilities.ts => downloader.ts} | 40 ++++++------ src/video-types/youtube/errors.ts | 3 + src/video-types/youtube/index.ts | 65 +------------------ test/index.test.ts | 61 ++++++++++------- 8 files changed, 140 insertions(+), 152 deletions(-) create mode 100644 src/video-types/youtube/clipper.ts create mode 100644 src/video-types/youtube/common.ts rename src/video-types/youtube/{utilities.ts => downloader.ts} (91%) create mode 100644 src/video-types/youtube/errors.ts diff --git a/src/common.ts b/src/common.ts index 128eac0..471ee2d 100644 --- a/src/common.ts +++ b/src/common.ts @@ -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; export const FILE_TYPES: Record = { 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; diff --git a/src/index.ts b/src/index.ts index 9be4df5..9559b87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }; diff --git a/src/video-types/youtube/clipper.ts b/src/video-types/youtube/clipper.ts new file mode 100644 index 0000000..1c4c2c1 --- /dev/null +++ b/src/video-types/youtube/clipper.ts @@ -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) => (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, + }); +} diff --git a/src/video-types/youtube/common.ts b/src/video-types/youtube/common.ts new file mode 100644 index 0000000..640c91f --- /dev/null +++ b/src/video-types/youtube/common.ts @@ -0,0 +1 @@ +export const VIDEO_TYPE = 'youtube' as const; diff --git a/src/video-types/youtube/utilities.ts b/src/video-types/youtube/downloader.ts similarity index 91% rename from src/video-types/youtube/utilities.ts rename to src/video-types/youtube/downloader.ts index 0c8af06..eb7c2f9 100644 --- a/src/video-types/youtube/utilities.ts +++ b/src/video-types/youtube/downloader.ts @@ -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, diff --git a/src/video-types/youtube/errors.ts b/src/video-types/youtube/errors.ts new file mode 100644 index 0000000..716def2 --- /dev/null +++ b/src/video-types/youtube/errors.ts @@ -0,0 +1,3 @@ +export class DownloaderNotFoundError extends Error {} + +export class DownloaderFailedToStartError extends Error {} diff --git a/src/video-types/youtube/index.ts b/src/video-types/youtube/index.ts index 2279928..6665d8c 100644 --- a/src/video-types/youtube/index.ts +++ b/src/video-types/youtube/index.ts @@ -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'; diff --git a/test/index.test.ts b/test/index.test.ts index f370907..675c74f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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((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((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((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((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(); - })); + }); }); });