diff --git a/src/common.ts b/src/common.ts index cf10821..128eac0 100644 --- a/src/common.ts +++ b/src/common.ts @@ -4,7 +4,7 @@ type ProcessEventCallback = (event: ProcessEvent) => void; type ErrorEventCallback = (event: Error) => void; -type SuccessEvent = { type: string, output: Buffer }; +type SuccessEvent = { contentType: string, content: Buffer }; type SuccessEventCallback = (event: SuccessEvent) => void; @@ -12,7 +12,7 @@ export interface VideoClipEventEmitter extends NodeJS.EventEmitter { process(): void; on(eventType: 'process', callback: ProcessEventCallback): this; on(eventType: 'error', callback: ErrorEventCallback): this; - on(eventType: 'end', callback: ErrorEventCallback): this; + on(eventType: 'end', callback: () => void): this; on(eventType: 'success', callback: SuccessEventCallback): this; } diff --git a/src/video-types/youtube.ts b/src/video-types/youtube.ts deleted file mode 100644 index 7a27a9f..0000000 --- a/src/video-types/youtube.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { EventEmitter } from 'events'; -import { spawnSync } from 'child_process'; -import { readFileSync, unlinkSync } from 'fs'; -import { CreateBaseClipperParams, FILE_TYPES, VideoClipEventEmitter } from '../common'; -import { convertDurationToSeconds, convertSecondsToDuration } from '../duration'; - -const normalizeStartSeconds = (start?: number | string) => { - if (typeof start === 'undefined') { - return 0; - } - if (typeof start === 'string') { - return convertDurationToSeconds(start); - } - - return start; -}; - -const FORMAT_DEFAULT_ARGS = [ - '--youtube-skip-dash-manifest', - '-f', 'bestvideo+bestaudio', -]; - -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(); -}; - -const constructDownloadSectionsRegex = (start?: number | string, end?: number | string) => { - const startSeconds = normalizeStartSeconds(start); - if (typeof end !== 'undefined') { - const endSeconds = ( - typeof end === 'string' - ? convertDurationToSeconds(end) - : end - ); - - return `*${convertSecondsToDuration(startSeconds)}-${convertSecondsToDuration(endSeconds)}`; - } - - if (startSeconds > 0) { - return `*${convertSecondsToDuration(startSeconds)}-inf`; - } - - return null; -}; - -const constructDefaultDownloadArgs = ( - outputFilename: string, - url: string, - start?: number | string, - end?: number | string, -) => { - const defaultDownloadArgs = [ - ...FORMAT_DEFAULT_ARGS, - '-o', outputFilename, - ]; - - const downloadSectionsRegex = constructDownloadSectionsRegex(start, end); - if (typeof downloadSectionsRegex === 'string') { - return [ - ...defaultDownloadArgs, - '--force-keyframes-at-cuts', - '--download-sections', downloadSectionsRegex, - url, - ]; - } - - return [ - ...defaultDownloadArgs, - url, - ]; -}; - -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', { - type: FILE_TYPES[fileExtension], - output, - }); - this.emit('end'); - } -} diff --git a/src/video-types/youtube/index.ts b/src/video-types/youtube/index.ts new file mode 100644 index 0000000..2279928 --- /dev/null +++ b/src/video-types/youtube/index.ts @@ -0,0 +1,62 @@ +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'); + } +} diff --git a/src/video-types/youtube/utilities.ts b/src/video-types/youtube/utilities.ts new file mode 100644 index 0000000..0c8af06 --- /dev/null +++ b/src/video-types/youtube/utilities.ts @@ -0,0 +1,82 @@ +import { spawnSync } from 'child_process'; +import { convertDurationToSeconds, convertSecondsToDuration } from '../../duration'; + +export const normalizeStartSeconds = (start?: number | string) => { + if (typeof start === 'undefined') { + return 0; + } + if (typeof start === 'string') { + return convertDurationToSeconds(start); + } + + return start; +}; + +const FORMAT_DEFAULT_ARGS = [ + '--youtube-skip-dash-manifest', + '-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 startSeconds = normalizeStartSeconds(start); + if (typeof end !== 'undefined') { + const endSeconds = ( + typeof end === 'string' + ? convertDurationToSeconds(end) + : end + ); + + return `*${convertSecondsToDuration(startSeconds)}-${convertSecondsToDuration(endSeconds)}`; + } + + if (startSeconds > 0) { + return `*${convertSecondsToDuration(startSeconds)}-inf`; + } + + return null; +}; + +export const constructDefaultDownloadArgs = ( + outputFilename: string, + url: string, + start?: number | string, + end?: number | string, +) => { + const defaultDownloadArgs = [ + ...FORMAT_DEFAULT_ARGS, + '-o', outputFilename, + ]; + + const downloadSectionsRegex = constructDownloadSectionsRegex(start, end); + if (typeof downloadSectionsRegex === 'string') { + return [ + ...defaultDownloadArgs, + '--force-keyframes-at-cuts', + '--download-sections', downloadSectionsRegex, + url, + ]; + } + + return [ + ...defaultDownloadArgs, + url, + ]; +}; diff --git a/test/index.test.ts b/test/index.test.ts index 23f398c..f370907 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -36,19 +36,19 @@ describe('createVideoClipper', () => { (spawnSync as Mock).mockReset(); }); - it('calls the downloader function', () => new Promise((done) => { + it('calls the downloader function', () => new Promise((done) => { (spawnSync as Mock) .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) .mockReturnValueOnce({ stdout: Buffer.from('') }); - clipper.on('end', done); + clipper.on('end', () => { done(); }); clipper.process(); 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', () => new Promise((done) => { const causeError = new Error('generic downloader message'); (spawnSync as Mock) .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) @@ -57,27 +57,27 @@ describe('createVideoClipper', () => { clipper.on('error', (err: Error) => { expect(err).toHaveProperty('message', causeError.message); }); - clipper.on('end', done); + clipper.on('end', () => { done(); }); clipper.process(); })); - it('calls the buffer extract function', () => new Promise((done) => { + it('calls the buffer extract function', () => new Promise((done) => { (spawnSync as Mock) .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) .mockReturnValueOnce({ stdout: Buffer.from('') }); - clipper.on('end', done); + clipper.on('end', () => { done(); }); clipper.process(); expect(readFileSync).toBeCalled(); })); - it('calls the cleanup function', () => new Promise((done) => { + it('calls the cleanup function', () => new Promise((done) => { (spawnSync as Mock) .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) .mockReturnValueOnce({ stdout: Buffer.from('') }); - clipper.on('end', done); + clipper.on('end', () => { done(); }); clipper.process(); expect(unlinkSync).toBeCalled();