Browse Source

Refactor core

Use simple Promise-based API.
master
TheoryOfNekomata 1 year ago
parent
commit
35e49a8994
8 changed files with 140 additions and 152 deletions
  1. +9
    -17
      src/common.ts
  2. +20
    -29
      src/index.ts
  3. +47
    -0
      src/video-types/youtube/clipper.ts
  4. +1
    -0
      src/video-types/youtube/common.ts
  5. +20
    -20
      src/video-types/youtube/downloader.ts
  6. +3
    -0
      src/video-types/youtube/errors.ts
  7. +3
    -62
      src/video-types/youtube/index.ts
  8. +37
    -24
      test/index.test.ts

+ 9
- 17
src/common.ts View File

@@ -1,28 +1,20 @@
type ProcessEvent = { type: string, phase: string, command?: string };

type ProcessEventCallback = (event: ProcessEvent) => void;

type ErrorEventCallback = (event: Error) => void;

type SuccessEvent = { contentType: string, content: Buffer };

type SuccessEventCallback = (event: SuccessEvent) => void;

export interface VideoClipEventEmitter extends NodeJS.EventEmitter {
process(): void;
on(eventType: 'process', callback: ProcessEventCallback): this;
on(eventType: 'error', callback: ErrorEventCallback): this;
on(eventType: 'end', callback: () => void): this;
on(eventType: 'success', callback: SuccessEventCallback): this;
export interface ClippedVideo {
contentType: string;
content: Buffer;
}

export type ClipFunction = (clipVideoParams: ClipVideoParams) => Promise<ClippedVideo>;
export const FILE_TYPES: Record<string, string> = {
mkv: 'video/x-matroska',
webm: 'video/webm',
mp4: 'video/mp4',
};

export interface CreateBaseClipperParams {
export interface CreateClipperParams {
downloaderExecutablePath: string;
}

export interface ClipVideoParams {
url: string;
start?: number | string;
end?: number | string;


+ 20
- 29
src/index.ts View File

@@ -1,36 +1,27 @@
import { CreateYouTubeClipperParams, YouTubeVideoClipEventEmitter } from './video-types/youtube';
import { VideoClipEventEmitter } from './common';
import * as YouTubeImpl from './video-types/youtube';

export enum VideoType {
YOUTUBE = 'youtube',
}
const SUPPORTED_VIDEO_TYPES = [
YouTubeImpl,
] as const;

export interface CreateVideoClipperParams extends CreateYouTubeClipperParams {
type: VideoType,
}
export type CreateClipperParams = (
YouTubeImpl.CreateClipperParams
);

export const createVideoClipper = (params: CreateVideoClipperParams): VideoClipEventEmitter => {
const {
type: videoType,
url,
start,
end,
downloaderExecutablePath,
} = params;
export type VideoType = typeof YouTubeImpl.VIDEO_TYPE;
export * as YouTube from './video-types/youtube';
export * from './common';

switch (videoType as string) {
case VideoType.YOUTUBE:
return new YouTubeVideoClipEventEmitter({
downloaderExecutablePath,
url,
start,
end,
});
default:
break;
export const createVideoClipper = (params: CreateClipperParams) => {
const { type: videoType, ...etcParams } = params;

const theVideoTypeModule = SUPPORTED_VIDEO_TYPES
.find((videoTypeModule) => videoTypeModule.VIDEO_TYPE === videoType);

if (!theVideoTypeModule) {
const validVideoTypes = SUPPORTED_VIDEO_TYPES.map((videoTypeModule) => videoTypeModule.VIDEO_TYPE).join(', ');
throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${validVideoTypes}`);
}

throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`);
return theVideoTypeModule.createVideoClipper(etcParams);
};

export { VideoClipEventEmitter };

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

@@ -0,0 +1,47 @@
import { spawnSync } from 'child_process';
import { readFileSync, unlinkSync } from 'fs';
import {
ClipVideoParams as BaseClipVideoParams,
CreateClipperParams as CreateBaseClipperParams,
FILE_TYPES,
} from '../../common';
import { VIDEO_TYPE } from './common';
import { constructDefaultDownloadArgs, getFileExtension } from './downloader';
import { DownloaderFailedToStartError, DownloaderNotFoundError } from './errors';

export interface CreateClipperParams extends CreateBaseClipperParams {
type: typeof VIDEO_TYPE;
}

export interface ClipVideoParams extends BaseClipVideoParams {}

export const createVideoClipper = (createClipperParams: Omit<CreateClipperParams, 'type'>) => (clipVideoParams: ClipVideoParams) => {
if (!createClipperParams.downloaderExecutablePath) {
throw new DownloaderNotFoundError('Downloader not found.');
}

const fileExtension = getFileExtension(
createClipperParams.downloaderExecutablePath,
clipVideoParams.url,
);
const cacheFilename = `output.${fileExtension}`; // todo label this on the cache
const downloadArgs = constructDefaultDownloadArgs(
cacheFilename,
clipVideoParams.url,
clipVideoParams.start,
clipVideoParams.end,
);
const downloaderProcess = spawnSync(
createClipperParams.downloaderExecutablePath,
downloadArgs,
);
if (downloaderProcess.error) {
throw new DownloaderFailedToStartError('Downloader failed to start.', { cause: downloaderProcess.error });
}
const output = readFileSync(cacheFilename);
unlinkSync(cacheFilename);
return Promise.resolve({
contentType: FILE_TYPES[fileExtension],
content: output,
});
}

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

@@ -0,0 +1 @@
export const VIDEO_TYPE = 'youtube' as const;

src/video-types/youtube/utilities.ts → src/video-types/youtube/downloader.ts View File

@@ -1,7 +1,7 @@
import { spawnSync } from 'child_process';
import { convertDurationToSeconds, convertSecondsToDuration } from '../../duration';

export const normalizeStartSeconds = (start?: number | string) => {
const normalizeStartSeconds = (start?: number | string) => {
if (typeof start === 'undefined') {
return 0;
}
@@ -17,25 +17,7 @@ const FORMAT_DEFAULT_ARGS = [
'-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 constructDownloadSectionsRegex = (start?: number | string, end?: number | string) => {
const startSeconds = normalizeStartSeconds(start);
if (typeof end !== 'undefined') {
const endSeconds = (
@@ -54,6 +36,24 @@ export const constructDownloadSectionsRegex = (start?: number | string, end?: nu
return null;
};

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 constructDefaultDownloadArgs = (
outputFilename: string,
url: string,

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

@@ -0,0 +1,3 @@
export class DownloaderNotFoundError extends Error {}

export class DownloaderFailedToStartError extends Error {}

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

@@ -1,62 +1,3 @@
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');
}
}
export * from './common';
export * from './clipper';
export * from './errors';

+ 37
- 24
test/index.test.ts View File

@@ -21,14 +21,11 @@ describe('createVideoClipper', () => {
});

describe('without postprocessing', () => {
let clipper: webVideoClipCore.VideoClipEventEmitter;
let clipper: webVideoClipCore.ClipFunction;
beforeEach(() => {
clipper = webVideoClipCore.createVideoClipper({
downloaderExecutablePath: 'yt-dlp',
type: webVideoClipCore.VideoType.YOUTUBE,
url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
start: 0,
end: 0,
type: webVideoClipCore.YouTube.VIDEO_TYPE,
});
});

@@ -36,51 +33,67 @@ describe('createVideoClipper', () => {
(spawnSync as Mock).mockReset();
});

it('calls the downloader function', () => new Promise<void>((done) => {
it('calls the downloader function', async () => {
(spawnSync as Mock)
.mockReturnValueOnce({ stdout: Buffer.from('mkv') })
.mockReturnValueOnce({ stdout: Buffer.from('') });

clipper.on('end', () => { done(); });
clipper.process();
await clipper({
url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
start: 0,
end: 0,
});
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<void>((done) => {
it('emits downloader errors', async () => {
const causeError = new Error('generic downloader message');
(spawnSync as Mock)
.mockReturnValueOnce({ stdout: Buffer.from('mkv') })
.mockReturnValueOnce({ error: causeError });

clipper.on('error', (err: Error) => {
expect(err).toHaveProperty('message', causeError.message);
});
clipper.on('end', () => { done(); });
clipper.process();
}));
try {
await clipper({
url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
start: 0,
end: 0,
})
} catch (errRaw) {
const err = errRaw as Error;
expect(err).toBeInstanceOf(webVideoClipCore.YouTube.DownloaderFailedToStartError);
expect(err.cause).toBe(causeError);
expect(err).toHaveProperty('message', 'Downloader failed to start.');
}
});

it('calls the buffer extract function', () => new Promise<void>((done) => {
it('calls the buffer extract function', async () => {
(spawnSync as Mock)
.mockReturnValueOnce({ stdout: Buffer.from('mkv') })
.mockReturnValueOnce({ stdout: Buffer.from('') });

clipper.on('end', () => { done(); });
clipper.process();
await clipper({
url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
start: 0,
end: 0,
});

expect(readFileSync).toBeCalled();
}));
});

it('calls the cleanup function', () => new Promise<void>((done) => {
it('calls the cleanup function', async () => {
(spawnSync as Mock)
.mockReturnValueOnce({ stdout: Buffer.from('mkv') })
.mockReturnValueOnce({ stdout: Buffer.from('') });

clipper.on('end', () => { done(); });
clipper.process();
await clipper({
url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
start: 0,
end: 0,
});

expect(unlinkSync).toBeCalled();
}));
});
});
});

Loading…
Cancel
Save