mirror of
https://github.com/snobu/destreamer.git
synced 2026-01-27 10:22:21 +00:00
Fixes and refactoring (#59)
* Input url list: Fix bad Windows behavior * Minor output fix * Fix all download issues - downloads are synchronous again - fix progress bar (fix #39) - nuke fluent and switch to a bug-free ffmpeg module (fessonia) * Move destreamer process events to a new file, we may add more in the future, lets give them their own space * Destreamer: Release packages and builder script ETA when? :P * Clean up * Implement yargs checks and add --videoUrlsFile option * Refactor error handling - Human readable - No magic numbers * Handle mkdir error - remove reduntant message * gitignore: don't add hidden files * Implement --outputDirectories This gives us more flexibility on where to save videos ..especially if your videos have all the same name <.< * Rename utils -> Utils * Fix tests don't import yargs on files other than main * Create scripts directory * Update make_release path * Fix typo * Create CONTRIBUTING.md Co-authored-by: kylon <kylonux@gmail.com>
This commit is contained in:
185
src/CommandLineParser.ts
Normal file
185
src/CommandLineParser.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { CLI_ERROR } from './Errors';
|
||||
|
||||
import yargs from 'yargs';
|
||||
import colors from 'colors';
|
||||
import fs from 'fs';
|
||||
|
||||
export const argv = yargs.options({
|
||||
videoUrls: {
|
||||
alias: 'V',
|
||||
describe: 'List of video urls',
|
||||
type: 'array',
|
||||
demandOption: false
|
||||
},
|
||||
videoUrlsFile: {
|
||||
alias: 'F',
|
||||
describe: 'Path to txt file containing the urls',
|
||||
type: 'string',
|
||||
demandOption: false
|
||||
},
|
||||
username: {
|
||||
alias: 'u',
|
||||
type: 'string',
|
||||
demandOption: false
|
||||
},
|
||||
outputDirectory: {
|
||||
alias: 'o',
|
||||
describe: 'The directory where destreamer will save your downloads [default: videos]',
|
||||
type: 'string',
|
||||
demandOption: false
|
||||
},
|
||||
outputDirectories: {
|
||||
alias: 'O',
|
||||
describe: 'Path to a txt file containing one output directory per video',
|
||||
type: 'string',
|
||||
demandOption: false
|
||||
},
|
||||
noThumbnails: {
|
||||
alias: 'nthumb',
|
||||
describe: `Do not display video thumbnails`,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
simulate: {
|
||||
alias: 's',
|
||||
describe: `Disable video download and print metadata information to the console`,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
verbose: {
|
||||
alias: 'v',
|
||||
describe: `Print additional information to the console (use this before opening an issue on GitHub)`,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Do our own argv magic before destreamer starts.
|
||||
* ORDER IS IMPORTANT!
|
||||
* Do not mess with this.
|
||||
*/
|
||||
.check(() => isShowHelpRequest())
|
||||
.check(argv => checkRequiredArgument(argv))
|
||||
.check(argv => checkVideoUrlsArgConflict(argv))
|
||||
.check(argv => checkOutputDirArgConflict(argv))
|
||||
.check(argv => checkVideoUrlsInput(argv))
|
||||
.check(argv => windowsFileExtensionBadBehaviorFix(argv))
|
||||
.check(argv => mergeVideoUrlsArguments(argv))
|
||||
.check(argv => mergeOutputDirArguments(argv))
|
||||
.argv;
|
||||
|
||||
function hasNoArgs() {
|
||||
return process.argv.length === 2;
|
||||
}
|
||||
|
||||
function isShowHelpRequest() {
|
||||
if (hasNoArgs())
|
||||
throw new Error(CLI_ERROR.GRACEFULLY_STOP);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkRequiredArgument(argv: any) {
|
||||
if (hasNoArgs())
|
||||
return true;
|
||||
|
||||
if (!argv.videoUrls && !argv.videoUrlsFile)
|
||||
throw new Error(colors.red(CLI_ERROR.MISSING_REQUIRED_ARG));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkVideoUrlsArgConflict(argv: any) {
|
||||
if (hasNoArgs())
|
||||
return true;
|
||||
|
||||
if (argv.videoUrls && argv.videoUrlsFile)
|
||||
throw new Error(colors.red(CLI_ERROR.VIDEOURLS_ARG_CONFLICT));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkOutputDirArgConflict(argv: any) {
|
||||
if (hasNoArgs())
|
||||
return true;
|
||||
|
||||
if (argv.outputDirectory && argv.outputDirectories)
|
||||
throw new Error(colors.red(CLI_ERROR.OUTPUTDIR_ARG_CONFLICT));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkVideoUrlsInput(argv: any) {
|
||||
if (hasNoArgs() || !argv.videoUrls)
|
||||
return true;
|
||||
|
||||
if (!argv.videoUrls.length)
|
||||
throw new Error(colors.red(CLI_ERROR.MISSING_REQUIRED_ARG));
|
||||
|
||||
const t = argv.videoUrls[0] as string;
|
||||
if (t.substring(t.length-4) === '.txt')
|
||||
throw new Error(colors.red(CLI_ERROR.FILE_INPUT_VIDEOURLS_ARG));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users see 2 separate options, but we don't really care
|
||||
* cause both options have no difference in code.
|
||||
*
|
||||
* Optimize and make this transparent to destreamer
|
||||
*/
|
||||
function mergeVideoUrlsArguments(argv: any) {
|
||||
if (!argv.videoUrlsFile)
|
||||
return true;
|
||||
|
||||
argv.videoUrls = [argv.videoUrlsFile]; // noone will notice ;)
|
||||
|
||||
// these are not valid anymore
|
||||
delete argv.videoUrlsFile;
|
||||
delete argv.F;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users see 2 separate options, but we don't really care
|
||||
* cause both options have no difference in code.
|
||||
*
|
||||
* Optimize and make this transparent to destreamer
|
||||
*/
|
||||
function mergeOutputDirArguments(argv: any) {
|
||||
if (!argv.outputDirectories && argv.outputDirectory)
|
||||
return true;
|
||||
|
||||
if (!argv.outputDirectory && !argv.outputDirectories)
|
||||
argv.outputDirectory = 'videos'; // default out dir
|
||||
else if (argv.outputDirectories)
|
||||
argv.outputDirectory = argv.outputDirectories;
|
||||
|
||||
if (argv.outputDirectories) {
|
||||
// these are not valid anymore
|
||||
delete argv.outputDirectories;
|
||||
delete argv.O;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// yeah this is for windows, but lets check everyone, who knows...
|
||||
function windowsFileExtensionBadBehaviorFix(argv: any) {
|
||||
if (hasNoArgs() || !argv.videoUrlsFile || !argv.outputDirectories)
|
||||
return true;
|
||||
|
||||
if (!fs.existsSync(argv.videoUrlsFile)) {
|
||||
if (fs.existsSync(argv.videoUrlsFile + '.txt'))
|
||||
argv.videoUrlsFile += '.txt';
|
||||
else
|
||||
throw new Error(colors.red(CLI_ERROR.INPUT_URLS_FILE_NOT_FOUND));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
63
src/Errors.ts
Normal file
63
src/Errors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
interface IError {
|
||||
[key: number]: string
|
||||
}
|
||||
|
||||
export const enum ERROR_CODE {
|
||||
NO_ERROR,
|
||||
UNHANDLED_ERROR,
|
||||
MISSING_FFMPEG,
|
||||
ELEVATED_SHELL,
|
||||
INVALID_OUTPUT_DIR,
|
||||
INVALID_INPUT_URLS,
|
||||
OUTDIRS_URLS_MISMATCH,
|
||||
INVALID_VIDEO_ID,
|
||||
INVALID_VIDEO_GUID,
|
||||
UNK_FFMPEG_ERROR,
|
||||
NO_SESSION_INFO,
|
||||
}
|
||||
|
||||
// TODO: create better errors descriptions
|
||||
export const Error: IError = {
|
||||
[ERROR_CODE.NO_ERROR]: 'Clean exit with code 0',
|
||||
|
||||
[ERROR_CODE.UNHANDLED_ERROR]: 'Unhandled error!\n' +
|
||||
'Timeout or fatal error, please check your downloads directory and try again',
|
||||
|
||||
[ERROR_CODE.ELEVATED_SHELL]: 'Running in an elevated shell',
|
||||
|
||||
[ERROR_CODE.INVALID_OUTPUT_DIR]: 'Unable to create output directory',
|
||||
|
||||
[ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing!\n' +
|
||||
'Destreamer requires a fairly recent release of FFmpeg to download videos',
|
||||
|
||||
[ERROR_CODE.UNK_FFMPEG_ERROR]: 'Unknown FFmpeg error',
|
||||
|
||||
[ERROR_CODE.INVALID_INPUT_URLS]: 'No valid URL in the input',
|
||||
|
||||
[ERROR_CODE.OUTDIRS_URLS_MISMATCH]: 'Output directories and URLs mismatch!\n' +
|
||||
'You must input the same number of URLs and output directories',
|
||||
|
||||
[ERROR_CODE.INVALID_VIDEO_ID]: 'Unable to get video ID from URL',
|
||||
|
||||
[ERROR_CODE.INVALID_VIDEO_GUID]: 'Unable to get video GUID from URL',
|
||||
|
||||
[ERROR_CODE.NO_SESSION_INFO]: 'Could not evaluate sessionInfo on the page'
|
||||
}
|
||||
|
||||
export const enum CLI_ERROR {
|
||||
GRACEFULLY_STOP = ' ', // gracefully stop execution, yargs way
|
||||
|
||||
MISSING_REQUIRED_ARG = 'You must specify a URLs source.\n' +
|
||||
'Valid options are --videoUrls or --videoUrlsFile.',
|
||||
|
||||
VIDEOURLS_ARG_CONFLICT = 'Too many URLs sources specified!\n' +
|
||||
'Please specify a single URLs source with either --videoUrls or --videoUrlsFile.',
|
||||
|
||||
OUTPUTDIR_ARG_CONFLICT = 'Too many output arguments specified!\n' +
|
||||
'Please specify a single output argument with either --outputDirectory or --outputDirectories.',
|
||||
|
||||
FILE_INPUT_VIDEOURLS_ARG = 'Wrong input for option --videoUrls.\n' +
|
||||
'To read URLs from file, use --videoUrlsFile option.',
|
||||
|
||||
INPUT_URLS_FILE_NOT_FOUND = 'Input URL list file not found.'
|
||||
}
|
||||
27
src/Events.ts
Normal file
27
src/Events.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Error, ERROR_CODE } from './Errors';
|
||||
|
||||
import colors from 'colors';
|
||||
|
||||
/**
|
||||
* This file contains global destreamer process events
|
||||
*
|
||||
* @note SIGINT event is overridden in downloadVideo function
|
||||
*
|
||||
* @note function is required for non-packaged destreamer, so we can't do better
|
||||
*/
|
||||
export function setProcessEvents() {
|
||||
// set exit event first so that we can always print cute errors
|
||||
process.on('exit', (code) => {
|
||||
if (code == 0)
|
||||
return;
|
||||
|
||||
const msg = code in Error ? `\n\n${Error[code]} \n` : `\n\nUnknown error: exit code ${code} \n`;
|
||||
|
||||
console.error(colors.bgRed(msg));
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error(colors.red(reason as string));
|
||||
process.exit(ERROR_CODE.UNHANDLED_ERROR);
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export async function getVideoMetadata(videoGuids: string[], session: Session, v
|
||||
let metadata: Metadata[] = [];
|
||||
let title: string;
|
||||
let date: string;
|
||||
let duration: number;
|
||||
let totalChunks: number;
|
||||
let playbackUrl: string;
|
||||
let posterImage: string;
|
||||
|
||||
@@ -50,11 +50,11 @@ export async function getVideoMetadata(videoGuids: string[], session: Session, v
|
||||
|
||||
posterImage = response.data['posterImage']['medium']['url'];
|
||||
date = publishedDateToString(response.data['publishedDate']);
|
||||
duration = durationToTotalChunks(response.data.media['duration']);
|
||||
totalChunks = durationToTotalChunks(response.data.media['duration']);
|
||||
|
||||
metadata.push({
|
||||
date: date,
|
||||
duration: duration,
|
||||
totalChunks: totalChunks,
|
||||
title: title,
|
||||
playbackUrl: playbackUrl,
|
||||
posterImage: posterImage
|
||||
|
||||
16
src/PuppeteerHelper.ts
Normal file
16
src/PuppeteerHelper.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from 'path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
// Thanks pkg-puppeteer [ cleaned up version :) ]
|
||||
export function getPuppeteerChromiumPath() {
|
||||
const isPkg = __filename.includes('snapshot');
|
||||
const macOS_Linux_rex = /^.*?\/node_modules\/puppeteer\/\.local-chromium/;
|
||||
const win32_rex = /^.*?\\node_modules\\puppeteer\\\.local-chromium/;
|
||||
const replaceRegex = process.platform === 'win32' ? win32_rex : macOS_Linux_rex;
|
||||
|
||||
if (!isPkg)
|
||||
return puppeteer.executablePath();
|
||||
|
||||
return puppeteer.executablePath()
|
||||
.replace(replaceRegex, path.join(path.dirname(process.execPath), 'chromium'))
|
||||
}
|
||||
28
src/Types.ts
28
src/Types.ts
@@ -7,32 +7,8 @@ export type Session = {
|
||||
|
||||
export type Metadata = {
|
||||
date: string;
|
||||
duration: number;
|
||||
totalChunks: number; // Abstraction of FFmpeg timemark
|
||||
title: string;
|
||||
playbackUrl: string;
|
||||
posterImage: string;
|
||||
}
|
||||
|
||||
|
||||
interface Errors {
|
||||
[key: number]: string
|
||||
}
|
||||
|
||||
// I didn't use an enum because there is no real advantage that i can find and
|
||||
// we can't use multiline string for long errors
|
||||
// TODO: create better errors descriptions
|
||||
export const Errors: Errors = {
|
||||
22: 'FFmpeg is missing.\n' +
|
||||
'Destreamer requires a fairly recent release of FFmpeg to download videos.\n' +
|
||||
'Please install it in $PATH or copy the ffmpeg binary to the root directory (next to package.json). \n',
|
||||
|
||||
33: "Can't split videoId from videoUrl\n",
|
||||
|
||||
44: "Couldn't evaluate sessionInfo on the page\n",
|
||||
|
||||
55: 'Running in an elevated shell\n',
|
||||
|
||||
66: 'No valid URL in the input\n',
|
||||
|
||||
0: "Clean exit with code 0."
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ERROR_CODE } from './Errors';
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import colors from 'colors';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
function sanitizeUrls(urls: string[]) {
|
||||
const rex = new RegExp(/(?:https:\/\/)?.*\/video\/[a-z0-9]{8}-(?:[a-z0-9]{4}\-){3}[a-z0-9]{12}$/, 'i');
|
||||
const sanitized: string[] = [];
|
||||
@@ -26,17 +27,35 @@ function sanitizeUrls(urls: string[]) {
|
||||
sanitized.push(url+query);
|
||||
}
|
||||
|
||||
return sanitized.length ? sanitized : null;
|
||||
if (!sanitized.length)
|
||||
process.exit(ERROR_CODE.INVALID_INPUT_URLS);
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeOutDirsList(dirsList: string[]) {
|
||||
const sanitized: string[] = [];
|
||||
|
||||
dirsList.forEach(dir => {
|
||||
if (dir !== '')
|
||||
sanitized.push(dir);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function readFileToArray(path: string) {
|
||||
return fs.readFileSync(path).toString('utf-8').split(/[\r\n]/);
|
||||
}
|
||||
|
||||
|
||||
export function parseVideoUrls(videoUrls: any) {
|
||||
const t = videoUrls[0] as string;
|
||||
let t = videoUrls[0] as string;
|
||||
const isPath = t.substring(t.length-4) === '.txt';
|
||||
let urls: string[];
|
||||
|
||||
if (isPath)
|
||||
urls = fs.readFileSync(t).toString('utf-8').split(/[\r\n]/);
|
||||
urls = readFileToArray(t);
|
||||
else
|
||||
urls = videoUrls as string[];
|
||||
|
||||
@@ -44,6 +63,47 @@ export function parseVideoUrls(videoUrls: any) {
|
||||
}
|
||||
|
||||
|
||||
export function getOutputDirectoriesList(outDirArg: string) {
|
||||
const isList = outDirArg.substring(outDirArg.length-4) === '.txt';
|
||||
let dirsList: string[];
|
||||
|
||||
if (isList)
|
||||
dirsList = sanitizeOutDirsList(readFileToArray(outDirArg));
|
||||
else
|
||||
dirsList = [outDirArg];
|
||||
|
||||
return dirsList;
|
||||
}
|
||||
|
||||
|
||||
export function makeOutputDirectories(dirsList: string[]) {
|
||||
dirsList.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.info(colors.yellow('Creating output directory:'));
|
||||
console.info(colors.green(dir)+'\n');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
} catch(e) {
|
||||
process.exit(ERROR_CODE.INVALID_OUTPUT_DIR);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function checkOutDirsUrlsMismatch(dirsList: string[], urlsList: string[]) {
|
||||
const dirsListL = dirsList.length;
|
||||
const urlsListL = urlsList.length;
|
||||
|
||||
if (dirsListL == 1) // one out dir, treat this as the chosen one for all
|
||||
return;
|
||||
else if (dirsListL != urlsListL)
|
||||
process.exit(ERROR_CODE.OUTDIRS_URLS_MISMATCH);
|
||||
}
|
||||
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -55,10 +115,8 @@ export function checkRequirements() {
|
||||
console.info(colors.green(`Using ${ffmpegVer}\n`));
|
||||
|
||||
} catch (e) {
|
||||
return null;
|
||||
process.exit(ERROR_CODE.MISSING_FFMPEG);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,90 +1,33 @@
|
||||
import { sleep, parseVideoUrls, checkRequirements, makeUniqueTitle, ffmpegTimemarkToChunk } from './utils';
|
||||
import {
|
||||
sleep, parseVideoUrls, checkRequirements, makeUniqueTitle, ffmpegTimemarkToChunk,
|
||||
makeOutputDirectories, getOutputDirectoriesList, checkOutDirsUrlsMismatch
|
||||
} from './Utils';
|
||||
import { getPuppeteerChromiumPath } from './PuppeteerHelper';
|
||||
import { setProcessEvents } from './Events';
|
||||
import { ERROR_CODE } from './Errors';
|
||||
import { TokenCache } from './TokenCache';
|
||||
import { getVideoMetadata } from './Metadata';
|
||||
import { Metadata, Session, Errors } from './Types';
|
||||
import { Metadata, Session } from './Types';
|
||||
import { drawThumbnail } from './Thumbnail';
|
||||
import { argv } from './CommandLineParser';
|
||||
|
||||
import isElevated from 'is-elevated';
|
||||
import puppeteer from 'puppeteer';
|
||||
import colors from 'colors';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yargs from 'yargs';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import cliProgress from 'cli-progress';
|
||||
|
||||
let tokenCache = new TokenCache();
|
||||
|
||||
const argv = yargs.options({
|
||||
username: {
|
||||
alias: 'u',
|
||||
type: 'string',
|
||||
demandOption: false
|
||||
},
|
||||
outputDirectory: {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
default: 'videos',
|
||||
demandOption: false
|
||||
},
|
||||
videoUrls: {
|
||||
alias: 'V',
|
||||
describe: 'List of video urls or path to txt file containing the urls',
|
||||
type: 'array',
|
||||
demandOption: true
|
||||
},
|
||||
simulate: {
|
||||
alias: 's',
|
||||
describe: `Disable video download and print metadata information to the console`,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
noThumbnails: {
|
||||
alias: 'nthumb',
|
||||
describe: `Do not display video thumbnails`,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
verbose: {
|
||||
alias: 'v',
|
||||
describe: `Print additional information to the console (use this before opening an issue on GitHub)`,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
}
|
||||
}).argv;
|
||||
const { FFmpegCommand, FFmpegInput, FFmpegOutput } = require('@tedconf/fessonia')();
|
||||
const tokenCache = new TokenCache();
|
||||
|
||||
async function init() {
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error(colors.red('Unhandled error!\nTimeout or fatal error, please check your downloads and try again if necessary.\n'));
|
||||
console.error(colors.red(reason as string));
|
||||
});
|
||||
|
||||
process.on('exit', (code) => {
|
||||
if (code == 0) {
|
||||
return
|
||||
};
|
||||
if (code in Errors)
|
||||
console.error(colors.bgRed(`\n\nError: ${Errors[code]} \n`))
|
||||
else
|
||||
console.error(colors.bgRed(`\n\nUnknown exit code ${code} \n`))
|
||||
});
|
||||
setProcessEvents(); // must be first!
|
||||
|
||||
if (await isElevated())
|
||||
process.exit(55);
|
||||
process.exit(ERROR_CODE.ELEVATED_SHELL);
|
||||
|
||||
// create output directory
|
||||
if (!fs.existsSync(argv.outputDirectory)) {
|
||||
console.log('Creating output directory: ' +
|
||||
process.cwd() + path.sep + argv.outputDirectory);
|
||||
fs.mkdirSync(argv.outputDirectory);
|
||||
}
|
||||
|
||||
console.info('Output Directory: %s', argv.outputDirectory);
|
||||
checkRequirements();
|
||||
|
||||
if (argv.username)
|
||||
console.info('Username: %s', argv.username);
|
||||
@@ -99,11 +42,11 @@ async function init() {
|
||||
}
|
||||
|
||||
async function DoInteractiveLogin(url: string, username?: string): Promise<Session> {
|
||||
|
||||
let videoId = url.split("/").pop() ?? process.exit(33)
|
||||
const videoId = url.split("/").pop() ?? process.exit(ERROR_CODE.INVALID_VIDEO_ID)
|
||||
|
||||
console.log('Launching headless Chrome to perform the OpenID Connect dance...');
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: getPuppeteerChromiumPath(),
|
||||
headless: false,
|
||||
args: ['--disable-dev-shm-usage']
|
||||
});
|
||||
@@ -122,7 +65,7 @@ async function DoInteractiveLogin(url: string, username?: string): Promise<Sessi
|
||||
console.info('We are logged in.');
|
||||
|
||||
let session = null;
|
||||
let tries: number = 0;
|
||||
let tries: number = 1;
|
||||
|
||||
while (!session) {
|
||||
try {
|
||||
@@ -137,13 +80,12 @@ async function DoInteractiveLogin(url: string, username?: string): Promise<Sessi
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (tries < 5){
|
||||
session = null;
|
||||
tries++;
|
||||
await sleep(3000);
|
||||
} else {
|
||||
process.exit(44)
|
||||
}
|
||||
if (tries > 5)
|
||||
process.exit(ERROR_CODE.NO_SESSION_INFO);
|
||||
|
||||
session = null;
|
||||
tries++;
|
||||
await sleep(3000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +99,7 @@ async function DoInteractiveLogin(url: string, username?: string): Promise<Sessi
|
||||
}
|
||||
|
||||
function extractVideoGuid(videoUrls: string[]): string[] {
|
||||
let videoGuids: string[] = [];
|
||||
const videoGuids: string[] = [];
|
||||
let guid: string | undefined = '';
|
||||
|
||||
for (const url of videoUrls) {
|
||||
@@ -165,8 +107,8 @@ function extractVideoGuid(videoUrls: string[]): string[] {
|
||||
guid = url.split('/').pop();
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Could not split the video GUID from URL: ${e.message}`);
|
||||
process.exit(33);
|
||||
console.error(`${e.message}`);
|
||||
process.exit(ERROR_CODE.INVALID_VIDEO_GUID);
|
||||
}
|
||||
|
||||
if (guid)
|
||||
@@ -181,16 +123,8 @@ function extractVideoGuid(videoUrls: string[]): string[] {
|
||||
return videoGuids;
|
||||
}
|
||||
|
||||
async function downloadVideo(videoUrls: string[], outputDirectory: string, session: Session) {
|
||||
async function downloadVideo(videoUrls: string[], outputDirectories: string[], session: Session) {
|
||||
const videoGuids = extractVideoGuid(videoUrls);
|
||||
const pbar = new cliProgress.SingleBar({
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
format: 'progress [{bar}] {percentage}% {speed}Kbps {eta_formatted}',
|
||||
barsize: Math.floor(process.stdout.columns / 3),
|
||||
stopOnComplete: true,
|
||||
etaBuffer: 20
|
||||
});
|
||||
|
||||
console.log('Fetching metadata...');
|
||||
|
||||
@@ -208,12 +142,25 @@ async function downloadVideo(videoUrls: string[], outputDirectory: string, sessi
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(metadata.map(async video => {
|
||||
console.log(colors.blue(`\nDownloading Video: ${video.title}\n`));
|
||||
if (argv.verbose)
|
||||
console.log(outputDirectories);
|
||||
|
||||
video.title = makeUniqueTitle(sanitize(video.title) + ' - ' + video.date, argv.outputDirectory);
|
||||
const outDirsIdxInc = outputDirectories.length > 1 ? 1:0;
|
||||
for (let i=0, j=0, l=metadata.length; i<l; ++i, j+=outDirsIdxInc) {
|
||||
const video = metadata[i];
|
||||
let previousChunks = 0;
|
||||
const pbar = new cliProgress.SingleBar({
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
format: 'progress [{bar}] {percentage}% {speed} {eta_formatted}',
|
||||
barsize: Math.floor(process.stdout.columns / 3),
|
||||
stopOnComplete: true,
|
||||
hideCursor: true,
|
||||
});
|
||||
|
||||
const outputPath = outputDirectory + path.sep + video.title + '.mp4';
|
||||
console.log(colors.yellow(`\nDownloading Video: ${video.title}\n`));
|
||||
|
||||
video.title = makeUniqueTitle(sanitize(video.title) + ' - ' + video.date, outputDirectories[j]);
|
||||
|
||||
// Very experimental inline thumbnail rendering
|
||||
if (!argv.noThumbnails)
|
||||
@@ -221,57 +168,69 @@ async function downloadVideo(videoUrls: string[], outputDirectory: string, sessi
|
||||
|
||||
console.info('Spawning ffmpeg with access token and HLS URL. This may take a few seconds...\n');
|
||||
|
||||
ffmpeg()
|
||||
.input(video.playbackUrl)
|
||||
.inputOption([
|
||||
// Never remove those "useless" escapes or ffmpeg will not
|
||||
// pick up the header correctly
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
'-headers', `Authorization:\ Bearer\ ${session.AccessToken}`,
|
||||
])
|
||||
.format('mp4')
|
||||
.saveToFile(outputPath)
|
||||
.on('codecData', data => {
|
||||
console.log(`Input is ${data.video} with ${data.audio} audio.\n`);
|
||||
const outputPath = outputDirectories[j] + path.sep + video.title + '.mp4';
|
||||
const ffmpegInpt = new FFmpegInput(video.playbackUrl, new Map([
|
||||
['headers', `Authorization:\ Bearer\ ${session.AccessToken}`]
|
||||
]));
|
||||
const ffmpegOutput = new FFmpegOutput(outputPath);
|
||||
const ffmpegCmd = new FFmpegCommand();
|
||||
|
||||
pbar.start(video.duration, 0, {
|
||||
speed: '0'
|
||||
});
|
||||
pbar.start(video.totalChunks, 0, {
|
||||
speed: '0'
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
pbar.stop();
|
||||
});
|
||||
})
|
||||
.on('progress', progress => {
|
||||
const currentChunks = ffmpegTimemarkToChunk(progress.timemark);
|
||||
// prepare ffmpeg command line
|
||||
ffmpegCmd.addInput(ffmpegInpt);
|
||||
ffmpegCmd.addOutput(ffmpegOutput);
|
||||
|
||||
pbar.update(currentChunks, {
|
||||
speed: progress.currentKbps
|
||||
});
|
||||
})
|
||||
.on('error', err => {
|
||||
pbar.stop();
|
||||
console.log(`ffmpeg returned an error: ${err.message}`);
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log(colors.green(`\nDownload finished: ${outputPath}`));
|
||||
// set events
|
||||
ffmpegCmd.on('update', (data: any) => {
|
||||
const currentChunks = ffmpegTimemarkToChunk(data.out_time);
|
||||
const incChunk = currentChunks - previousChunks;
|
||||
|
||||
pbar.increment(incChunk, {
|
||||
speed: data.bitrate
|
||||
});
|
||||
}));
|
||||
|
||||
previousChunks = currentChunks;
|
||||
});
|
||||
|
||||
ffmpegCmd.on('error', (error: any) => {
|
||||
pbar.stop();
|
||||
console.log(`\nffmpeg returned an error: ${error.message}`);
|
||||
process.exit(ERROR_CODE.UNK_FFMPEG_ERROR);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
pbar.stop();
|
||||
});
|
||||
|
||||
// let the magic begin...
|
||||
await new Promise((resolve: any, reject: any) => {
|
||||
ffmpegCmd.on('success', (data:any) => {
|
||||
pbar.update(video.totalChunks); // set progress bar to 100%
|
||||
console.log(colors.green(`\nDownload finished: ${outputPath}`));
|
||||
resolve();
|
||||
});
|
||||
|
||||
ffmpegCmd.spawn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
checkRequirements() ?? process.exit(22);
|
||||
await init();
|
||||
await init(); // must be first
|
||||
|
||||
const videoUrls: string[] = parseVideoUrls(argv.videoUrls) ?? process.exit(66);
|
||||
const outDirs: string[] = getOutputDirectoriesList(argv.outputDirectory as string);
|
||||
const videoUrls: string[] = parseVideoUrls(argv.videoUrls);
|
||||
let session: Session;
|
||||
|
||||
let session = tokenCache.Read();
|
||||
checkOutDirsUrlsMismatch(outDirs, videoUrls);
|
||||
makeOutputDirectories(outDirs); // create all dirs now to prevent ffmpeg panic
|
||||
|
||||
if (session == null) {
|
||||
session = await DoInteractiveLogin(videoUrls[0], argv.username);
|
||||
}
|
||||
session = tokenCache.Read() ?? await DoInteractiveLogin(videoUrls[0], argv.username);
|
||||
|
||||
downloadVideo(videoUrls, argv.outputDirectory, session);
|
||||
downloadVideo(videoUrls, outDirs, session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user