
* Add fluent-ffmpeg back and cross-platform progress bar * Repo clean up Move ts files to src, build and output js files to build folder * Do not print messages when exit code is 0 this is triggered by signal events Co-authored-by: kylon <kylonux@gmail.com>
275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
import { sleep, parseVideoUrls, checkRequirements, makeUniqueTitle, ffmpegTimemarkToChunk } from './utils';
|
|
import { TokenCache } from './TokenCache';
|
|
import { getVideoMetadata } from './Metadata';
|
|
import { Metadata, Session, Errors } from './Types';
|
|
import { drawThumbnail } from './Thumbnail';
|
|
|
|
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;
|
|
|
|
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 in Errors)
|
|
console.error(colors.bgRed(`\n\nError: ${Errors[code]} \n`))
|
|
else
|
|
console.error(colors.bgRed(`\n\nUnknown exit code ${code} \n`))
|
|
});
|
|
|
|
if (await isElevated())
|
|
process.exit(55);
|
|
|
|
// 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);
|
|
|
|
if (argv.username)
|
|
console.info('Username: %s', argv.username);
|
|
|
|
if (argv.simulate)
|
|
console.info(colors.yellow('Simulate mode, there will be no video download.\n'));
|
|
|
|
if (argv.verbose) {
|
|
console.info('Video URLs:');
|
|
console.info(argv.videoUrls);
|
|
}
|
|
}
|
|
|
|
async function DoInteractiveLogin(url: string, username?: string): Promise<Session> {
|
|
|
|
let videoId = url.split("/").pop() ?? process.exit(33)
|
|
|
|
console.log('Launching headless Chrome to perform the OpenID Connect dance...');
|
|
const browser = await puppeteer.launch({
|
|
headless: false,
|
|
args: ['--disable-dev-shm-usage']
|
|
});
|
|
const page = (await browser.pages())[0];
|
|
console.log('Navigating to login page...');
|
|
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('input[type="email"]');
|
|
|
|
if (username) {
|
|
await page.keyboard.type(username);
|
|
await page.click('input[type="submit"]');
|
|
}
|
|
|
|
await browser.waitForTarget(target => target.url().includes(videoId), { timeout: 150000 });
|
|
console.info('We are logged in.');
|
|
|
|
let session = null;
|
|
let tries: number = 0;
|
|
|
|
while (!session) {
|
|
try {
|
|
let sessionInfo: any;
|
|
session = await page.evaluate(
|
|
() => {
|
|
return {
|
|
AccessToken: sessionInfo.AccessToken,
|
|
ApiGatewayUri: sessionInfo.ApiGatewayUri,
|
|
ApiGatewayVersion: sessionInfo.ApiGatewayVersion
|
|
};
|
|
}
|
|
);
|
|
} catch (error) {
|
|
if (tries < 5){
|
|
session = null;
|
|
tries++;
|
|
await sleep(3000);
|
|
} else {
|
|
process.exit(44)
|
|
}
|
|
}
|
|
}
|
|
|
|
tokenCache.Write(session);
|
|
console.log('Wrote access token to token cache.');
|
|
console.log("At this point Chromium's job is done, shutting it down...\n");
|
|
|
|
await browser.close();
|
|
|
|
return session;
|
|
}
|
|
|
|
function extractVideoGuid(videoUrls: string[]): string[] {
|
|
let videoGuids: string[] = [];
|
|
let guid: string | undefined = '';
|
|
|
|
for (const url of videoUrls) {
|
|
try {
|
|
guid = url.split('/').pop();
|
|
|
|
} catch (e) {
|
|
console.error(`Could not split the video GUID from URL: ${e.message}`);
|
|
process.exit(33);
|
|
}
|
|
|
|
if (guid)
|
|
videoGuids.push(guid);
|
|
}
|
|
|
|
if (argv.verbose) {
|
|
console.info('Video GUIDs:');
|
|
console.info(videoGuids);
|
|
}
|
|
|
|
return videoGuids;
|
|
}
|
|
|
|
async function downloadVideo(videoUrls: string[], outputDirectory: 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...');
|
|
|
|
const metadata: Metadata[] = await getVideoMetadata(videoGuids, session, argv.verbose);
|
|
|
|
if (argv.simulate) {
|
|
metadata.forEach(video => {
|
|
console.log(
|
|
colors.yellow('\n\nTitle: ') + colors.green(video.title) +
|
|
colors.yellow('\nPublished Date: ') + colors.green(video.date) +
|
|
colors.yellow('\nPlayback URL: ') + colors.green(video.playbackUrl)
|
|
);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
await Promise.all(metadata.map(async video => {
|
|
console.log(colors.blue(`\nDownloading Video: ${video.title}\n`));
|
|
|
|
video.title = makeUniqueTitle(sanitize(video.title) + ' - ' + video.date, argv.outputDirectory);
|
|
|
|
const outputPath = outputDirectory + path.sep + video.title + '.mp4';
|
|
|
|
// Very experimental inline thumbnail rendering
|
|
if (!argv.noThumbnails)
|
|
await drawThumbnail(video.posterImage, session.AccessToken);
|
|
|
|
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`);
|
|
|
|
pbar.start(video.duration, 0, {
|
|
speed: '0'
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
pbar.stop();
|
|
});
|
|
})
|
|
.on('progress', progress => {
|
|
const currentChuncks = ffmpegTimemarkToChunk(progress.timemark);
|
|
|
|
pbar.update(currentChuncks, {
|
|
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}`));
|
|
});
|
|
}));
|
|
}
|
|
|
|
async function main() {
|
|
checkRequirements() ?? process.exit(22);
|
|
await init();
|
|
|
|
const videoUrls: string[] = parseVideoUrls(argv.videoUrls) ?? process.exit(66);
|
|
|
|
let session = tokenCache.Read();
|
|
|
|
if (session == null) {
|
|
session = await DoInteractiveLogin(videoUrls[0], argv.username);
|
|
}
|
|
|
|
downloadVideo(videoUrls, argv.outputDirectory, session);
|
|
}
|
|
|
|
|
|
main();
|