@@ -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) => { | |||
@@ -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(); | |||
})); | |||
}); | |||
}); |