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 ErrorEventCallback = (event: Error) => void; | ||||
type SuccessEvent = { type: string, output: Buffer }; | |||||
type SuccessEvent = { contentType: string, content: Buffer }; | |||||
type SuccessEventCallback = (event: SuccessEvent) => void; | type SuccessEventCallback = (event: SuccessEvent) => void; | ||||
@@ -12,7 +12,7 @@ export interface VideoClipEventEmitter extends NodeJS.EventEmitter { | |||||
process(): void; | process(): void; | ||||
on(eventType: 'process', callback: ProcessEventCallback): this; | on(eventType: 'process', callback: ProcessEventCallback): this; | ||||
on(eventType: 'error', callback: ErrorEventCallback): 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; | 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(); | (spawnSync as Mock).mockReset(); | ||||
}); | }); | ||||
it('calls the downloader function', () => new Promise((done) => { | |||||
it('calls the downloader function', () => new Promise<void>((done) => { | |||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | .mockReturnValueOnce({ stdout: Buffer.from('') }); | ||||
clipper.on('end', done); | |||||
clipper.on('end', () => { done(); }); | |||||
clipper.process(); | clipper.process(); | ||||
expect(spawnSync).toBeCalledTimes(2); | expect(spawnSync).toBeCalledTimes(2); | ||||
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.arrayContaining(['--print'])); | expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.arrayContaining(['--print'])); | ||||
expect(spawnSync).nthCalledWith(2, 'yt-dlp', expect.anything()); | 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'); | const causeError = new Error('generic downloader message'); | ||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
@@ -57,27 +57,27 @@ describe('createVideoClipper', () => { | |||||
clipper.on('error', (err: Error) => { | clipper.on('error', (err: Error) => { | ||||
expect(err).toHaveProperty('message', causeError.message); | expect(err).toHaveProperty('message', causeError.message); | ||||
}); | }); | ||||
clipper.on('end', done); | |||||
clipper.on('end', () => { done(); }); | |||||
clipper.process(); | clipper.process(); | ||||
})); | })); | ||||
it('calls the buffer extract function', () => new Promise((done) => { | |||||
it('calls the buffer extract function', () => new Promise<void>((done) => { | |||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | .mockReturnValueOnce({ stdout: Buffer.from('') }); | ||||
clipper.on('end', done); | |||||
clipper.on('end', () => { done(); }); | |||||
clipper.process(); | clipper.process(); | ||||
expect(readFileSync).toBeCalled(); | expect(readFileSync).toBeCalled(); | ||||
})); | })); | ||||
it('calls the cleanup function', () => new Promise((done) => { | |||||
it('calls the cleanup function', () => new Promise<void>((done) => { | |||||
(spawnSync as Mock) | (spawnSync as Mock) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | .mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | ||||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | .mockReturnValueOnce({ stdout: Buffer.from('') }); | ||||
clipper.on('end', done); | |||||
clipper.on('end', () => { done(); }); | |||||
clipper.process(); | clipper.process(); | ||||
expect(unlinkSync).toBeCalled(); | expect(unlinkSync).toBeCalled(); | ||||