mirror of
https://github.com/snobu/destreamer.git
synced 2026-01-28 02:42:20 +00:00
Compare commits
64 Commits
c81b16c8f3
...
aria2c_for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd76ba226 | ||
|
|
cea18bbf5e | ||
|
|
3fe64d13a3 | ||
|
|
6d8f3c6ee0 | ||
|
|
1e8db6f9b9 | ||
|
|
195d0dd1f8 | ||
|
|
66a7f609b3 | ||
|
|
c1a0994e2a | ||
|
|
6d28b3c397 | ||
|
|
08364ed635 | ||
|
|
ddafc5091d | ||
|
|
0b4c900e3f | ||
|
|
461df7b726 | ||
|
|
0f2d902d2e | ||
|
|
4a45c68943 | ||
|
|
eacb2b63c1 | ||
|
|
a179bdcadc | ||
|
|
1763dc8cbd | ||
|
|
41206a97f0 | ||
|
|
85f3beae71 | ||
|
|
3249759c29 | ||
|
|
2a3c3eb225 | ||
|
|
b89e04156f | ||
|
|
331efd9773 | ||
|
|
020518e542 | ||
|
|
502565dcea | ||
|
|
c7e0415786 | ||
|
|
14cfe7c18e | ||
|
|
95c7150449 | ||
|
|
482a506145 | ||
|
|
38edbadf4a | ||
|
|
af4725c371 | ||
|
|
c9c9fefd2d | ||
|
|
8df51555f7 | ||
|
|
3e472f9ae0 | ||
|
|
9453458664 | ||
|
|
7cab44a2e4 | ||
|
|
6c8628e5e1 | ||
|
|
796753f170 | ||
|
|
6f082e163b | ||
|
|
16a85325d9 | ||
|
|
e9dea1484e | ||
|
|
a93b32879c | ||
|
|
f1476ffe39 | ||
|
|
ec099e9124 | ||
|
|
96f4c90277 | ||
|
|
a185f51eb5 | ||
|
|
aa21e54a3d | ||
|
|
8b61f86639 | ||
|
|
d037b7cfb2 | ||
|
|
9e25870191 | ||
|
|
6e874f5138 | ||
|
|
1dff41c1bf | ||
|
|
903f2bfafc | ||
|
|
0c65ff7dfe | ||
|
|
5350bc324b | ||
|
|
d29bd54d5b | ||
|
|
29a6fab20b | ||
|
|
6c0e37ad98 | ||
|
|
0d8b4204fa | ||
|
|
685fa27cc7 | ||
|
|
67573fcf86 | ||
|
|
53342932d9 | ||
|
|
0cbc962bf3 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,8 @@
|
||||
*.js
|
||||
*.zip
|
||||
|
||||
.vscode\launch.json
|
||||
|
||||
.chrome_data
|
||||
node_modules
|
||||
videos
|
||||
|
||||
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"type": "node",
|
||||
"runtimeArgs": ["--max-http-header-size", "32768"],
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/build/src/destreamer.js",
|
||||
"args": [
|
||||
"-i",
|
||||
"https://web.microsoftstream.com/video/ce4da1ff-0400-86ec-2ad6-f1ea83412074" // Bacon and Eggs
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
16
README.md
16
README.md
@@ -2,14 +2,9 @@
|
||||
<img src="https://github.com/snobu/destreamer/workflows/Node%20CI/badge.svg" alt="CI build status" />
|
||||
</a>
|
||||
|
||||
# BREAKING
|
||||
# destreamer v3.0 (aria2c as download manager)
|
||||
|
||||
**destreamer v3.0** is just around the corner. Download speed improvement is astonishing and we have a never before seen photo from the design sessions:<br><br>
|
||||

|
||||
|
||||
Help us pick a codename for the new release:<br><br>
|
||||
<br><br>
|
||||
Comment in this thread: https://github.com/snobu/destreamer/issues/223
|
||||
## This is a pre-release branch so don't expect stability, do expect speed improvements. Tons of it.
|
||||
|
||||

|
||||
|
||||
@@ -49,6 +44,8 @@ Hopefully this doesn't break the end user agreement for Microsoft Stream. Since
|
||||
- **npm**: usually comes with Node.js, type `npm` in your terminal to check for its presence
|
||||
- [**ffmpeg**][ffmpeg]: a recent version (year 2019 or above), in `$PATH` or in the same directory as this README file (project root).
|
||||
- [**git**][git]: one or more npm dependencies require git.
|
||||
- [**aria2**][aria2]: present in your `$PATH`, on Linux you can install via `sudo apt install aria2`.
|
||||
|
||||
|
||||
Destreamer takes a [honeybadger](https://www.youtube.com/watch?v=4r7wHMg5Yjg) approach towards the OS it's running on. We've successfully tested it on Windows, macOS and Linux.
|
||||
|
||||
@@ -181,9 +178,9 @@ These optional lines must start with white space(s).
|
||||
Usage -
|
||||
```
|
||||
https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx
|
||||
-dir=videos/lessons/week1
|
||||
-dir="videos/lessons/week1"
|
||||
https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx
|
||||
-dir=videos/lessons/week2"
|
||||
-dir="videos/lessons/week2"
|
||||
```
|
||||
|
||||
### Title template
|
||||
@@ -233,6 +230,7 @@ Please open an [issue](https://github.com/snobu/destreamer/issues) and we'll loo
|
||||
[xming]: https://sourceforge.net/projects/xming/
|
||||
[node]: https://nodejs.org/en/download/
|
||||
[git]: https://git-scm.com/downloads
|
||||
[aria2]: https://aria2.github.io
|
||||
[wsl]: https://github.com/snobu/destreamer/issues/90#issuecomment-619377950
|
||||
[polimi]: https://www.polimi.it
|
||||
[unipi]: https://www.unipi.it/
|
||||
|
||||
7256
package-lock.json
generated
7256
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -7,7 +7,9 @@
|
||||
"version": "2.1.0",
|
||||
"description": "Save Microsoft Stream videos for offline enjoyment.",
|
||||
"main": "build/src/destreamer.js",
|
||||
"bin": "build/src/destreamer.js",
|
||||
"bin": {
|
||||
"destreamer": "build/src/destreamer.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo Transpiling TypeScript to JavaScript... && node node_modules/typescript/bin/tsc && echo Destreamer was built successfully.",
|
||||
"test": "mocha build/test",
|
||||
@@ -17,34 +19,37 @@
|
||||
"author": "snobu",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^7.0.2",
|
||||
"@types/puppeteer": "^1.20.4",
|
||||
"@types/cli-progress": "^3.8.0",
|
||||
"@types/jwt-decode": "^2.2.1",
|
||||
"@types/mocha": "^8.0.4",
|
||||
"@types/puppeteer": "^5.4.0",
|
||||
"@types/readline-sync": "^1.4.3",
|
||||
"@types/tmp": "^0.1.0",
|
||||
"@types/yargs": "^15.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^2.25.0",
|
||||
"@typescript-eslint/parser": "^2.25.0",
|
||||
"eslint": "^6.8.0",
|
||||
"mocha": "^7.1.1",
|
||||
"tmp": "^0.1.0"
|
||||
"@types/tmp": "^0.2.0",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@types/yargs": "^15.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.9.0",
|
||||
"@typescript-eslint/parser": "^4.9.0",
|
||||
"eslint": "^7.14.0",
|
||||
"mocha": "^8.2.1",
|
||||
"typescript": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tedconf/fessonia": "^2.1.0",
|
||||
"@types/cli-progress": "^3.4.2",
|
||||
"@types/jwt-decode": "^2.2.1",
|
||||
"axios": "^0.19.2",
|
||||
"axios-retry": "^3.1.8",
|
||||
"cli-progress": "^3.7.0",
|
||||
"axios": "^0.21.0",
|
||||
"axios-retry": "^3.1.9",
|
||||
"cli-progress": "^3.8.2",
|
||||
"colors": "^1.4.0",
|
||||
"is-elevated": "^3.0.0",
|
||||
"iso8601-duration": "^1.2.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"puppeteer": "2.1.1",
|
||||
"iso8601-duration": "^1.3.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"m3u8-parser": "^4.5.0",
|
||||
"portfinder": "^1.0.28",
|
||||
"puppeteer": "5.5.0",
|
||||
"readline-sync": "^1.4.10",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"terminal-image": "^1.0.1",
|
||||
"typescript": "^3.8.3",
|
||||
"winston": "^3.3.2",
|
||||
"yargs": "^15.0.3"
|
||||
"terminal-image": "^1.2.1",
|
||||
"tmp": "^0.2.1",
|
||||
"winston": "^3.3.3",
|
||||
"ws": "^7.4.0",
|
||||
"yargs": "^16.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export class ApiClient {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: session?.ApiGatewayUri,
|
||||
// timeout: 7000,
|
||||
headers: { 'User-Agent': 'destreamer/2.0 (Hammer of Dawn)' }
|
||||
headers: { 'User-Agent': 'destreamer/3.0 (Preview)' }
|
||||
});
|
||||
|
||||
axiosRetry(this.axiosInstance, {
|
||||
@@ -34,7 +34,10 @@ export class ApiClient {
|
||||
return true;
|
||||
}
|
||||
logger.warn(`Got HTTP code ${err?.response?.status ?? undefined}. Retrying request...`);
|
||||
logger.verbose(`Here is the error message: \n '${err.response?.data}`);
|
||||
logger.warn('Here is the error message: \n' +
|
||||
JSON.stringify(err.response?.data ?? undefined) +
|
||||
'\nRetrying request...');
|
||||
logger.warn(`We called this URL: ${err.response?.config.baseURL}${err.response?.config.url}`);
|
||||
|
||||
const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);
|
||||
|
||||
@@ -81,6 +84,13 @@ export class ApiClient {
|
||||
'Authorization': 'Bearer ' + this.session?.AccessToken
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
'[ApiClient.callApi]\n' +
|
||||
'path: ' + path + '\n' +
|
||||
'method: ' + method + '\n' +
|
||||
'payload: ' + payload + '\n'
|
||||
);
|
||||
|
||||
return this.axiosInstance?.request({
|
||||
method: method,
|
||||
headers: headers,
|
||||
@@ -102,6 +112,14 @@ export class ApiClient {
|
||||
'Authorization': 'Bearer ' + this.session?.AccessToken
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
'[ApiClient.callUrl]\n' +
|
||||
'url: ' + url + '\n' +
|
||||
'method: ' + method + '\n' +
|
||||
'payload: ' + payload + '\n' +
|
||||
'responseType: ' + responseType + '\n'
|
||||
);
|
||||
|
||||
return this.axiosInstance?.request({
|
||||
method: method,
|
||||
headers: headers,
|
||||
|
||||
@@ -71,32 +71,27 @@ export const argv: any = yargs.options({
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
debug: {
|
||||
alias: 'd',
|
||||
describe: 'Set logging level to debug (only use this if you know what are doing)',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
selectQuality: {
|
||||
alias: 'q',
|
||||
describe: 'Select the quality with a number 1 (worst) trough 10 (best), 0 prompt the user for each video',
|
||||
default: 10,
|
||||
type: 'number',
|
||||
demandOption: false
|
||||
},
|
||||
closedCaptions: {
|
||||
alias: 'cc',
|
||||
describe: 'Check if closed captions are available and let the user choose which one to download (will not ask if only one available).',
|
||||
describe: 'Check if closed captions are available and let the user choose which one to download (will not ask if only one available)',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
noCleanup: {
|
||||
alias: 'nc',
|
||||
describe: 'Do not delete the downloaded video file when an FFmpeg error occurs.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false
|
||||
},
|
||||
vcodec: {
|
||||
describe: 'Re-encode video track. Specify FFmpeg codec (e.g. libx265) or set to "none" to disable video.',
|
||||
type: 'string',
|
||||
default: 'copy',
|
||||
demandOption: false
|
||||
},
|
||||
acodec: {
|
||||
describe: 'Re-encode audio track. Specify FFmpeg codec (e.g. libopus) or set to "none" to disable audio.',
|
||||
type: 'string',
|
||||
default: 'copy',
|
||||
demandOption: false
|
||||
},
|
||||
format: {
|
||||
describe: 'Output container format (mkv, mp4, mov, anything that FFmpeg supports).',
|
||||
type: 'string',
|
||||
@@ -113,17 +108,9 @@ export const argv: any = yargs.options({
|
||||
.wrap(120)
|
||||
.check(() => noArguments())
|
||||
.check((argv: any) => checkInputConflicts(argv.videoUrls, argv.inputFile))
|
||||
.check((argv: any) => {
|
||||
if (checkOutDir(argv.outputDirectory)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
logger.error(CLI_ERROR.INVALID_OUTDIR);
|
||||
|
||||
throw new Error(' ');
|
||||
}
|
||||
})
|
||||
.check((argv: any) => checkOutputDirectoryExistance(argv.outputDirectory))
|
||||
.check((argv: any) => isOutputTemplateValid(argv))
|
||||
.check((argv: any) => checkQualityValue(argv))
|
||||
.argv;
|
||||
|
||||
|
||||
@@ -173,6 +160,18 @@ function checkInputConflicts(videoUrls: Array<string | number> | undefined,
|
||||
}
|
||||
|
||||
|
||||
function checkOutputDirectoryExistance(dir: string): boolean {
|
||||
if (checkOutDir(dir)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
logger.error(CLI_ERROR.INVALID_OUTDIR, { fatal: true });
|
||||
|
||||
throw new Error(' ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function isOutputTemplateValid(argv: any): boolean {
|
||||
let finalTemplate: string = argv.outputTemplate;
|
||||
const elementRegEx = RegExp(/{(.*?)}/g);
|
||||
@@ -206,8 +205,27 @@ function isOutputTemplateValid(argv: any): boolean {
|
||||
}
|
||||
|
||||
|
||||
function checkQualityValue(argv: any): boolean {
|
||||
if (isNaN(argv.selectQuality)) {
|
||||
logger.error('The quality value provided was not a number, switching to default');
|
||||
argv.selectQuality = 10;
|
||||
|
||||
return true;
|
||||
}
|
||||
else if (argv.selectQuality < 0 || argv.selectQuality > 10) {
|
||||
logger.error('The quality value provided was outside the valid range, switching to default');
|
||||
argv.selectQuality = 10;
|
||||
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function promptUser(choices: Array<string>): number {
|
||||
let index: number = readlineSync.keyInSelect(choices, 'Which resolution/format do you prefer?');
|
||||
const index: number = readlineSync.keyInSelect(choices, 'Which resolution/format do you prefer?');
|
||||
|
||||
if (index === -1) {
|
||||
process.exit(ERROR_CODE.CANCELLED_USER_INPUT);
|
||||
|
||||
36
src/Decrypter.ts
Normal file
36
src/Decrypter.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { logger } from './Logger';
|
||||
import { Session } from './Types';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
|
||||
export async function getDecrypter(playlistUrl: string, session: Session): Promise<crypto.Decipher> {
|
||||
const apiClient = ApiClient.getInstance(session);
|
||||
|
||||
const keyOption = await apiClient.callUrl(playlistUrl, 'get', null, 'text')
|
||||
.then(res => (res?.data as string).split(/\r?\n/)
|
||||
.find(line => line.startsWith('#EXT-X-KEY')));
|
||||
|
||||
if (keyOption) {
|
||||
logger.debug('[Decrypter] CRIPTO LINE IN M3U8: ' + keyOption);
|
||||
|
||||
const match = RegExp(/#EXT-X-KEY:METHOD=(.*?),URI="(.*?)",IV=0X(.*)/).exec(keyOption);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('No match for regex');
|
||||
}
|
||||
|
||||
const algorithm = match[1].toLowerCase().replace('-', '');
|
||||
|
||||
const key: Buffer = await apiClient.callUrl(match[2], 'post', null, 'arraybuffer')
|
||||
.then(res => res?.data);
|
||||
|
||||
const iv = Buffer.from(match[3], 'hex');
|
||||
|
||||
return crypto.createDecipheriv(algorithm, key, iv);
|
||||
}
|
||||
else {
|
||||
process.exit(555);
|
||||
}
|
||||
}
|
||||
310
src/DownloadManager.ts
Normal file
310
src/DownloadManager.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { ERROR_CODE } from './Errors';
|
||||
import { logger } from './Logger';
|
||||
|
||||
import cliProgress from 'cli-progress';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
|
||||
export class DownloadManager {
|
||||
// it's initalized in this.init()
|
||||
private webSocket!: WebSocket;
|
||||
private connected: boolean;
|
||||
// NOTE: is there a way to fix the ETA? Can't get size nor ETA from aria that I can see
|
||||
// we initialize this for each download
|
||||
private progresBar!: cliProgress.Bar;
|
||||
private completed: number;
|
||||
private queue: Set<string>;
|
||||
private index: number;
|
||||
|
||||
public constructor() {
|
||||
this.connected = false;
|
||||
this.completed = 0;
|
||||
this.queue = new Set<string>();
|
||||
this.index = 1;
|
||||
|
||||
if (!process.stdout.columns) {
|
||||
logger.warn(
|
||||
'Unable to get number of columns from terminal.\n' +
|
||||
'This happens sometimes in Cygwin/MSYS.\n' +
|
||||
'No progress bar can be rendered, however the download process should not be affected.\n\n' +
|
||||
'Please use PowerShell or cmd.exe to run destreamer on Windows.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MUST BE CALLED BEFORE ANY OTHER OPERATION
|
||||
*
|
||||
* Wait for an established connection between the webSocket
|
||||
* and Aria2c with a 10s timeout.
|
||||
* Then send aria2c the global config option if specified.
|
||||
*/
|
||||
public async init(port: number, options?: { [option: string]: string }): Promise<void> {
|
||||
let socTries = 0;
|
||||
const maxTries = 10;
|
||||
let timer = 0;
|
||||
const waitTime = 20;
|
||||
|
||||
const errorHanlder = async (err: WebSocket.ErrorEvent): Promise<void> => {
|
||||
// we try for 10 sec to initialize a socket on the specified port
|
||||
if (err.error.code === 'ECONNREFUSED' && socTries < maxTries) {
|
||||
logger.debug(`[DownloadMangaer] trying webSocket init ${socTries}/${maxTries}`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
this.webSocket = new WebSocket(`http://localhost:${port}/jsonrpc`);
|
||||
this.webSocket.onerror = errorHanlder;
|
||||
this.webSocket.onopen = openHandler;
|
||||
socTries++;
|
||||
}
|
||||
else {
|
||||
logger.error(err);
|
||||
process.exit(ERROR_CODE.NO_CONNECT_ARIA2C);
|
||||
}
|
||||
};
|
||||
|
||||
const openHandler = (event: WebSocket.OpenEvent): void => {
|
||||
this.connected = true;
|
||||
logger.debug(`[DownloadMangaer] open event recived ${event}`);
|
||||
logger.info('Connected to aria2 daemon!');
|
||||
};
|
||||
|
||||
// create webSocket
|
||||
this.webSocket = new WebSocket(`http://localhost:${port}/jsonrpc`);
|
||||
this.webSocket.onerror = errorHanlder;
|
||||
this.webSocket.onopen = openHandler;
|
||||
|
||||
|
||||
// wait for socket connection
|
||||
while (!this.connected) {
|
||||
if (timer < waitTime) {
|
||||
timer++;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
else {
|
||||
process.exit(ERROR_CODE.NO_CONNECT_ARIA2C);
|
||||
}
|
||||
}
|
||||
|
||||
// setup messages handling
|
||||
this.webSocket.on('message', (data: WebSocket.Data) => {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
|
||||
// print only messaged not handled during download
|
||||
// NOTE: maybe we could remove this and re-add when the downloads are done
|
||||
if (parsed.method !== 'aria2.onDownloadComplete' &&
|
||||
parsed.method !== 'aria2.onDownloadStart' &&
|
||||
parsed.method !== 'aria2.onDownloadError' &&
|
||||
parsed.id !== 'getSpeed' &&
|
||||
parsed.id !== 'addUrl' &&
|
||||
parsed.id !== 'shutdown' &&
|
||||
parsed.id !== 'getUrlForRetry') {
|
||||
logger.info('[INCOMING] \n' + JSON.stringify(parsed, null, 4) + '\n\n');
|
||||
}
|
||||
});
|
||||
|
||||
if (options) {
|
||||
logger.info('Now trying to send configs...');
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
this.webSocket.send(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'Destreamer',
|
||||
method: 'aria2.getGlobalOption'
|
||||
}));
|
||||
|
||||
logger.debug('[DownloadMangaer] Setup listener count on "message": ' + this.webSocket.listenerCount('message'));
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
let exited = false;
|
||||
let timer = 0;
|
||||
const waitTime = 10;
|
||||
|
||||
this.webSocket.on('message', (data: WebSocket.Data) => {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
|
||||
if (parsed.result === 'OK') {
|
||||
exited = true;
|
||||
logger.verbose('Aria2c shutdown complete');
|
||||
}
|
||||
});
|
||||
|
||||
this.webSocket.send(this.createMessage('aria2.shutdown', null, 'shutdown'));
|
||||
this.webSocket.close();
|
||||
|
||||
while ((this.webSocket.readyState !== this.webSocket.CLOSED) || !exited) {
|
||||
if (timer < waitTime) {
|
||||
timer++;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initProgresBar(): void {
|
||||
this.progresBar = new cliProgress.SingleBar({
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
format: 'progress [{bar}] {percentage}% {speed} MB/s {eta_formatted}',
|
||||
noTTYOutput: true,
|
||||
notTTYSchedule: 3000,
|
||||
// process.stdout.columns may return undefined in some terminals (Cygwin/MSYS)
|
||||
barsize: Math.floor((process.stdout.columns || 30) / 3),
|
||||
stopOnComplete: true,
|
||||
hideCursor: true,
|
||||
});
|
||||
}
|
||||
|
||||
private createMessage(method: 'aria2.addUri', params: [[string]] | [[string], object], id?: string): string;
|
||||
private createMessage(method: 'aria2.tellStatus', params: [[string]] | [string, object], id?: string): string;
|
||||
private createMessage(method: 'aria2.changeOption', params: [string, object], id?: string): string;
|
||||
private createMessage(method: 'aria2.changeGlobalOption', params: [{ [option: string]: string }], id?: string): string;
|
||||
private createMessage(method: 'system.multicall', params: [Array<object>], id?: string): string;
|
||||
// FIXME: I don't know how to properly implement this one that doesn't require params..
|
||||
private createMessage(method: 'aria2.getGlobalStat', params?: null, id?: string): string;
|
||||
private createMessage(method: 'aria2.shutdown', params?: null, id?: string): string;
|
||||
private createMessage(method: string, params?: any, id?: string): string {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: id ?? 'Destreamer',
|
||||
method: method,
|
||||
// This took 40 mins just because I didn't want to use an if...so smart -_-
|
||||
...(!!params && { params: params })
|
||||
});
|
||||
}
|
||||
|
||||
private createMulticallElement(method: string, params?: any): any {
|
||||
return {
|
||||
methodName: method,
|
||||
// This took 40 mins just because I didn't want to use an if...so smart -_-
|
||||
...(!!params && { params: params })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* For general options see
|
||||
* {@link https://aria2.github.io/manual/en/html/aria2c.html#aria2.changeOption here}.
|
||||
* For single download options see
|
||||
* {@link https://aria2.github.io/manual/en/html/aria2c.html#aria2.changeGlobalOption here}
|
||||
*
|
||||
* @param options object with key: value pairs
|
||||
*/
|
||||
private setOptions(options: { [option: string]: string }, guid?: string): void {
|
||||
const message: string = guid ?
|
||||
this.createMessage('aria2.changeOption', [guid, options]) :
|
||||
this.createMessage('aria2.changeGlobalOption', [options]);
|
||||
|
||||
this.webSocket.send(message);
|
||||
}
|
||||
|
||||
public downloadUrls(urls: Array<string>, directory: string): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
|
||||
this.index = 1;
|
||||
this.completed = 0;
|
||||
// initialize the bar as a new one
|
||||
this.initProgresBar();
|
||||
let barStarted = false;
|
||||
|
||||
const handleResponse = (data: WebSocket.Data): void => {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
|
||||
/* I ordered them in order of (probable) times called so
|
||||
that we don't check useless ifs (even if we aren't caring about efficency) */
|
||||
|
||||
// handle download completions
|
||||
if (parsed.method === 'aria2.onDownloadComplete') {
|
||||
this.queue.delete(parsed.params.pop().gid.toString());
|
||||
this.progresBar.update(++this.completed);
|
||||
|
||||
/* NOTE: probably we could use setIntervall because reling on
|
||||
a completed download is good in most cases (since the segments
|
||||
are small and a lot, somany and frequent updates) BUT if the user
|
||||
internet speed is really low the completed downalods come in
|
||||
less frequently and we have less updates */
|
||||
this.webSocket.send(this.createMessage('aria2.getGlobalStat', null, 'getSpeed'));
|
||||
|
||||
if (this.queue.size === 0) {
|
||||
this.webSocket.off('message', handleResponse);
|
||||
logger.debug('[DownloadMangaer] End download listener count on "message": ' + this.webSocket.listenerCount('message'));
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// handle speed update packages
|
||||
else if (parsed.id === 'getSpeed') {
|
||||
this.progresBar.update(this.completed,
|
||||
{ speed: ((parsed.result.downloadSpeed as number) / 1000000).toFixed(2) });
|
||||
}
|
||||
|
||||
// handle download errors
|
||||
else if (parsed.method === 'aria2.onDownloadError') {
|
||||
logger.error('Error while downloading, retrying...');
|
||||
|
||||
const errorGid: string = parsed.params.pop().gid.toString();
|
||||
this.queue.delete(errorGid);
|
||||
|
||||
// FIXME: I don't know if it's fixed, I was not able to reproduce a fail reliably
|
||||
this.webSocket.send(this.createMessage('aria2.tellStatus', [errorGid, ['files']], 'getUrlForRetry'));
|
||||
}
|
||||
|
||||
else if (parsed.id === 'getUrlForRetry') {
|
||||
const retryUrl = parsed.result.files[0].uris[0].uri;
|
||||
const retryTitle = parsed.result.files[0].path;
|
||||
this.webSocket.send(this.createMessage('aria2.addUri', [[retryUrl], { out: retryTitle }], 'addUrl'));
|
||||
}
|
||||
|
||||
// handle url added to download list in aria
|
||||
else if (parsed.id === 'addUrl') {
|
||||
// if we recive array it's the starting list of downloads
|
||||
// if it's a single string it's an error download being re-added
|
||||
if (typeof parsed.result === 'string') {
|
||||
this.queue.add(parsed.result.gid.toString());
|
||||
}
|
||||
else if (Array.isArray(parsed.result)) {
|
||||
parsed.result.forEach((gid: string) =>
|
||||
this.queue.add(gid.toString())
|
||||
);
|
||||
|
||||
if (!barStarted) {
|
||||
barStarted = true;
|
||||
logger.debug(`[DownloadMangaer] Starting download queue size: ${this.queue.size}`);
|
||||
this.progresBar.start(this.queue.size, 0, { speed: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// FIXME: terrible workaround for 'https://github.com/snobu/destreamer/issues/232#issuecomment-699642770' :/
|
||||
this.webSocket.removeAllListeners('message');
|
||||
this.webSocket.on('message', (data: WebSocket.Data) => {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
if (parsed.method !== 'aria2.onDownloadComplete' &&
|
||||
parsed.method !== 'aria2.onDownloadStart' &&
|
||||
parsed.method !== 'aria2.onDownloadError' &&
|
||||
parsed.id !== 'getSpeed' &&
|
||||
parsed.id !== 'addUrl' &&
|
||||
parsed.id !== 'shutdown' &&
|
||||
parsed.id !== 'getUrlForRetry') {
|
||||
logger.info('[INCOMING] \n' + JSON.stringify(parsed, null, 4) + '\n\n');
|
||||
}
|
||||
});
|
||||
logger.debug('[DownloadMangaer] Start download listener count on "message": ' + this.webSocket.listenerCount('message'));
|
||||
this.webSocket.on('message', data => handleResponse(data));
|
||||
|
||||
const paramsForDownload: Array<any> = urls.map(url => {
|
||||
const title: string = (this.index++).toString().padStart(16, '0') + '.encr';
|
||||
|
||||
return this.createMulticallElement(
|
||||
'aria2.addUri', [[url], { out: title, dir: directory }]);
|
||||
});
|
||||
|
||||
this.webSocket.send(
|
||||
this.createMessage('system.multicall', [paramsForDownload], 'addUrl')
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,48 @@
|
||||
/* let's start our error codes up high so we
|
||||
don't exit with the wrong message if other modules exit with some code */
|
||||
export const enum ERROR_CODE {
|
||||
UNHANDLED_ERROR,
|
||||
UNHANDLED_ERROR = 1000,
|
||||
ELEVATED_SHELL,
|
||||
CANCELLED_USER_INPUT,
|
||||
MISSING_FFMPEG,
|
||||
UNK_FFMPEG_ERROR,
|
||||
INVALID_VIDEO_GUID,
|
||||
NO_SESSION_INFO
|
||||
NO_SESSION_INFO,
|
||||
NO_ENCRYPTION,
|
||||
ARIA2C_CRASH,
|
||||
NO_CONNECT_ARIA2C,
|
||||
NO_DAEMON_PORT,
|
||||
MISSING_ARIA2
|
||||
}
|
||||
|
||||
|
||||
export const errors: {[key: number]: string} = {
|
||||
[ERROR_CODE.UNHANDLED_ERROR]: 'Unhandled error!\n' +
|
||||
'Timeout or fatal error, please check your downloads directory and try again',
|
||||
[ERROR_CODE.UNHANDLED_ERROR]: 'Unhandled error or uncaught exception! \n' +
|
||||
'Please check your download directory/directories and try again. \n' +
|
||||
'If this keep happening please report it on github "https://github.com/snobu/destreamer/issues"',
|
||||
|
||||
[ERROR_CODE.ELEVATED_SHELL]: 'Destreamer cannot run in an elevated (Administrator/root) shell.\n' +
|
||||
[ERROR_CODE.ELEVATED_SHELL]: 'Destreamer cannot run in an elevated (Administrator/root) shell. \n' +
|
||||
'Please run in a regular, non-elevated window.',
|
||||
|
||||
[ERROR_CODE.CANCELLED_USER_INPUT]: 'Input was cancelled by user',
|
||||
|
||||
[ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing!\n' +
|
||||
'Destreamer requires a fairly recent release of FFmpeg to download videos',
|
||||
[ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing! Destreamer requires FFmpeg to merge videos',
|
||||
|
||||
[ERROR_CODE.MISSING_ARIA2]: 'Aria2c is missing! Destreamer requires Aria2c to download videos',
|
||||
|
||||
[ERROR_CODE.UNK_FFMPEG_ERROR]: 'Unknown FFmpeg error',
|
||||
|
||||
[ERROR_CODE.INVALID_VIDEO_GUID]: 'Unable to get video GUID from URL',
|
||||
|
||||
[ERROR_CODE.NO_SESSION_INFO]: 'Could not evaluate sessionInfo on the page'
|
||||
[ERROR_CODE.NO_SESSION_INFO]: 'Could not evaluate sessionInfo on the page',
|
||||
|
||||
[ERROR_CODE.NO_ENCRYPTION]: 'Could not extract the encryption info from the playlist',
|
||||
|
||||
[ERROR_CODE.ARIA2C_CRASH]: 'The Aria2c RPC server crashed with the previous message',
|
||||
|
||||
[ERROR_CODE.NO_CONNECT_ARIA2C]: 'Could not connect to Aria2c JSON-RPC WebSocket before timeout!',
|
||||
|
||||
[ERROR_CODE.NO_DAEMON_PORT]: 'Could not get a free port to use'
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import { logger } from './Logger';
|
||||
/**
|
||||
* 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(): void {
|
||||
@@ -21,6 +19,16 @@ export function setProcessEvents(): void {
|
||||
logger.error({ message: msg, fatal: true });
|
||||
});
|
||||
|
||||
process.on('SIGINT', signal => {
|
||||
logger.error(signal);
|
||||
process.exit(777);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err: Error) => {
|
||||
logger.error(err);
|
||||
process.exit(ERROR_CODE.UNHANDLED_ERROR);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason: {} | null | undefined) => {
|
||||
if (reason instanceof Error) {
|
||||
logger.error({ message: (reason as Error) });
|
||||
|
||||
@@ -35,6 +35,9 @@ function customPrint (info: winston.Logform.TransformableInfo): string {
|
||||
else if (info.level === 'verbose') {
|
||||
return colors.cyan('\n[VERBOSE] ') + info.message;
|
||||
}
|
||||
else if (info.level === 'debug') {
|
||||
return colors.magenta('\n[debug] ') + info.message;
|
||||
}
|
||||
|
||||
return `${info.level}: ${info.message} - ${info.timestamp}`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AxiosResponse } from 'axios';
|
||||
export async function drawThumbnail(posterImage: string, session: Session): Promise<void> {
|
||||
const apiClient: ApiClient = ApiClient.getInstance(session);
|
||||
|
||||
let thumbnail: Buffer = await apiClient.callUrl(posterImage, 'get', null, 'arraybuffer')
|
||||
const thumbnail: Buffer = await apiClient.callUrl(posterImage, 'get', null, 'arraybuffer')
|
||||
.then((response: AxiosResponse<any> | undefined) => response?.data);
|
||||
|
||||
console.log(await terminalImage.buffer(thumbnail, { width: 70 } ));
|
||||
|
||||
@@ -9,6 +9,10 @@ import jwtDecode from 'jwt-decode';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
|
||||
type Jwt = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export class TokenCache {
|
||||
private tokenCacheFile = '.token_cache';
|
||||
|
||||
@@ -19,30 +23,24 @@ export class TokenCache {
|
||||
return null;
|
||||
}
|
||||
|
||||
let session: Session = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8'));
|
||||
const session: Session = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8'));
|
||||
|
||||
type Jwt = {
|
||||
[key: string]: any
|
||||
}
|
||||
const decodedJwt: Jwt = jwtDecode(session.AccessToken);
|
||||
const [isExpiring, timeLeft] = this.isExpiring(session);
|
||||
|
||||
let now: number = Math.floor(Date.now() / 1000);
|
||||
let exp: number = decodedJwt['exp'];
|
||||
let timeLeft: number = exp - now;
|
||||
|
||||
if (timeLeft < 120) {
|
||||
if (isExpiring) {
|
||||
logger.warn('Access token has expired! \n');
|
||||
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
logger.info(`Access token still good for ${Math.floor(timeLeft / 60)} minutes.\n`.green);
|
||||
|
||||
logger.info(`Access token still good for ${Math.floor(timeLeft / 60)} minutes.\n`.green);
|
||||
|
||||
return session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
public Write(session: Session): void {
|
||||
let s: string = JSON.stringify(session, null, 4);
|
||||
const s: string = JSON.stringify(session, null, 4);
|
||||
fs.writeFile('.token_cache', s, (err: any) => {
|
||||
if (err) {
|
||||
return logger.error(err);
|
||||
@@ -50,11 +48,23 @@ export class TokenCache {
|
||||
logger.info('Fresh access token dropped into .token_cachen \n'.green);
|
||||
});
|
||||
}
|
||||
|
||||
public isExpiring(session: Session): [boolean, number] {
|
||||
const decodedJwt: Jwt = jwtDecode(session.AccessToken);
|
||||
|
||||
const timeLeft: number = decodedJwt['exp'] - Math.floor(Date.now() / 1000);
|
||||
|
||||
if (timeLeft < (5 * 60)) {
|
||||
return [true, 0];
|
||||
}
|
||||
else {
|
||||
return [false, timeLeft];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function refreshSession(url: string): Promise<Session> {
|
||||
const videoId: string = url.split('/').pop() ?? process.exit(ERROR_CODE.INVALID_VIDEO_GUID);
|
||||
|
||||
const browser: puppeteer.Browser = await puppeteer.launch({
|
||||
executablePath: getPuppeteerChromiumPath(),
|
||||
@@ -70,7 +80,7 @@ export async function refreshSession(url: string): Promise<Session> {
|
||||
const page: puppeteer.Page = (await browser.pages())[0];
|
||||
await page.goto(url, { waitUntil: 'load' });
|
||||
|
||||
await browser.waitForTarget((target: puppeteer.Target) => target.url().includes(videoId), { timeout: 30000 });
|
||||
await browser.waitForTarget((target: puppeteer.Target) => target.url().endsWith('microsoftstream.com/'), { timeout: 150000 });
|
||||
|
||||
let session: Session | null = null;
|
||||
let tries = 1;
|
||||
|
||||
12
src/Types.ts
12
src/Types.ts
@@ -6,6 +6,7 @@ export type Session = {
|
||||
|
||||
|
||||
export type Video = {
|
||||
// the following properties are all for the title template
|
||||
title: string;
|
||||
duration: string;
|
||||
publishDate: string;
|
||||
@@ -13,15 +14,20 @@ export type Video = {
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
uniqueId: string;
|
||||
outPath: string;
|
||||
totalChunks: number; // Abstraction of FFmpeg timemark
|
||||
|
||||
// the following properties are all the urls neede for the download
|
||||
playbackUrl: string;
|
||||
posterImageUrl: string;
|
||||
captionsUrl?: string
|
||||
|
||||
// final filename, already sanitized and unique
|
||||
filename: string;
|
||||
// complete path to save the video
|
||||
outPath: string;
|
||||
}
|
||||
|
||||
|
||||
/* TODO: expand this template once we are all on board with a list
|
||||
/* NOTE: expand this template once we are all on board with a list
|
||||
see https://github.com/snobu/destreamer/issues/190#issuecomment-663718010 for list*/
|
||||
export const templateElements: Array<string> = [
|
||||
'title',
|
||||
|
||||
53
src/Utils.ts
53
src/Utils.ts
@@ -22,9 +22,23 @@ async function extractGuids(url: string, client: ApiClient): Promise<Array<strin
|
||||
else if (groupMatch) {
|
||||
const videoNumber: number = await client.callApi(`groups/${groupMatch[1]}`, 'get')
|
||||
.then((response: AxiosResponse<any> | undefined) => response?.data.metrics.videos);
|
||||
const result: Array<string> = [];
|
||||
|
||||
let result: Array<string> = await client.callApi(`groups/${groupMatch[1]}/videos?$top=${videoNumber}&$orderby=publishedDate asc`, 'get')
|
||||
.then((response: AxiosResponse<any> | undefined) => response?.data.value.map((item: any) => item.id));
|
||||
logger.error(videoNumber);
|
||||
|
||||
// Anything above $top=100 results in 400 Bad Request
|
||||
// Use $skip to skip the first 100 and get another 100 and so on
|
||||
for (let index = 0; index <= Math.floor(videoNumber / 100); index++) {
|
||||
const partial: Array<string> = await client.callApi(
|
||||
`groups/${groupMatch[1]}/videos?$skip=${100 * index}&` +
|
||||
'$top=100&$orderby=publishedDate asc', 'get')
|
||||
.then(
|
||||
(response: AxiosResponse<any> | undefined) =>
|
||||
response?.data.value.map((item: any) => item.id)
|
||||
);
|
||||
|
||||
result.push(...partial);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -48,7 +62,7 @@ export async function parseCLIinput(urlList: Array<string>, defaultOutDir: strin
|
||||
session: Session): Promise<Array<Array<string>>> {
|
||||
|
||||
const apiClient: ApiClient = ApiClient.getInstance(session);
|
||||
let guidList: Array<string> = [];
|
||||
const guidList: Array<string> = [];
|
||||
|
||||
for (const url of urlList) {
|
||||
const guids: Array<string> | null = await extractGuids(url, apiClient);
|
||||
@@ -85,8 +99,8 @@ export async function parseInputFile(inputFile: string, defaultOutDir: string,
|
||||
.split(/\r?\n/);
|
||||
const apiClient: ApiClient = ApiClient.getInstance(session);
|
||||
|
||||
let guidList: Array<string> = [];
|
||||
let outDirList: Array<string> = [];
|
||||
const guidList: Array<string> = [];
|
||||
const outDirList: Array<string> = [];
|
||||
// if the last line was an url set this
|
||||
let foundUrl = false;
|
||||
|
||||
@@ -101,23 +115,23 @@ export async function parseInputFile(inputFile: string, defaultOutDir: string,
|
||||
// parse if line is option
|
||||
else if (line.includes('-dir')) {
|
||||
if (foundUrl) {
|
||||
let outDir: string | null = parseOption('-dir', line);
|
||||
const outDir: string | null = parseOption('-dir', line);
|
||||
|
||||
if (outDir && checkOutDir(outDir)) {
|
||||
outDirList.push(...Array(guidList.length - outDirList.length)
|
||||
.fill(outDir));
|
||||
.fill(outDir));
|
||||
}
|
||||
else {
|
||||
outDirList.push(...Array(guidList.length - outDirList.length)
|
||||
.fill(defaultOutDir));
|
||||
.fill(defaultOutDir));
|
||||
}
|
||||
|
||||
foundUrl = false;
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
logger.warn(`Found options without preceding url at line ${i + 1}, skipping..`);
|
||||
continue;
|
||||
logger.warn(`Found options without preceding url at line ${i + 1}, skipping..`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +169,7 @@ export async function parseInputFile(inputFile: string, defaultOutDir: string,
|
||||
function parseOption(optionSyntax: string, item: string): string | null {
|
||||
const match: RegExpMatchArray | null = item.match(
|
||||
RegExp(`^\\s*${optionSyntax}\\s?=\\s?['"](.*)['"]`)
|
||||
);
|
||||
);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -168,7 +182,7 @@ export function checkOutDir(directory: string): boolean {
|
||||
logger.info('\nCreated directory: '.yellow + directory);
|
||||
}
|
||||
catch (e) {
|
||||
logger.warn('Cannot create directory: '+ directory +
|
||||
logger.warn('Cannot create directory: ' + directory +
|
||||
'\nFalling back to default directory..');
|
||||
|
||||
return false;
|
||||
@@ -179,6 +193,13 @@ export function checkOutDir(directory: string): boolean {
|
||||
}
|
||||
|
||||
|
||||
export async function getUrlsFromPlaylist(playlistUrl: string, session: Session): Promise<Array<string>> {
|
||||
return await ApiClient.getInstance(session).callUrl(playlistUrl, 'get', null, 'text')
|
||||
.then(res => (res?.data as string).split(/\r?\n/)
|
||||
.filter(line => !(line.startsWith('#') || line === '')));
|
||||
}
|
||||
|
||||
|
||||
export function checkRequirements(): void {
|
||||
try {
|
||||
const ffmpegVer: string = execSync('ffmpeg -version').toString().split('\n')[0];
|
||||
@@ -187,6 +208,14 @@ export function checkRequirements(): void {
|
||||
catch (e) {
|
||||
process.exit(ERROR_CODE.MISSING_FFMPEG);
|
||||
}
|
||||
|
||||
try {
|
||||
const aria2Ver: string = execSync('aria2c --version').toString().split('\n')[0];
|
||||
logger.verbose(`Using ${aria2Ver}\n`);
|
||||
}
|
||||
catch (e) {
|
||||
process.exit(ERROR_CODE.MISSING_ARIA2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parse as parseDuration, Duration } from 'iso8601-duration';
|
||||
import path from 'path';
|
||||
import sanitizeWindowsName from 'sanitize-filename';
|
||||
|
||||
|
||||
function publishedDateToString(date: string): string {
|
||||
const dateJs: Date = new Date(date);
|
||||
const day: string = dateJs.getDate().toString().padStart(2, '0');
|
||||
@@ -35,18 +36,11 @@ function isoDurationToString(time: string): string {
|
||||
}
|
||||
|
||||
|
||||
function durationToTotalChunks(duration: string): number {
|
||||
const durationObj: any = parseDuration(duration);
|
||||
const hrs: number = durationObj.hours ?? 0;
|
||||
const mins: number = durationObj.minutes ?? 0;
|
||||
const secs: number = Math.ceil(durationObj.seconds ?? 0);
|
||||
export async function getVideosInfo(videoGuids: Array<string>,
|
||||
session: Session, subtitles?: boolean): Promise<Array<Video>> {
|
||||
|
||||
return (hrs * 60) + mins + (secs / 60);
|
||||
}
|
||||
const metadata: Array<Video> = [];
|
||||
|
||||
|
||||
export async function getVideoInfo(videoGuids: Array<string>, session: Session, subtitles?: boolean): Promise<Array<Video>> {
|
||||
let metadata: Array<Video> = [];
|
||||
let title: string;
|
||||
let duration: string;
|
||||
let publishDate: string;
|
||||
@@ -54,18 +48,18 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
||||
let author: string;
|
||||
let authorEmail: string;
|
||||
let uniqueId: string;
|
||||
const outPath = '';
|
||||
let totalChunks: number;
|
||||
|
||||
let playbackUrl: string;
|
||||
let posterImageUrl: string;
|
||||
let captionsUrl: string | undefined;
|
||||
|
||||
const apiClient: ApiClient = ApiClient.getInstance(session);
|
||||
|
||||
/* TODO: change this to a single guid at a time to ease our footprint on the
|
||||
MSS servers or we get throttled after 10 sequential reqs */
|
||||
|
||||
/* See 'https://github.com/snobu/destreamer/pull/203' for API throttling mitigation */
|
||||
for (const guid of videoGuids) {
|
||||
let response: AxiosResponse<any> | undefined =
|
||||
|
||||
const response: AxiosResponse<any> | undefined =
|
||||
await apiClient.callApi('videos/' + guid + '?$expand=creator', 'get');
|
||||
|
||||
title = sanitizeWindowsName(response?.data['name']);
|
||||
@@ -82,8 +76,6 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
||||
|
||||
uniqueId = '#' + guid.split('-')[0];
|
||||
|
||||
totalChunks = durationToTotalChunks(response?.data.media['duration']);
|
||||
|
||||
playbackUrl = response?.data['playbackUrls']
|
||||
.filter((item: { [x: string]: string; }) =>
|
||||
item['mimeType'] == 'application/vnd.apple.mpegurl')
|
||||
@@ -94,7 +86,7 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
||||
posterImageUrl = response?.data['posterImage']['medium']['url'];
|
||||
|
||||
if (subtitles) {
|
||||
let captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid}/texttracks`, 'get');
|
||||
const captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid}/texttracks`, 'get');
|
||||
|
||||
if (!captions?.data.value.length) {
|
||||
captionsUrl = undefined;
|
||||
@@ -119,11 +111,14 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
||||
author: author,
|
||||
authorEmail: authorEmail,
|
||||
uniqueId: uniqueId,
|
||||
outPath: outPath,
|
||||
totalChunks: totalChunks, // Abstraction of FFmpeg timemark
|
||||
|
||||
// totalChunks: totalChunks, // Abstraction of FFmpeg timemark
|
||||
playbackUrl: playbackUrl,
|
||||
posterImageUrl: posterImageUrl,
|
||||
captionsUrl: captionsUrl
|
||||
captionsUrl: captionsUrl,
|
||||
|
||||
filename: '',
|
||||
outPath: '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,7 +126,8 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
||||
}
|
||||
|
||||
|
||||
export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, template: string, format: string, skip?: boolean): Array<Video> {
|
||||
export function createUniquePaths(videos: Array<Video>, outDirs: Array<string>,
|
||||
template: string, format: string, skip?: boolean): Array<Video> {
|
||||
|
||||
videos.forEach((video: Video, index: number) => {
|
||||
let title: string = template;
|
||||
@@ -140,7 +136,7 @@ export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, t
|
||||
let match = elementRegEx.exec(template);
|
||||
|
||||
while (match) {
|
||||
let value = video[match[1] as keyof Video] as string;
|
||||
const value = video[match[1] as keyof Video] as string;
|
||||
title = title.replace(match[0], value);
|
||||
match = elementRegEx.exec(template);
|
||||
}
|
||||
@@ -155,9 +151,14 @@ export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, t
|
||||
const finalFileName = `${finalTitle}.${format}`;
|
||||
const cleanFileName = sanitizeWindowsName(finalFileName, { replacement: '_' });
|
||||
if (finalFileName !== cleanFileName) {
|
||||
logger.warn(`Not a valid Windows file name: "${finalFileName}".\nReplacing invalid characters with underscores to preserve cross-platform consistency.`);
|
||||
logger.warn(
|
||||
`Not a valid Windows file name: "${finalFileName}"` +
|
||||
'\nReplacing invalid characters with underscores to ' +
|
||||
'preserve cross-platform consistency.');
|
||||
}
|
||||
|
||||
|
||||
video.filename = finalFileName;
|
||||
video.outPath = path.join(outDirs[index], finalFileName);
|
||||
|
||||
});
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import { argv } from './CommandLineParser';
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { argv, promptUser } from './CommandLineParser';
|
||||
import { getDecrypter } from './Decrypter';
|
||||
import { DownloadManager } from './DownloadManager';
|
||||
import { ERROR_CODE } from './Errors';
|
||||
import { setProcessEvents } from './Events';
|
||||
import { logger } from './Logger';
|
||||
import { getPuppeteerChromiumPath } from './PuppeteerHelper';
|
||||
import { drawThumbnail } from './Thumbnail';
|
||||
import { TokenCache, refreshSession } from './TokenCache';
|
||||
import { TokenCache, refreshSession} from './TokenCache';
|
||||
import { Video, Session } from './Types';
|
||||
import { checkRequirements, ffmpegTimemarkToChunk, parseInputFile, parseCLIinput} from './Utils';
|
||||
import { getVideoInfo, createUniquePath } from './VideoUtils';
|
||||
import { checkRequirements, parseInputFile, parseCLIinput, getUrlsFromPlaylist} from './Utils';
|
||||
import { getVideosInfo, createUniquePaths } from './VideoUtils';
|
||||
|
||||
import cliProgress from 'cli-progress';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import isElevated from 'is-elevated';
|
||||
import portfinder from 'portfinder';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { ApiClient } from './ApiClient';
|
||||
import path from 'path';
|
||||
import tmp from 'tmp';
|
||||
|
||||
|
||||
const { FFmpegCommand, FFmpegInput, FFmpegOutput } = require('@tedconf/fessonia')();
|
||||
// TODO: can we create an export or something for this?
|
||||
const m3u8Parser: any = require('m3u8-parser');
|
||||
const tokenCache: TokenCache = new TokenCache();
|
||||
const downloadManager = new DownloadManager();
|
||||
export const chromeCacheFolder = '.chrome_data';
|
||||
tmp.setGracefulCleanup();
|
||||
|
||||
|
||||
async function init(): Promise<void> {
|
||||
setProcessEvents(); // must be first!
|
||||
setProcessEvents(); // must be first!
|
||||
|
||||
if (argv.verbose) {
|
||||
logger.level = 'verbose';
|
||||
}
|
||||
logger.level = argv.debug ? 'debug' : (argv.verbose ? 'verbose' : 'info');
|
||||
|
||||
if (await isElevated()) {
|
||||
process.exit(ERROR_CODE.ELEVATED_SHELL);
|
||||
@@ -112,28 +118,31 @@ async function DoInteractiveLogin(url: string, username?: string): Promise<Sessi
|
||||
|
||||
tokenCache.Write(session);
|
||||
logger.info('Wrote access token to token cache.');
|
||||
logger.info("At this point Chromium's job is done, shutting it down...\n");
|
||||
logger.info("At this point Chromium's job is done, shutting it down... \n\n");
|
||||
|
||||
await browser.close();
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function downloadVideo(videoGUIDs: Array<string>,
|
||||
outputDirectories: Array<string>, session: Session): Promise<void> {
|
||||
|
||||
async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array<string>, session: Session): Promise<void> {
|
||||
const apiClient = ApiClient.getInstance(session);
|
||||
|
||||
logger.info('Fetching videos info... \n');
|
||||
const videos: Array<Video> = createUniquePath (
|
||||
await getVideoInfo(videoGUIDs, session, argv.closedCaptions),
|
||||
outputDirectories, argv.outputTemplate, argv.format, argv.skip
|
||||
);
|
||||
logger.info('Downloading video info, this might take a while...');
|
||||
|
||||
const videos: Array<Video> = createUniquePaths (
|
||||
await getVideosInfo(videoGUIDs, session, argv.closedCaptions),
|
||||
outputDirectories, argv.outputTemplate ,argv.format, argv.skip
|
||||
);
|
||||
|
||||
if (argv.simulate) {
|
||||
videos.forEach((video: Video) => {
|
||||
videos.forEach(video => {
|
||||
logger.info(
|
||||
'\nTitle: '.green + video.title +
|
||||
'\nOutPath: '.green + video.outPath +
|
||||
'\nPublished Date: '.green + video.publishDate +
|
||||
'\nPublished Date: '.green + video.publishDate + ' ' + video.publishTime +
|
||||
'\nPlayback URL: '.green + video.playbackUrl +
|
||||
((video.captionsUrl) ? ('\nCC URL: '.green + video.captionsUrl) : '')
|
||||
);
|
||||
@@ -142,135 +151,228 @@ async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [index, video] of videos.entries()) {
|
||||
logger.info('Trying to launch and connect to aria2c...\n');
|
||||
|
||||
|
||||
/* FIXME: aria2Exec must be defined here for the scope but later on it's complaining that it's not
|
||||
initialized even if we never reach line#361 if we fail the assignment here*/
|
||||
let aria2cExec: ChildProcess;
|
||||
let arai2cExited = false;
|
||||
await portfinder.getPortPromise({ port: 6800 }).then(
|
||||
async (port: number) => {
|
||||
logger.debug(`[DESTREAMER] Trying to use port ${port}`);
|
||||
// Launch aria2c
|
||||
aria2cExec = spawn(
|
||||
'aria2c',
|
||||
['--pause=true', '--enable-rpc', '--allow-overwrite=true', '--auto-file-renaming=false', `--rpc-listen-port=${port}`],
|
||||
{stdio: 'ignore'}
|
||||
);
|
||||
|
||||
aria2cExec.on('exit', (code: number | null, signal: string) => {
|
||||
if (code === 0) {
|
||||
logger.verbose('Aria2c process exited');
|
||||
arai2cExited = true;
|
||||
}
|
||||
else {
|
||||
logger.error(`aria2c exit code: ${code}` + '\n' + `aria2c exit signal: ${signal}`);
|
||||
process.exit(ERROR_CODE.ARIA2C_CRASH);
|
||||
}
|
||||
});
|
||||
|
||||
aria2cExec.on('error', (err) => {
|
||||
logger.error(err as Error);
|
||||
});
|
||||
|
||||
// init webSocket
|
||||
await downloadManager.init(port, );
|
||||
// We are connected
|
||||
},
|
||||
error => {
|
||||
logger.error(error);
|
||||
process.exit(ERROR_CODE.NO_DAEMON_PORT);
|
||||
}
|
||||
);
|
||||
|
||||
for (const video of videos) {
|
||||
const masterParser = new m3u8Parser.Parser();
|
||||
|
||||
logger.info(`\nDownloading video no.${videos.indexOf(video) + 1} \n`);
|
||||
|
||||
if (argv.skip && fs.existsSync(video.outPath)) {
|
||||
logger.info(`File already exists, skipping: ${video.outPath} \n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argv.keepLoginCookies && index !== 0) {
|
||||
logger.info('Trying to refresh token...');
|
||||
session = await refreshSession('https://web.microsoftstream.com/video/' + videoGUIDs[index]);
|
||||
ApiClient.getInstance().setSession(session);
|
||||
const [isSessionExpiring] = tokenCache.isExpiring(session);
|
||||
if (argv.keepLoginCookies && isSessionExpiring) {
|
||||
logger.info('Trying to refresh access token...');
|
||||
session = await refreshSession('https://web.microsoftstream.com/');
|
||||
apiClient.setSession(session);
|
||||
}
|
||||
|
||||
const pbar: cliProgress.SingleBar = new cliProgress.SingleBar({
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
format: 'progress [{bar}] {percentage}% {speed} {eta_formatted}',
|
||||
// process.stdout.columns may return undefined in some terminals (Cygwin/MSYS)
|
||||
barsize: Math.floor((process.stdout.columns || 30) / 3),
|
||||
stopOnComplete: true,
|
||||
hideCursor: true,
|
||||
});
|
||||
masterParser.push(await apiClient.callUrl(video.playbackUrl).then(res => res?.data));
|
||||
masterParser.end();
|
||||
|
||||
logger.info(`\nDownloading Video: ${video.title} \n`);
|
||||
logger.verbose('Extra video info \n' +
|
||||
'\t Video m3u8 playlist URL: '.cyan + video.playbackUrl + '\n' +
|
||||
'\t Video tumbnail URL: '.cyan + video.posterImageUrl + '\n' +
|
||||
'\t Video subtitle URL (may not exist): '.cyan + video.captionsUrl + '\n' +
|
||||
'\t Video total chunks: '.cyan + video.totalChunks + '\n');
|
||||
// video playlist url
|
||||
let videoPlaylistUrl: string;
|
||||
const videoPlaylists: Array<any> = (masterParser.manifest.playlists as Array<any>)
|
||||
.filter(playlist =>
|
||||
Object.prototype.hasOwnProperty.call(playlist.attributes, 'RESOLUTION'));
|
||||
|
||||
logger.info('Spawning ffmpeg with access token and HLS URL. This may take a few seconds...\n\n');
|
||||
if (!process.stdout.columns) {
|
||||
logger.warn(
|
||||
'Unable to get number of columns from terminal.\n' +
|
||||
'This happens sometimes in Cygwin/MSYS.\n' +
|
||||
'No progress bar can be rendered, however the download process should not be affected.\n\n' +
|
||||
'Please use PowerShell or cmd.exe to run destreamer on Windows.'
|
||||
if (videoPlaylists.length === 1 || argv.selectQuality === 10) {
|
||||
videoPlaylistUrl = videoPlaylists.pop().uri;
|
||||
}
|
||||
else if (argv.selectQuality === 0) {
|
||||
const resolutions = videoPlaylists.map(playlist =>
|
||||
playlist.attributes.RESOLUTION.width + 'x' +
|
||||
playlist.attributes.RESOLUTION.height
|
||||
);
|
||||
|
||||
videoPlaylistUrl = videoPlaylists[promptUser(resolutions)].uri;
|
||||
}
|
||||
else {
|
||||
let choiche = Math.round((argv.selectQuality * videoPlaylists.length) / 10);
|
||||
if (choiche === videoPlaylists.length) {
|
||||
choiche--;
|
||||
}
|
||||
logger.debug(`Video quality choiche: ${choiche}`);
|
||||
videoPlaylistUrl = videoPlaylists[choiche].uri;
|
||||
}
|
||||
|
||||
const headers: string = 'Authorization: Bearer ' + session.AccessToken;
|
||||
// audio playlist url
|
||||
// TODO: better audio playlists parsing? With language maybe?
|
||||
const audioPlaylists: Array<string> =
|
||||
Object.keys(masterParser.manifest.mediaGroups.AUDIO.audio);
|
||||
const audioPlaylistUrl: string =
|
||||
masterParser.manifest.mediaGroups.AUDIO.audio[audioPlaylists[0]].uri;
|
||||
// if (audioPlaylists.length === 1){
|
||||
// audioPlaylistUrl = masterParser.manifest.mediaGroups.AUDIO
|
||||
// .audio[audioPlaylists[0]].uri;
|
||||
// }
|
||||
// else {
|
||||
// audioPlaylistUrl = masterParser.manifest.mediaGroups.AUDIO
|
||||
// .audio[audioPlaylists[promptUser(audioPlaylists)]].uri;
|
||||
// }
|
||||
|
||||
const videoUrls = await getUrlsFromPlaylist(videoPlaylistUrl, session);
|
||||
const audioUrls = await getUrlsFromPlaylist(audioPlaylistUrl, session);
|
||||
const videoDecrypter = await getDecrypter(videoPlaylistUrl, session);
|
||||
const audioDecrypter = await getDecrypter(videoPlaylistUrl, session);
|
||||
|
||||
if (!argv.noExperiments) {
|
||||
await drawThumbnail(video.posterImageUrl, session);
|
||||
}
|
||||
|
||||
const ffmpegInpt: any = new FFmpegInput(video.playbackUrl, new Map([
|
||||
['headers', headers]
|
||||
]));
|
||||
const ffmpegOutput: any = new FFmpegOutput(video.outPath, new Map([
|
||||
argv.acodec === 'none' ? ['an', null] : ['c:a', argv.acodec],
|
||||
argv.vcodec === 'none' ? ['vn', null] : ['c:v', argv.vcodec],
|
||||
['n', null]
|
||||
]));
|
||||
const ffmpegCmd: any = new FFmpegCommand();
|
||||
|
||||
const cleanupFn: () => void = () => {
|
||||
pbar.stop();
|
||||
|
||||
if (argv.noCleanup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(video.outPath);
|
||||
}
|
||||
catch (e) {
|
||||
// Future handling of an error (maybe)
|
||||
}
|
||||
};
|
||||
|
||||
pbar.start(video.totalChunks, 0, {
|
||||
speed: '0'
|
||||
// video download
|
||||
const videoSegmentsDir = tmp.dirSync({
|
||||
prefix: 'video',
|
||||
tmpdir: path.dirname(video.outPath),
|
||||
unsafeCleanup: true
|
||||
});
|
||||
|
||||
// prepare ffmpeg command line
|
||||
ffmpegCmd.addInput(ffmpegInpt);
|
||||
ffmpegCmd.addOutput(ffmpegOutput);
|
||||
if (argv.closedCaptions && video.captionsUrl) {
|
||||
const captionsInpt: any = new FFmpegInput(video.captionsUrl, new Map([
|
||||
['headers', headers]
|
||||
]));
|
||||
logger.info('\nDownloading video segments \n');
|
||||
await downloadManager.downloadUrls(videoUrls, videoSegmentsDir.name);
|
||||
|
||||
ffmpegCmd.addInput(captionsInpt);
|
||||
// audio download
|
||||
const audioSegmentsDir = tmp.dirSync({
|
||||
prefix: 'audio',
|
||||
tmpdir: path.dirname(video.outPath),
|
||||
unsafeCleanup: true
|
||||
});
|
||||
|
||||
logger.info('\nDownloading audio segments \n');
|
||||
await downloadManager.downloadUrls(audioUrls, audioSegmentsDir.name);
|
||||
|
||||
// subs download
|
||||
if (argv.closedCaptions && video.captionsUrl) {
|
||||
logger.info('\nDownloading subtitles \n');
|
||||
await apiClient.callUrl(video.captionsUrl, 'get', null, 'text')
|
||||
.then(res => fs.writeFileSync(
|
||||
path.join(videoSegmentsDir.name, 'CC.vtt'), res?.data));
|
||||
}
|
||||
|
||||
ffmpegCmd.on('update', async (data: any) => {
|
||||
const currentChunks: number = ffmpegTimemarkToChunk(data.out_time);
|
||||
logger.info('\n\nMerging and decrypting video and audio segments...\n');
|
||||
|
||||
pbar.update(currentChunks, {
|
||||
speed: data.bitrate
|
||||
});
|
||||
const cmd = (process.platform == 'win32') ? 'copy /b *.encr ' : 'cat *.encr > ';
|
||||
|
||||
// Graceful fallback in case we can't get columns (Cygwin/MSYS)
|
||||
if (!process.stdout.columns) {
|
||||
process.stdout.write(`--- Speed: ${data.bitrate}, Cursor: ${data.out_time}\r`);
|
||||
}
|
||||
execSync(cmd + `"${video.filename}.video.encr"`, { cwd: videoSegmentsDir.name });
|
||||
const videoDecryptInput = fs.createReadStream(
|
||||
path.join(videoSegmentsDir.name, video.filename + '.video.encr'));
|
||||
const videoDecryptOutput = fs.createWriteStream(
|
||||
path.join(videoSegmentsDir.name, video.filename + '.video'));
|
||||
|
||||
const decryptVideoPromise = new Promise(resolve => {
|
||||
videoDecryptOutput.on('finish', resolve);
|
||||
videoDecryptInput.pipe(videoDecrypter).pipe(videoDecryptOutput);
|
||||
});
|
||||
|
||||
process.on('SIGINT', cleanupFn);
|
||||
execSync(cmd + `"${video.filename}.audio.encr"`, {cwd: audioSegmentsDir.name});
|
||||
const audioDecryptInput = fs.createReadStream(
|
||||
path.join(audioSegmentsDir.name, video.filename + '.audio.encr'));
|
||||
const audioDecryptOutput = fs.createWriteStream(
|
||||
path.join(audioSegmentsDir.name, video.filename + '.audio'));
|
||||
|
||||
// let the magic begin...
|
||||
await new Promise((resolve: any) => {
|
||||
ffmpegCmd.on('error', (error: any) => {
|
||||
cleanupFn();
|
||||
|
||||
logger.error(`FFmpeg returned an error: ${error.message}`);
|
||||
process.exit(ERROR_CODE.UNK_FFMPEG_ERROR);
|
||||
});
|
||||
|
||||
ffmpegCmd.on('success', () => {
|
||||
pbar.update(video.totalChunks); // set progress bar to 100%
|
||||
logger.info(`\nDownload finished: ${video.outPath} \n`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
ffmpegCmd.spawn();
|
||||
const decryptAudioPromise = new Promise(resolve => {
|
||||
audioDecryptOutput.on('finish', resolve);
|
||||
audioDecryptInput.pipe(audioDecrypter).pipe(audioDecryptOutput);
|
||||
});
|
||||
|
||||
process.removeListener('SIGINT', cleanupFn);
|
||||
await Promise.all([decryptVideoPromise, decryptAudioPromise]);
|
||||
|
||||
logger.info('Decrypted!\n');
|
||||
|
||||
logger.info('Merging video and audio together...\n');
|
||||
const mergeCommand = (
|
||||
// add video input
|
||||
`ffmpeg -i "${path.join(videoSegmentsDir.name, video.filename + '.video')}" ` +
|
||||
// add audio input
|
||||
`-i "${path.join(audioSegmentsDir.name, video.filename + '.audio')}" ` +
|
||||
// add subtitles input if present and wanted
|
||||
((argv.closedCaptions && video.captionsUrl) ?
|
||||
`-i "${path.join(videoSegmentsDir.name, 'CC.vtt')}" ` : '') +
|
||||
// copy codec and output path
|
||||
`-c copy "${video.outPath}"`
|
||||
);
|
||||
|
||||
logger.debug('[destreamer] ' + mergeCommand);
|
||||
|
||||
execSync(mergeCommand, { stdio: 'ignore' });
|
||||
|
||||
logger.info('Done! Removing temp files...\n');
|
||||
|
||||
videoSegmentsDir.removeCallback();
|
||||
audioSegmentsDir.removeCallback();
|
||||
|
||||
logger.info(`Video no.${videos.indexOf(video) + 1} downloaded!!\n\n`);
|
||||
}
|
||||
|
||||
logger.info('Exiting, this will take some seconds...');
|
||||
|
||||
logger.debug('[destreamer] closing downloader socket');
|
||||
await downloadManager.close();
|
||||
logger.debug('[destreamer] closed downloader. Waiting aria2c deamon exit');
|
||||
let tries = 0;
|
||||
while (!arai2cExited) {
|
||||
if (tries < 10) {
|
||||
tries++;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
else {
|
||||
aria2cExec!.kill('SIGINT');
|
||||
}
|
||||
}
|
||||
logger.debug('[destreamer] stopped aria2c');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await init(); // must be first
|
||||
|
||||
let session: Session;
|
||||
session = tokenCache.Read() ?? await DoInteractiveLogin('https://web.microsoftstream.com/', argv.username);
|
||||
|
||||
const session: Session = tokenCache.Read() ??
|
||||
await DoInteractiveLogin('https://web.microsoftstream.com/', argv.username);
|
||||
|
||||
logger.verbose('Session and API info \n' +
|
||||
'\t API Gateway URL: '.cyan + session.ApiGatewayUri + '\n' +
|
||||
@@ -288,12 +390,13 @@ async function main(): Promise<void> {
|
||||
[videoGUIDs, outDirs] = await parseInputFile(argv.inputFile!, argv.outputDirectory, session);
|
||||
}
|
||||
|
||||
logger.verbose('List of GUIDs and corresponding output directory \n' +
|
||||
logger.verbose('List of videos and corresponding output directory \n' +
|
||||
videoGUIDs.map((guid: string, i: number) =>
|
||||
`\thttps://web.microsoftstream.com/video/${guid} => ${outDirs[i]} \n`).join(''));
|
||||
|
||||
|
||||
downloadVideo(videoGUIDs, outDirs, session);
|
||||
// fuck you bug, I WON!!!
|
||||
await downloadVideo(videoGUIDs, outDirs, session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user