youtube-likes-downloader
Version:
Download all liked videos from youtube
275 lines (204 loc) • 8.48 kB
text/typescript
import fs from 'fs';
import type { Interface } from 'readline';
import { createInterface } from 'readline';
import { mockPartial } from '@anmiles/jest-extensions';
import { error } from '@anmiles/logger';
import { validate } from '@anmiles/zod-tools';
import mockFs from 'mock-fs';
import { open } from 'out-url';
import z from 'zod';
import { addVideo } from '../addVideo';
import { getDownloadArchive, getOutputDir } from '../utils/paths';
import { SequentialArray } from './testUtils/sequentialArray';
jest.mock('@anmiles/logger');
jest.mock('out-url');
jest.mock('readline');
jest.mock('colorette', () => new Proxy({}, {
get: () => (str: string) => str,
}));
const profile = 'username';
const videoFile = 'test/video.mp4';
const imageFile = 'test/poster.jpg';
const descriptionFile = 'test/description.txt';
const id = 'video_c3';
const link = `https://www.youtube.com/watch?v=${id}`;
const title = 'First movie: beginning <pilot version>';
const channel = 'Test channel';
const duration = '01:30:00';
const width = 1280;
const height = 720;
const resolution = `${width}x${height}`;
const date = '2020-01-01';
const epoch = Math.round(new Date(date).getTime() / 1000);
const ext = 'mp4';
const video = 'video\nend';
const image = 'image\nend';
const description = 'description\nend';
const thumbnail = 'https://example.com/maxresdefault.jpg?sqp=wveou-wwhriakkrft=&rs=qt';
const questions = [
'Input JSON (optional): ',
'Youtube link: ',
'Title: ',
'Channel: ',
'Duration: ',
'Resolution: ',
'Date: ',
'Video file: ',
'Image file: ',
'Description file: ',
] as const;
const questionsSchema = z.enum(questions);
const outputDir = getOutputDir(profile);
const expectedFiles: Record<string, string> = {
['output/username/First movie - beginning pilot version [Test channel].video_c3.mp4'] : video,
['output/username/First movie - beginning pilot version [Test channel].video_c3.jpg'] : image,
['output/username/First movie - beginning pilot version [Test channel].video_c3.description']: description,
['output/username/First movie - beginning pilot version [Test channel].video_c3.info.json'] : JSON.stringify({
id,
title,
channel,
ext,
width,
height,
resolution,
duration_string: duration, // eslint-disable-line camelcase
epoch,
}),
};
let answers: Record<typeof questions[number], SequentialArray<string>>;
const write = jest.fn();
const close = jest.fn();
const readlineInterface = mockPartial<Interface>({
question: jest.fn().mockImplementation((question: string, resolve: (answer: string) => void) => {
let validQuestion: typeof questions[number];
try {
validQuestion = validate(question, questionsSchema);
} catch (ex) {
throw new Error(`Invalid question '${question}'`, Error.parse(ex));
}
const answer = answers[validQuestion].next();
if (typeof answer === 'undefined') {
throw new Error(`Question '${question}' has no more answers`);
}
resolve(answer);
}),
write,
close,
});
jest.mocked(createInterface).mockReturnValue(readlineInterface);
beforeEach(() => {
mockFs({
[videoFile] : video,
[imageFile] : image,
[descriptionFile] : description,
[getDownloadArchive(profile)]: 'youtube video_a1\nyoutube video_b2\n',
}, { createTmp: true });
answers = {
'Input JSON (optional): ': new SequentialArray([ '' ]),
'Youtube link: ' : new SequentialArray([ link ]),
'Title: ' : new SequentialArray([ title ]),
'Channel: ' : new SequentialArray([ channel ]),
'Duration: ' : new SequentialArray([ duration ]),
'Resolution: ' : new SequentialArray([ resolution ]),
'Date: ' : new SequentialArray([ date ]),
'Video file: ' : new SequentialArray([ videoFile ]),
'Image file: ' : new SequentialArray([ imageFile ]),
'Description file: ' : new SequentialArray([ descriptionFile ]),
};
});
afterAll(() => {
mockFs.restore();
});
describe('src/lib/addVideo', () => {
describe('addVideo', () => {
it('should generate files', async () => {
await addVideo(profile);
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should add downloaded ID to the archive', async () => {
await addVideo(profile);
const downloadArchive = (await fs.promises.readFile(getDownloadArchive(profile))).toString();
expect(downloadArchive).toEqual('youtube video_a1\nyoutube video_b2\nyoutube video_c3\n');
});
it('should close used interface', async () => {
await addVideo(profile);
expect(close).toHaveBeenCalled();
});
it('should output error if youtube link is not valid', async () => {
answers['Youtube link: '] = new SequentialArray([ 'wrong link', link ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (Youtube link should be in the format https://www.youtube.com/watch?v=...)');
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should output error if duration is not valid', async () => {
answers['Duration: '] = new SequentialArray([ 'wrong duration', duration ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (Duration should be in the format <min>:<sec> or <hour>:<min>:<sec>)');
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should output error if resolution is not valid', async () => {
answers['Resolution: '] = new SequentialArray([ 'wrong resolution', resolution ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (Resolution should be in the format <width>x<height>)');
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should output error if date is not valid', async () => {
answers['Date: '] = new SequentialArray([ 'wrong date', date ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (Date should be in the format YYYY-MM-dd)');
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should output error if video file does not exist', async () => {
answers['Video file: '] = new SequentialArray([ 'wrong file', videoFile ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (File not exists)');
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should output error if image file does not exist', async () => {
answers['Image file: '] = new SequentialArray([ 'wrong file', imageFile ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (File not exists)');
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should output error if description file does not exist', async () => {
answers['Description file: '] = new SequentialArray([ 'wrong file', descriptionFile ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (File not exists)');
expect(outputDir).toMatchFiles(expectedFiles);
});
describe('json', () => {
const json = Object.freeze({
id,
title,
channel,
duration,
resolution,
epoch,
description,
thumbnail,
});
it('should process json if specified', async () => {
answers['Input JSON (optional): '] = new SequentialArray([ JSON.stringify(json) ]);
await addVideo(profile);
expect(outputDir).toMatchFiles(expectedFiles);
});
it('should open thumbnail in the browser', async () => {
answers['Input JSON (optional): '] = new SequentialArray([ JSON.stringify(json) ]);
await addVideo(profile);
expect(open).toHaveBeenCalledWith('https://example.com/maxresdefault.jpg');
});
it('should output error if json is not valid', async () => {
const wrongJSON = JSON.stringify({ ...json, resolution: 'wrong resolution' });
answers['Input JSON (optional): '] = new SequentialArray([ JSON.stringify(wrongJSON), JSON.stringify(json) ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (Invalid input)');
});
it('should output error if json is broken', async () => {
const wrongJSON = 'wrong json';
answers['Input JSON (optional): '] = new SequentialArray([ JSON.stringify(wrongJSON), JSON.stringify(json) ]);
await addVideo(profile);
expect(error).toHaveBeenCalledWith('Validation failed:\n\t (Invalid input)');
});
});
});
});