diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..1d63fcd --- /dev/null +++ b/src/common.ts @@ -0,0 +1,24 @@ +type ProcessEvent = { type: string, phase: string, command?: string }; + +type ProcessEventCallback = (event: ProcessEvent) => void; + +type ErrorEventCallback = (event: Error) => 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: ErrorEventCallback): this; +} + +export const FILE_TYPES: Record = { + mkv: 'video/x-matroska', + webm: 'video/webm', + mp4: 'video/mp4', +}; + +export interface CreateBaseClipperParams { + url: string; + start?: number | string; + end?: number | string; +} diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..3e5343f --- /dev/null +++ b/src/duration.ts @@ -0,0 +1,16 @@ +export const convertSecondsToDuration = (s: number) => { + const milliseconds = (s - Math.floor(s)) * 1000; + const seconds = s % 60; + const minutes = Math.floor(s / 60) % 60; + const hours = Math.floor(s / 3600); + const sss = milliseconds.toString().padStart(3, '0'); + const ss = seconds.toString().padStart(2, '0'); + const mm = minutes.toString().padStart(2, '0'); + const hh = hours.toString().padStart(2, '0'); + return `${hh}:${mm}:${ss}.${sss}`; +}; + +export const convertDurationToSeconds = (d: string) => { + const [hh, mm, ss] = d.split(':'); + return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss); +}; diff --git a/src/index.ts b/src/index.ts index 0804aa5..9be4df5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,266 +1,27 @@ -import { spawnSync } from 'child_process'; -import { unlinkSync, readFileSync } from 'fs'; -import { EventEmitter } from 'events'; +import { CreateYouTubeClipperParams, YouTubeVideoClipEventEmitter } from './video-types/youtube'; +import { VideoClipEventEmitter } from './common'; export enum VideoType { YOUTUBE = 'youtube', } -interface CreateBaseClipperParams { - url: string; - start?: number | string; - end?: number | string; - postprocessorExecutablePath?: string; -} - -interface CreateYouTubeClipperParams extends CreateBaseClipperParams { - downloaderExecutablePath?: string; -} - export interface CreateVideoClipperParams extends CreateYouTubeClipperParams { type: VideoType, } -const convertSecondsToDuration = (s: number) => { - const milliseconds = (s - Math.floor(s)) * 1000; - const seconds = s % 60; - const minutes = Math.floor(s / 60) % 60; - const hours = Math.floor(s / 3600); - const sss = milliseconds.toString().padStart(3, '0'); - const ss = seconds.toString().padStart(2, '0'); - const mm = minutes.toString().padStart(2, '0'); - const hh = hours.toString().padStart(2, '0'); - return `${hh}:${mm}:${ss}.${sss}`; -}; - -const convertDurationToSeconds = (d: string) => { - const [hh, mm, ss] = d.split(':'); - 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 VideoClipEventEmitter extends EventEmitter { - process(): void; -} - -class YouTubeVideoClipEventEmitter extends EventEmitter implements VideoClipEventEmitter { - constructor(private readonly params: CreateYouTubeClipperParams) { - super(); - } - - private constructDefaultDownloadArgs(cacheFilename: string) { - const startSeconds = normalizeStartSeconds(this.params.start); - - const downloadArgs = [ - '--youtube-skip-dash-manifest', - '-f', 'bestvideo+bestaudio', - '--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 as string} ${downloadArgs.join(' ')}`, - }); - 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', { - type: 'video/webm', - 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; - } - - const [videoUrlStream, audioUrlStream] = downloaderProcess.stdout.toString('utf-8').split('\n'); - this.emit('process', { - type: 'download', - phase: 'success', - streamUrls: { - video: [videoUrlStream], - audio: [audioUrlStream], - }, - }); - - const startSeconds = normalizeStartSeconds(this.params.start); - const ffmpegProcessVideoStreamArgs = [ - '-ss', (this.params.start ?? '00:00:00').toString(), - '-i', videoUrlStream, - ]; - - const ffmpegProcessAudioStreamArgs = [ - '-ss', (this.params.start ?? '00:00:00').toString(), - '-i', audioUrlStream, - ]; - - if (typeof this.params.end !== 'undefined') { - // -to flag is broken on video stream in ffmpeg..... - const endSeconds = ( - typeof this.params.end === 'string' - ? convertDurationToSeconds(this.params.end) - : this.params.end - ); - - const difference = endSeconds - startSeconds; - const clipDuration = convertSecondsToDuration(difference); - - ffmpegProcessVideoStreamArgs.push('-t'); - ffmpegProcessVideoStreamArgs.push(clipDuration); - - ffmpegProcessAudioStreamArgs.push('-t'); - ffmpegProcessAudioStreamArgs.push(clipDuration); - } - - const cacheFilename = getCacheFilename(); // todo label this on the cache - const ffmpegProcessArgs = [ - ...ffmpegProcessVideoStreamArgs, - ...ffmpegProcessAudioStreamArgs, - '-map', '0:v', - '-map', '1:a', - cacheFilename, - ]; - this.emit('process', { - type: 'postprocess', - phase: 'start', - command: `${this.params.postprocessorExecutablePath as string} ${ffmpegProcessArgs.join(' ')}`, - }); - 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; - } - this.emit('process', { - type: 'postprocess', - phase: 'success', - }); - const output = readFileSync(cacheFilename); - unlinkSync(cacheFilename); - this.emit('success', { - type: 'video/webm', - 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: CreateVideoClipperParams) => { +export const createVideoClipper = (params: CreateVideoClipperParams): VideoClipEventEmitter => { const { type: videoType, url, start, end, downloaderExecutablePath, - postprocessorExecutablePath, } = params; switch (videoType as string) { case VideoType.YOUTUBE: return new YouTubeVideoClipEventEmitter({ downloaderExecutablePath, - postprocessorExecutablePath, url, start, end, @@ -271,3 +32,5 @@ export const createVideoClipper = (params: CreateVideoClipperParams) => { throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`); }; + +export { VideoClipEventEmitter }; diff --git a/src/video-types/youtube.ts b/src/video-types/youtube.ts new file mode 100644 index 0000000..0e25b52 --- /dev/null +++ b/src/video-types/youtube.ts @@ -0,0 +1,142 @@ +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'); +}; + +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/test/index.test.ts b/test/index.test.ts index 55045c3..23f398c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,113 +1,27 @@ import { - describe, it, expect, vi, beforeEach, Mock, afterEach, + describe, + it, + expect, + vi, + beforeEach, + Mock, + afterEach, } from 'vitest'; import { spawnSync } from 'child_process'; import { readFileSync, unlinkSync } from 'fs'; import * as webVideoClipCore from '../src'; -import { VideoClipEventEmitter } from '../src'; -vi.mock('child_process', () => ({ - spawnSync: vi.fn(), -})); +vi.mock('child_process'); -vi.mock('fs', () => ({ - readFileSync: vi.fn(), - unlinkSync: vi.fn(), -})); +vi.mock('fs'); describe('createVideoClipper', () => { beforeEach(() => { (readFileSync as Mock).mockReturnValueOnce(Buffer.from('')); }); - describe('with postprocessing', () => { - let clipper: VideoClipEventEmitter; - 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); - }); - 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(); - })); - }); - describe('without postprocessing', () => { - let clipper: VideoClipEventEmitter; + let clipper: webVideoClipCore.VideoClipEventEmitter; beforeEach(() => { clipper = webVideoClipCore.createVideoClipper({ downloaderExecutablePath: 'yt-dlp', @@ -124,23 +38,24 @@ describe('createVideoClipper', () => { it('calls the downloader function', () => new Promise((done) => { (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); + .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) + .mockReturnValueOnce({ stdout: Buffer.from('') }); clipper.on('end', done); clipper.process(); - expect(spawnSync).toBeCalledTimes(1); - expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything()); + 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) => { const causeError = new Error('generic downloader message'); (spawnSync as Mock) - .mockReturnValueOnce({ error: causeError }) - .mockReturnValueOnce({}); + .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) + .mockReturnValueOnce({ error: causeError }); clipper.on('error', (err: Error) => { - expect(err.cause).toHaveProperty('message', causeError.message); + expect(err).toHaveProperty('message', causeError.message); }); clipper.on('end', done); clipper.process(); @@ -148,8 +63,8 @@ describe('createVideoClipper', () => { it('calls the buffer extract function', () => new Promise((done) => { (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); + .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) + .mockReturnValueOnce({ stdout: Buffer.from('') }); clipper.on('end', done); clipper.process(); @@ -159,8 +74,8 @@ describe('createVideoClipper', () => { it('calls the cleanup function', () => new Promise((done) => { (spawnSync as Mock) - .mockReturnValueOnce({ stdout: Buffer.from('') }) - .mockReturnValueOnce({}); + .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) + .mockReturnValueOnce({ stdout: Buffer.from('') }); clipper.on('end', done); clipper.process(); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 459f2a1..1e2f6f1 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -14,8 +14,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "moduleResolution": "node", - "jsx": "react", "esModuleInterop": true, - "target": "es2018" + "target": "es2022" } } diff --git a/tsconfig.json b/tsconfig.json index e210542..253c1fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "exclude": ["node_modules"], - "include": ["src", "types"], + "include": ["src", "types", "test"], "compilerOptions": { "module": "ESNext", "lib": ["ESNext"], @@ -14,8 +14,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "moduleResolution": "node", - "jsx": "react", "esModuleInterop": true, - "target": "es2018" + "target": "es2022" } }