diff --git a/src/index.ts b/src/index.ts index 19f0579..6c79831 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,11 +10,11 @@ interface ClipVideoBaseParams { url: string; start?: number | string; end?: number | string; + postprocessorExecutablePath?: string; } interface ClipYouTubeVideoParams extends ClipVideoBaseParams { downloaderExecutablePath?: string; - postprocessorExecutablePath?: string; } interface ClipVideoParams extends ClipYouTubeVideoParams { @@ -38,6 +38,22 @@ const convertDurationToSeconds = (d: string) => { return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss); }; +const normalizeStartSeconds = (start?: number | string) => { + if (typeof start === 'undefined') { + return 0; + } + if (typeof start === 'string') { + return convertDurationToSeconds(start); + } + + return start; +}; + +const getCacheFilename = () => { + const time = new Date().getTime(); + return `output-${time}.mkv`; +}; + export interface ClipEventEmitter extends EventEmitter { process(): void; } @@ -47,33 +63,96 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit super(); } - process() { - if (!this.params.downloaderExecutablePath) { - this.emit('error', new Error('Downloader not found.')); - this.emit('end', null); - return; - } - - if (!this.params.postprocessorExecutablePath) { - this.emit('error', new Error('ffmpeg not found.')); - this.emit('end', null); - return; - } + private constructDefaultDownloadArgs(cacheFilename: string) { + const startSeconds = normalizeStartSeconds(this.params.start); const downloadArgs = [ '--youtube-skip-dash-manifest', '-f', 'bestvideo+bestaudio', - '-g', this.params.url, + '--fragment-retries', 'infinite', ]; + + if (typeof this.params.end !== 'undefined') { + downloadArgs.push('--download-sections'); + const endSeconds = ( + typeof this.params.end === 'string' + ? convertDurationToSeconds(this.params.end) + : this.params.end + ); + downloadArgs.push( + `*${convertSecondsToDuration(startSeconds)}-${convertSecondsToDuration(endSeconds)}`, + ); + downloadArgs.push('-o'); + downloadArgs.push(cacheFilename); + downloadArgs.push('--force-keyframes-at-cuts'); + if (startSeconds > 0) { + downloadArgs.push(`${this.params.url}?t=${startSeconds}`); + } else { + downloadArgs.push(this.params.url); + } + } else if (startSeconds > 0) { + downloadArgs.push('--download-sections'); + downloadArgs.push(`*${convertSecondsToDuration(startSeconds)}-inf`); + downloadArgs.push('-o'); + downloadArgs.push(cacheFilename); + downloadArgs.push('--force-keyframes-at-cuts'); + downloadArgs.push(`${this.params.url}?t=${startSeconds}`); + } else { + downloadArgs.push('-o'); + downloadArgs.push(cacheFilename); + downloadArgs.push(this.params.url); + } + + return downloadArgs; + } + + private doDownloadProcess(downloadArgs: string[]) { this.emit('process', { type: 'download', phase: 'start', - command: `${this.params.downloaderExecutablePath} ${downloadArgs.join(' ')}`, + command: `${this.params.downloaderExecutablePath as string} ${downloadArgs.join(' ')}`, }); - const downloaderProcess = spawnSync(this.params.downloaderExecutablePath, downloadArgs); + const downloaderProcess = spawnSync( + this.params.downloaderExecutablePath as string, + downloadArgs, + ); if (downloaderProcess.error) { this.emit('error', new Error('Downloader error.', { cause: downloaderProcess.error })); this.emit('end', null); + return null; + } + return downloaderProcess; + } + + private processDefault() { + const cacheFilename = getCacheFilename(); // todo label this on the cache + const downloadArgs = this.constructDefaultDownloadArgs(cacheFilename); + const downloaderProcess = this.doDownloadProcess(downloadArgs); + if (!downloaderProcess) { + return; + } + + this.emit('process', { + type: 'download', + phase: 'success', + }); + + const output = readFileSync(cacheFilename); + unlinkSync(cacheFilename); + this.emit('success', { + output, + }); + this.emit('end'); + } + + private processWithPostprocessing() { + const downloadArgs = [ + '--youtube-skip-dash-manifest', + '-f', 'bestvideo+bestaudio', + '-g', this.params.url, + ]; + const downloaderProcess = this.doDownloadProcess(downloadArgs); + if (!downloaderProcess) { return; } @@ -87,15 +166,7 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit }, }); - let startSeconds: number; - if (typeof this.params.start === 'undefined') { - startSeconds = 0; - } else if (typeof this.params.start === 'string') { - startSeconds = convertDurationToSeconds(this.params.start); - } else { - startSeconds = this.params.start; - } - + const startSeconds = normalizeStartSeconds(this.params.start); const ffmpegProcessVideoStreamArgs = [ '-ss', (this.params.start ?? '00:00:00').toString(), '-i', videoUrlStream, @@ -124,7 +195,7 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit ffmpegProcessAudioStreamArgs.push(clipDuration); } - const cacheFilename = 'output.webm'; // todo label this on the cache + const cacheFilename = getCacheFilename(); // todo label this on the cache const ffmpegProcessArgs = [ ...ffmpegProcessVideoStreamArgs, ...ffmpegProcessAudioStreamArgs, @@ -135,23 +206,42 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit this.emit('process', { type: 'postprocess', phase: 'start', - command: `${this.params.postprocessorExecutablePath} ${ffmpegProcessArgs.join(' ')}`, + command: `${this.params.postprocessorExecutablePath as string} ${ffmpegProcessArgs.join(' ')}`, }); - const ffmpegProcess = spawnSync(this.params.postprocessorExecutablePath, ffmpegProcessArgs); + const ffmpegProcess = spawnSync( + this.params.postprocessorExecutablePath as string, + ffmpegProcessArgs, + ); if (ffmpegProcess.error) { this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error })); this.emit('end'); return; } - const output = readFileSync(cacheFilename); - unlinkSync(cacheFilename); this.emit('process', { type: 'postprocess', phase: 'success', + }); + const output = readFileSync(cacheFilename); + unlinkSync(cacheFilename); + this.emit('success', { output, }); this.emit('end'); } + + process() { + if (!this.params.downloaderExecutablePath) { + this.emit('error', new Error('Downloader not found.')); + this.emit('end', null); + return; + } + + if (this.params.postprocessorExecutablePath) { + this.processWithPostprocessing(); + return; + } + this.processDefault(); + } } export const createVideoClipper = (params: ClipVideoParams) => { diff --git a/test/index.test.ts b/test/index.test.ts index 60b08ea..07453e3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -20,87 +20,152 @@ describe('createVideoClipper', () => { (readFileSync as Mock).mockReturnValueOnce(Buffer.from('')); }); - let clipper: ClipEventEmitter; - beforeEach(() => { - clipper = webVideoClipCore.createVideoClipper({ - downloaderExecutablePath: 'yt-dlp', - postprocessorExecutablePath: 'ffmpeg', - type: webVideoClipCore.VideoType.YOUTUBE, - url: 'https://www.youtube.com/watch?v=BaW_jenozKc', - start: 0, - end: 0, + describe('with postprocessing', () => { + let clipper: ClipEventEmitter; + beforeEach(() => { + clipper = webVideoClipCore.createVideoClipper({ + downloaderExecutablePath: 'yt-dlp', + postprocessorExecutablePath: 'ffmpeg', + type: webVideoClipCore.VideoType.YOUTUBE, + url: 'https://www.youtube.com/watch?v=BaW_jenozKc', + start: 0, + end: 0, + }); }); - }); - - afterEach(() => { - (spawnSync as Mock).mockReset(); - }); - - it('calls the downloader function', () => new Promise((done) => { - (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); - - clipper.on('end', done); - clipper.process(); - expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything()); - })); - - it('emits downloader errors', () => new Promise((done) => { - const causeError = new Error('generic downloader message'); - (spawnSync as Mock) - .mockReturnValueOnce({ error: causeError }) - .mockReturnValueOnce({}); - clipper.on('error', (err: Error) => { - expect(err.cause).toHaveProperty('message', causeError.message); + afterEach(() => { + (spawnSync as Mock).mockReset(); }); - clipper.on('end', done); - clipper.process(); - })); - - it('calls the postprocess function', () => new Promise((done) => { - (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); - - clipper.on('end', done); - clipper.process(); - expect(spawnSync).nthCalledWith(2, 'ffmpeg', expect.anything()); - })); - - it('emits postprocess errors', () => new Promise((done) => { - const causeError = new Error('generic postprocessor message'); - (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({ error: causeError }); - - clipper.on('error', (err: Error) => { - expect(err.cause).toHaveProperty('message', causeError.message); - }); - clipper.on('end', done); - clipper.process(); - })); - - it('calls the buffer extract function', () => new Promise((done) => { - (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); - clipper.on('end', done); - clipper.process(); - - expect(readFileSync).toBeCalled(); - })); + it('calls the downloader function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything()); + })); + + it('emits downloader errors', () => new Promise((done) => { + const causeError = new Error('generic downloader message'); + (spawnSync as Mock) + .mockReturnValueOnce({ error: causeError }) + .mockReturnValueOnce({}); + + clipper.on('error', (err: Error) => { + expect(err.cause).toHaveProperty('message', causeError.message); + }); + clipper.on('end', done); + clipper.process(); + })); + + it('calls the postprocess function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + expect(spawnSync).nthCalledWith(2, 'ffmpeg', expect.anything()); + })); + + it('emits postprocess errors', () => new Promise((done) => { + const causeError = new Error('generic postprocessor message'); + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({ error: causeError }); + + clipper.on('error', (err: Error) => { + expect(err.cause).toHaveProperty('message', causeError.message); + }); + clipper.on('end', done); + clipper.process(); + })); + + it('calls the buffer extract function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + + expect(readFileSync).toBeCalled(); + })); + + it('calls the cleanup function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + + expect(unlinkSync).toBeCalled(); + })); + }); - it('calls the cleanup function', () => new Promise((done) => { - (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); + describe('without postprocessing', () => { + let clipper: ClipEventEmitter; + beforeEach(() => { + clipper = webVideoClipCore.createVideoClipper({ + downloaderExecutablePath: 'yt-dlp', + type: webVideoClipCore.VideoType.YOUTUBE, + url: 'https://www.youtube.com/watch?v=BaW_jenozKc', + start: 0, + end: 0, + }); + }); - clipper.on('end', done); - clipper.process(); + afterEach(() => { + (spawnSync as Mock).mockReset(); + }); - expect(unlinkSync).toBeCalled(); - })); + it('calls the downloader function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + expect(spawnSync).toBeCalledTimes(1); + expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything()); + })); + + it('emits downloader errors', () => new Promise((done) => { + const causeError = new Error('generic downloader message'); + (spawnSync as Mock) + .mockReturnValueOnce({ error: causeError }) + .mockReturnValueOnce({}); + + clipper.on('error', (err: Error) => { + expect(err.cause).toHaveProperty('message', causeError.message); + }); + clipper.on('end', done); + clipper.process(); + })); + + it('calls the buffer extract function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + + expect(readFileSync).toBeCalled(); + })); + + it('calls the cleanup function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); + + clipper.on('end', done); + clipper.process(); + + expect(unlinkSync).toBeCalled(); + })); + }); });