diff --git a/.gitignore b/.gitignore index 3821511..14b30a9 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,4 @@ dist vendor test/*.webm .idea/ +scenarios/ diff --git a/package.json b/package.json index 4db0a37..3a804c5 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "pridepack" ], "devDependencies": { + "@types/dotenv": "^8.2.0", "@types/node": "^18.14.1", + "dotenv": "^16.0.3", "eslint": "^8.35.0", "eslint-config-lxsmnsyc": "^0.5.0", "pridepack": "2.4.2", "tslib": "^2.5.0", + "tsx": "^3.12.6", "typescript": "^4.9.5", "vitest": "^0.28.1" }, @@ -30,7 +33,8 @@ "watch": "pridepack watch", "start": "pridepack start", "dev": "pridepack dev", - "test": "vitest" + "test": "vitest", + "tsx": "tsx" }, "private": false, "description": "Clip YouTube videos.", diff --git a/src/index.ts b/src/index.ts index b19b158..1a883ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,53 +1,173 @@ import { spawnSync } from 'child_process'; import { unlinkSync, readFileSync } from 'fs'; +import { EventEmitter } from 'events'; export enum VideoType { YOUTUBE = 'youtube', } interface ClipVideoBaseParams { - url: string, - start: number | string, - end: number | string, + url: string; + start?: number | string; + end?: number | string; } interface ClipYouTubeVideoParams extends ClipVideoBaseParams { downloaderExecutablePath?: string; + postprocessorExecutablePath?: string; } interface ClipVideoParams extends ClipYouTubeVideoParams { type: VideoType, } -const clipYouTubeVideo = (params: ClipYouTubeVideoParams) => { - if (!params.downloaderExecutablePath) { - throw new Error('Downloader not found.'); +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); +}; + +export interface ClipEventEmitter extends EventEmitter { + process(): void; +} + +class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmitter { + constructor(private readonly params: ClipYouTubeVideoParams) { + super(); } - spawnSync( - params.downloaderExecutablePath, - [ - '--postprocessor-args', - `-ss ${params.start.toString()} -to ${params.end.toString()}`, - '-o', + + 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; + } + + const downloadArgs = [ + '--youtube-skip-dash-manifest', + '-f', 'bestvideo+bestaudio', + '-g', this.params.url, + ]; + 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', new Error('Downloader error.', { cause: downloaderProcess.error })); + this.emit('end', null); + return; + } + + const [videoUrlStream, audioUrlStream] = downloaderProcess.stdout.toString('utf-8').split('\n'); + this.emit('process', { + type: 'download', + phase: 'success', + streamUrls: { + video: [videoUrlStream], + audio: [audioUrlStream], + }, + }); + + 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 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 ffmpegProcessArgs = [ + ...ffmpegProcessVideoStreamArgs, + ...ffmpegProcessAudioStreamArgs, + '-map', '0:v', + '-map', '1:a', 'output.webm', - params.url, - ], - ); - const output = readFileSync('output.webm'); - unlinkSync('output.webm'); - return output; -}; + ]; + this.emit('process', { + type: 'postprocess', + phase: 'start', + command: `${this.params.postprocessorExecutablePath} ${ffmpegProcessArgs.join(' ')}`, + }); + const ffmpegProcess = spawnSync(this.params.postprocessorExecutablePath, ffmpegProcessArgs); + if (ffmpegProcess.error) { + this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error })); + this.emit('end'); + return; + } + const output = readFileSync('output.webm'); + unlinkSync('output.webm'); + this.emit('process', { + type: 'postprocess', + phase: 'success', + output, + }); + this.emit('end'); + } +} -export const clipVideo = (params: ClipVideoParams) => { +export const createVideoClipper = (params: ClipVideoParams) => { const { - type: videoType, url, start, end, + type: videoType, + url, + start, + end, downloaderExecutablePath, + postprocessorExecutablePath, } = params; switch (videoType as string) { case VideoType.YOUTUBE: - return clipYouTubeVideo({ + return new YouTubeVideoClipEventEmitter({ downloaderExecutablePath, + postprocessorExecutablePath, url, start, end, diff --git a/test/index.test.ts b/test/index.test.ts index 645bf3f..60b08ea 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,9 +1,10 @@ import { - describe, it, expect, vi, beforeEach, Mock, + describe, it, expect, vi, beforeEach, Mock, afterEach, } from 'vitest'; import { spawnSync } from 'child_process'; import { readFileSync, unlinkSync } from 'fs'; -import * as youtubeClipCore from '../src'; +import * as webVideoClipCore from '../src'; +import { ClipEventEmitter } from '../src'; vi.mock('child_process', () => ({ spawnSync: vi.fn(), @@ -14,44 +15,92 @@ vi.mock('fs', () => ({ unlinkSync: vi.fn(), })); -describe('clipVideo', () => { +describe('createVideoClipper', () => { beforeEach(() => { (readFileSync as Mock).mockReturnValueOnce(Buffer.from('')); }); - it('calls the downloader function', () => { - youtubeClipCore.clipVideo({ + let clipper: ClipEventEmitter; + beforeEach(() => { + clipper = webVideoClipCore.createVideoClipper({ downloaderExecutablePath: 'yt-dlp', - type: youtubeClipCore.VideoType.YOUTUBE, + postprocessorExecutablePath: 'ffmpeg', + type: webVideoClipCore.VideoType.YOUTUBE, url: 'https://www.youtube.com/watch?v=BaW_jenozKc', start: 0, end: 0, }); + }); - expect(spawnSync).toBeCalledWith('yt-dlp', expect.anything()); + afterEach(() => { + (spawnSync as Mock).mockReset(); }); - it('calls the buffer extract function', () => { - youtubeClipCore.clipVideo({ - downloaderExecutablePath: 'yt-dlp', - type: youtubeClipCore.VideoType.YOUTUBE, - url: 'https://www.youtube.com/watch?v=BaW_jenozKc', - start: 0, - end: 0, + 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(); + })); - expect(readFileSync).toBeCalled(); - }); + it('calls the postprocess function', () => new Promise((done) => { + (spawnSync as Mock) + .mockReturnValueOnce({ stdout: Buffer.from('') }) + .mockReturnValueOnce({}); - it('calls the cleanup function', () => { - youtubeClipCore.clipVideo({ - downloaderExecutablePath: 'yt-dlp', - type: youtubeClipCore.VideoType.YOUTUBE, - url: 'https://www.youtube.com/watch?v=BaW_jenozKc', - start: 0, - end: 0, + 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(); - }); + })); }); diff --git a/test/try-service.js b/test/try-service.js deleted file mode 100644 index a8cc111..0000000 --- a/test/try-service.js +++ /dev/null @@ -1,15 +0,0 @@ -const { clipVideo, VideoType } = require('../dist/cjs/development'); -const fs = require('fs'); -const dotenv = require('dotenv'); - -dotenv.config(); - -const clippedVideoBuffer = clipVideo({ - downloaderExecutablePath: process.env.YOUTUBE_DOWNLOADER_EXECUTABLE_PATH, - type: VideoType.YOUTUBE, - url: 'https://www.youtube.com/watch?v=BaW_jenozKc', - start: 0, - end: 1, -}); - -fs.writeFileSync('output.webm', clippedVideoBuffer); diff --git a/yarn.lock b/yarn.lock index 7cf2ecd..f355bd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -233,6 +233,30 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@esbuild-kit/cjs-loader@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@esbuild-kit/cjs-loader/-/cjs-loader-2.4.2.tgz#cb4dde00fbf744a68c4f20162ea15a8242d0fa54" + integrity sha512-BDXFbYOJzT/NBEtp71cvsrGPwGAMGRB/349rwKuoxNSiKjPraNNnlK6MIIabViCjqZugu6j+xeMDlEkWdHHJSg== + dependencies: + "@esbuild-kit/core-utils" "^3.0.0" + get-tsconfig "^4.4.0" + +"@esbuild-kit/core-utils@^3.0.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@esbuild-kit/core-utils/-/core-utils-3.1.0.tgz#49945d533dbd5e1b7620aa0fc522c15e6ec089c5" + integrity sha512-Uuk8RpCg/7fdHSceR1M6XbSZFSuMrxcePFuGgyvsBn+u339dk5OeL4jv2EojwTN2st/unJGsVm4qHWjWNmJ/tw== + dependencies: + esbuild "~0.17.6" + source-map-support "^0.5.21" + +"@esbuild-kit/esm-loader@^2.5.5": + version "2.5.5" + resolved "https://registry.yarnpkg.com/@esbuild-kit/esm-loader/-/esm-loader-2.5.5.tgz#b82da14fcee3fc1d219869756c06f43f67d1ca71" + integrity sha512-Qwfvj/qoPbClxCRNuac1Du01r9gvNOT+pMYtJDapfB1eoGN1YlJ1BixLyL9WVENRx5RXgNLdfYdx/CuswlGhMw== + dependencies: + "@esbuild-kit/core-utils" "^3.0.0" + get-tsconfig "^4.4.0" + "@esbuild/android-arm64@0.17.12": version "0.17.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.12.tgz#15a8e2b407d03989b899e325151dc2e96d19c620" @@ -508,6 +532,13 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4" integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw== +"@types/dotenv@^8.2.0": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-8.2.0.tgz#5cd64710c3c98e82d9d15844375a33bf1b45d053" + integrity sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw== + dependencies: + dotenv "*" + "@types/json-schema@^7.0.9": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" @@ -1180,7 +1211,7 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" -dotenv@^16.0.3: +dotenv@*, dotenv@^16.0.3: version "16.0.3" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== @@ -1293,7 +1324,7 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild@^0.17.4, esbuild@^0.17.5: +esbuild@^0.17.4, esbuild@^0.17.5, esbuild@~0.17.6: version "0.17.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.12.tgz#2ad7523bf1bc01881e9d904bc04e693bd3bdcf2f" integrity sha512-bX/zHl7Gn2CpQwcMtRogTTBf9l1nl+H6R8nUbjk+RuKqAE3+8FDulLA+pHvX7aA7Xe07Iwa+CWvy9I8Y2qqPKQ== @@ -1807,7 +1838,7 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" -get-tsconfig@^4.2.0: +get-tsconfig@^4.2.0, get-tsconfig@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.4.0.tgz#64eee64596668a81b8fce18403f94f245ee0d4e5" integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ== @@ -3240,6 +3271,17 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +tsx@^3.12.6: + version "3.12.6" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-3.12.6.tgz#36b3693e48b8392da374487190972c7b80e433b4" + integrity sha512-q93WgS3lBdHlPgS0h1i+87Pt6n9K/qULIMNYZo07nSeu2z5QE2CellcAZfofVXBo2tQg9av2ZcRMQ2S2i5oadQ== + dependencies: + "@esbuild-kit/cjs-loader" "^2.4.2" + "@esbuild-kit/core-utils" "^3.0.0" + "@esbuild-kit/esm-loader" "^2.5.5" + optionalDependencies: + fsevents "~2.3.2" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"