Browse Source

Minor refactor

Split utility functions to separate file. Group all the utilities being
used by video type.
master
TheoryOfNekomata 1 year ago
parent
commit
4f486d7a0f
5 changed files with 154 additions and 152 deletions
  1. +2
    -2
      src/common.ts
  2. +0
    -142
      src/video-types/youtube.ts
  3. +62
    -0
      src/video-types/youtube/index.ts
  4. +82
    -0
      src/video-types/youtube/utilities.ts
  5. +8
    -8
      test/index.test.ts

+ 2
- 2
src/common.ts View File

@@ -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;
}



+ 0
- 142
src/video-types/youtube.ts View File

@@ -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');
}
}

+ 62
- 0
src/video-types/youtube/index.ts View File

@@ -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');
}
}

+ 82
- 0
src/video-types/youtube/utilities.ts View File

@@ -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,
];
};

+ 8
- 8
test/index.test.ts View File

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


Loading…
Cancel
Save