Browse Source

Update implementation

Refactor code for cleaner codebase.
master
TheoryOfNekomata 1 year ago
parent
commit
7eef7473be
7 changed files with 212 additions and 354 deletions
  1. +24
    -0
      src/common.ts
  2. +16
    -0
      src/duration.ts
  3. +5
    -242
      src/index.ts
  4. +142
    -0
      src/video-types/youtube.ts
  5. +22
    -107
      test/index.test.ts
  6. +1
    -2
      tsconfig.eslint.json
  7. +2
    -3
      tsconfig.json

+ 24
- 0
src/common.ts View File

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

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

type ErrorEventCallback = (event: Error) => 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: ErrorEventCallback): this;
}

export const FILE_TYPES: Record<string, string> = {
mkv: 'video/x-matroska',
webm: 'video/webm',
mp4: 'video/mp4',
};

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

+ 16
- 0
src/duration.ts View File

@@ -0,0 +1,16 @@
export const convertSecondsToDuration = (s: number) => {
const milliseconds = (s - Math.floor(s)) * 1000;
const seconds = s % 60;
const minutes = Math.floor(s / 60) % 60;
const hours = Math.floor(s / 3600);
const sss = milliseconds.toString().padStart(3, '0');
const ss = seconds.toString().padStart(2, '0');
const mm = minutes.toString().padStart(2, '0');
const hh = hours.toString().padStart(2, '0');
return `${hh}:${mm}:${ss}.${sss}`;
};

export const convertDurationToSeconds = (d: string) => {
const [hh, mm, ss] = d.split(':');
return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss);
};

+ 5
- 242
src/index.ts View File

@@ -1,266 +1,27 @@
import { spawnSync } from 'child_process';
import { unlinkSync, readFileSync } from 'fs';
import { EventEmitter } from 'events';
import { CreateYouTubeClipperParams, YouTubeVideoClipEventEmitter } from './video-types/youtube';
import { VideoClipEventEmitter } from './common';

export enum VideoType {
YOUTUBE = 'youtube',
}

interface CreateBaseClipperParams {
url: string;
start?: number | string;
end?: number | string;
postprocessorExecutablePath?: string;
}

interface CreateYouTubeClipperParams extends CreateBaseClipperParams {
downloaderExecutablePath?: string;
}

export interface CreateVideoClipperParams extends CreateYouTubeClipperParams {
type: VideoType,
}

const convertSecondsToDuration = (s: number) => {
const milliseconds = (s - Math.floor(s)) * 1000;
const seconds = s % 60;
const minutes = Math.floor(s / 60) % 60;
const hours = Math.floor(s / 3600);
const sss = milliseconds.toString().padStart(3, '0');
const ss = seconds.toString().padStart(2, '0');
const mm = minutes.toString().padStart(2, '0');
const hh = hours.toString().padStart(2, '0');
return `${hh}:${mm}:${ss}.${sss}`;
};

const convertDurationToSeconds = (d: string) => {
const [hh, mm, ss] = d.split(':');
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 VideoClipEventEmitter extends EventEmitter {
process(): void;
}

class YouTubeVideoClipEventEmitter extends EventEmitter implements VideoClipEventEmitter {
constructor(private readonly params: CreateYouTubeClipperParams) {
super();
}

private constructDefaultDownloadArgs(cacheFilename: string) {
const startSeconds = normalizeStartSeconds(this.params.start);

const downloadArgs = [
'--youtube-skip-dash-manifest',
'-f', 'bestvideo+bestaudio',
'--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 as string} ${downloadArgs.join(' ')}`,
});
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', {
type: 'video/webm',
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;
}

const [videoUrlStream, audioUrlStream] = downloaderProcess.stdout.toString('utf-8').split('\n');
this.emit('process', {
type: 'download',
phase: 'success',
streamUrls: {
video: [videoUrlStream],
audio: [audioUrlStream],
},
});

const startSeconds = normalizeStartSeconds(this.params.start);
const ffmpegProcessVideoStreamArgs = [
'-ss', (this.params.start ?? '00:00:00').toString(),
'-i', videoUrlStream,
];

const ffmpegProcessAudioStreamArgs = [
'-ss', (this.params.start ?? '00:00:00').toString(),
'-i', audioUrlStream,
];

if (typeof this.params.end !== 'undefined') {
// -to flag is broken on video stream in ffmpeg.....
const endSeconds = (
typeof this.params.end === 'string'
? convertDurationToSeconds(this.params.end)
: this.params.end
);

const difference = endSeconds - startSeconds;
const clipDuration = convertSecondsToDuration(difference);

ffmpegProcessVideoStreamArgs.push('-t');
ffmpegProcessVideoStreamArgs.push(clipDuration);

ffmpegProcessAudioStreamArgs.push('-t');
ffmpegProcessAudioStreamArgs.push(clipDuration);
}

const cacheFilename = getCacheFilename(); // todo label this on the cache
const ffmpegProcessArgs = [
...ffmpegProcessVideoStreamArgs,
...ffmpegProcessAudioStreamArgs,
'-map', '0:v',
'-map', '1:a',
cacheFilename,
];
this.emit('process', {
type: 'postprocess',
phase: 'start',
command: `${this.params.postprocessorExecutablePath as string} ${ffmpegProcessArgs.join(' ')}`,
});
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;
}
this.emit('process', {
type: 'postprocess',
phase: 'success',
});
const output = readFileSync(cacheFilename);
unlinkSync(cacheFilename);
this.emit('success', {
type: 'video/webm',
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: CreateVideoClipperParams) => {
export const createVideoClipper = (params: CreateVideoClipperParams): VideoClipEventEmitter => {
const {
type: videoType,
url,
start,
end,
downloaderExecutablePath,
postprocessorExecutablePath,
} = params;

switch (videoType as string) {
case VideoType.YOUTUBE:
return new YouTubeVideoClipEventEmitter({
downloaderExecutablePath,
postprocessorExecutablePath,
url,
start,
end,
@@ -271,3 +32,5 @@ export const createVideoClipper = (params: CreateVideoClipperParams) => {

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

export { VideoClipEventEmitter };

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

@@ -0,0 +1,142 @@
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');
};

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

+ 22
- 107
test/index.test.ts View File

@@ -1,113 +1,27 @@
import {
describe, it, expect, vi, beforeEach, Mock, afterEach,
describe,
it,
expect,
vi,
beforeEach,
Mock,
afterEach,
} from 'vitest';
import { spawnSync } from 'child_process';
import { readFileSync, unlinkSync } from 'fs';
import * as webVideoClipCore from '../src';
import { VideoClipEventEmitter } from '../src';

vi.mock('child_process', () => ({
spawnSync: vi.fn(),
}));
vi.mock('child_process');

vi.mock('fs', () => ({
readFileSync: vi.fn(),
unlinkSync: vi.fn(),
}));
vi.mock('fs');

describe('createVideoClipper', () => {
beforeEach(() => {
(readFileSync as Mock).mockReturnValueOnce(Buffer.from(''));
});

describe('with postprocessing', () => {
let clipper: VideoClipEventEmitter;
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);
});
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();
}));
});

describe('without postprocessing', () => {
let clipper: VideoClipEventEmitter;
let clipper: webVideoClipCore.VideoClipEventEmitter;
beforeEach(() => {
clipper = webVideoClipCore.createVideoClipper({
downloaderExecutablePath: 'yt-dlp',
@@ -124,23 +38,24 @@ describe('createVideoClipper', () => {

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

clipper.on('end', done);
clipper.process();
expect(spawnSync).toBeCalledTimes(1);
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything());
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) => {
const causeError = new Error('generic downloader message');
(spawnSync as Mock)
.mockReturnValueOnce({ error: causeError })
.mockReturnValueOnce({});
.mockReturnValueOnce({ stdout: Buffer.from('mkv') })
.mockReturnValueOnce({ error: causeError });

clipper.on('error', (err: Error) => {
expect(err.cause).toHaveProperty('message', causeError.message);
expect(err).toHaveProperty('message', causeError.message);
});
clipper.on('end', done);
clipper.process();
@@ -148,8 +63,8 @@ describe('createVideoClipper', () => {

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

clipper.on('end', done);
clipper.process();
@@ -159,8 +74,8 @@ describe('createVideoClipper', () => {

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

clipper.on('end', done);
clipper.process();


+ 1
- 2
tsconfig.eslint.json View File

@@ -14,8 +14,7 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018"
"target": "es2022"
}
}

+ 2
- 3
tsconfig.json View File

@@ -1,6 +1,6 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"include": ["src", "types", "test"],
"compilerOptions": {
"module": "ESNext",
"lib": ["ESNext"],
@@ -14,8 +14,7 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"target": "es2018"
"target": "es2022"
}
}

Loading…
Cancel
Save