Split utility functions to separate file. Group all the utilities being used by video type.master
@@ -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; | |||
} | |||
@@ -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'); | |||
} | |||
} |
@@ -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'); | |||
} | |||
} |
@@ -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, | |||
]; | |||
}; |
@@ -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<void>((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<void>((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<void>((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<void>((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(); | |||