1
0
mirror of https://github.com/snobu/destreamer.git synced 2026-02-09 08:19:41 +00:00

61 Commits

Author SHA1 Message Date
Adrian Calinescu
e93ad80ef6 Addressing https://github.com/snobu/destreamer/issues/439 2022-05-19 17:04:14 +03:00
WilliamWsyHK
9b8d341e5b Add personal to URL regex to apply to more sites (#437)
* Add personal URL for new MS Teams recordings
* Encode URI components to avoid unescape char
2022-05-19 16:54:06 +03:00
Luca Armaroli
d2a79442df changed regex to ignore drive letters 2021-10-21 20:25:47 +02:00
Luca Armaroli
528dc79752 Merge branch 'sharepoint' of https://github.com/snobu/destreamer into sharepoint 2021-10-21 19:42:59 +02:00
Luca Armaroli
b6a06dbd82 removed dependancy from argv in our test 2021-10-21 19:42:42 +02:00
lukaarma
81e7173e10 Merge pull request #413 from ar363/sharepoint
fix SharePoint regex to also include 'teams'
2021-10-21 19:37:51 +02:00
Luca Armaroli
377f7281b8 shift error codes beyond 200 to avoid collisions 2021-10-21 19:31:00 +02:00
Aditya Raj
71b51e76ce fix SharePoint url regex to apply to more sites 2021-10-21 15:49:17 +05:30
Luca Armaroli
de158e3119 quick fix on subtitles for Stream 2021-10-15 22:43:28 +02:00
Luca Armaroli
e4fe46c4a7 SharePoint download video via DASH manifest 2021-10-15 22:33:53 +02:00
Luca Armaroli
1111eea9d5 fix target during SharePoint login 2021-10-14 21:56:23 +02:00
Luca Armaroli
153d15c9c9 fix error message for manifest download 2021-10-14 20:47:08 +02:00
Luca Armaroli
f23d2a25fe video info e direct download from SharePoint 2021-10-14 20:44:04 +02:00
Luca Armaroli
6a2159b266 refactor toward SharePoint downloader 2021-10-13 22:01:54 +02:00
dependabot[bot]
b2497865a1 Bump axios from 0.21.1 to 0.21.2 (#404)
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-16 14:53:43 +03:00
drew458
a86389774d fixed typo (#399) 2021-08-24 12:44:35 +03:00
dependabot[bot]
7c16da6505 Bump color-string from 1.5.4 to 1.5.5 (#392)
Bumps [color-string](https://github.com/Qix-/color-string) from 1.5.4 to 1.5.5.
- [Release notes](https://github.com/Qix-/color-string/releases)
- [Changelog](https://github.com/Qix-/color-string/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Qix-/color-string/compare/1.5.4...1.5.5)

---
updated-dependencies:
- dependency-name: color-string
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-28 16:19:34 +03:00
Adrian Calinescu
52d5b227e2 Fix typo 2021-06-28 14:18:24 +03:00
Adrian Calinescu
a62f8ef777 Mention chrome://version in README 2021-06-28 14:16:42 +03:00
dependabot[bot]
d9137cc690 Bump glob-parent from 5.1.1 to 5.1.2 (#389)
Bumps [glob-parent](https://github.com/gulpjs/glob-parent) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/gulpjs/glob-parent/releases)
- [Changelog](https://github.com/gulpjs/glob-parent/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gulpjs/glob-parent/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: glob-parent
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-25 10:03:37 +03:00
dependabot[bot]
6ac226bda6 Bump ws from 7.4.2 to 7.4.6 (#386)
Bumps [ws](https://github.com/websockets/ws) from 7.4.2 to 7.4.6.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.4.2...7.4.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-25 10:01:57 +03:00
snobu
cb9e844d06 Cleanup yarn.lock 2021-05-21 09:57:58 +03:00
snobu
c7efd90f7f Trigger Action on PRs as well 2021-05-21 09:47:40 +03:00
dependabot[bot]
9fcc631236 Bump lodash from 4.17.20 to 4.17.21 (#381)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.20...4.17.21)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-18 19:15:49 +03:00
snobu
1da56990bc Cleanup README for thumb bug 2021-05-11 11:07:10 +03:00
snobu
7d91f32af2 Fix for null thumbnail URL 2021-05-11 11:06:16 +03:00
Adrian Calinescu
918aadce5d Added thumbnail bug to README 2021-05-10 14:40:45 +03:00
Adrian Calinescu
dfab30cf46 Add -x for now 2021-05-05 13:10:02 +03:00
lukaarma
757aab1747 Merge pull request #367 from jirkavrba/fix-typo
Fix typo in .token_cache
2021-04-15 21:15:18 +02:00
Jiří Vrba
ef91acaf10 Directly reference tokenCacheFile 2021-04-15 21:09:59 +02:00
lukaarma
cb689336d8 Merge pull request #368 from TomasHubelbauer/patch-1
Mention TokenCache in the own browser usage section
2021-04-15 14:50:18 +02:00
Tomáš Hübelbauer
deadd6758c Mention TokenCache in the own browser usage section 2021-04-14 16:10:24 +02:00
Jiří Vrba
e9a0954528 Fix typo in .token_cache 2021-04-12 10:15:12 +02:00
lukaarma
eb588f74a3 Merge pull request #356 from Gargaj/patch-1
escape edge path properly
2021-03-31 11:58:58 +02:00
Gargaj
22658a3706 escape edge path properly 2021-03-30 18:27:17 +02:00
Giuseppe Montuoro
55234af08f Fixed minor linting error due to pull request #345. (#346) 2021-03-20 17:22:06 +02:00
lukaarma
a129ac0240 Merge pull request #345 from goldmont/master
Output template valid even without "magic keywords". Updated README to reflect changes.
2021-03-18 21:19:11 +01:00
Giuseppe Montuoro
1181290d8f Improved "Title template" documentation. 2021-03-18 20:42:48 +01:00
Giuseppe Montuoro
21215bc9da Updated README. 2021-03-18 17:10:53 +01:00
Giuseppe Montuoro
8b2a02d0ae Template elements are no more mandatory in final name. 2021-03-18 16:37:35 +01:00
Adrian Calinescu
7efa54932f Added KNOWN BUGS section 2021-03-02 10:30:15 +02:00
lukaarma
c81b16c8f3 Merge pull request #322 from apandada1/patch-1
update README - add instructions for Linux
2021-02-09 21:33:34 +01:00
Archisman Panigrahi
aa569bee4d update README - add instructions for Linux
These instructions are valid for a lot of Linux distros, and not just limited to Raspberry Pi.
2021-02-07 12:20:18 +05:30
Adrian Calinescu
9ebd4faab3 Add Raspberry Pi note 2021-01-26 16:59:48 +02:00
dependabot[bot]
66c018e164 Bump axios from 0.21.0 to 0.21.1 (#309)
Bumps [axios](https://github.com/axios/axios) from 0.21.0 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.0...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-26 14:52:39 +02:00
Adrian Calinescu
3c7d61febe Fix return HTTP 403 reason with or without verbose (#315) 2021-01-26 14:49:42 +02:00
Adrian Calinescu
b5226713b6 If you're using Node 8 you're on your own, pal. 2021-01-13 20:55:21 +02:00
snobu
f9bc0c7128 I've had enough of Node 8.x horse manure 2021-01-13 20:53:00 +02:00
snobu
2c38517bcd Fix some dumb npm s*** 2021-01-13 20:18:50 +02:00
lukaarma
f8207f4fd1 Group parsing fix and error out on old ffmpeg version (#298)
* fixed parsing for group with more than 100 videos

* updated all packages to latest version

* Error on old ffmpeg binaries (closes #294)
minor linting fixes

* automatic update of files

Co-authored-by: Adrian Calinescu <foo@snobu.org>
2021-01-13 20:12:12 +02:00
lukaarma
58122d5c4e Merge pull request #307 from snobu/dependabot/npm_and_yarn/axios-0.21.1
Bump axios from 0.19.2 to 0.21.1 (vulnerability fix)
2021-01-08 16:16:23 +01:00
Adrian Calinescu
0726bd90f4 Holiday cleanup 2021-01-07 18:33:57 +02:00
dependabot[bot]
2d0407e5c8 Bump axios from 0.19.2 to 0.21.1
Bumps [axios](https://github.com/axios/axios) from 0.19.2 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.19.2...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-05 23:14:38 +00:00
Adrian Calinescu
0da9c6fb5f Fixup for the holidays 2020-12-22 21:51:07 +02:00
Adrian Calinescu
f26204c38a Revert "Fixed parsing for group with more than 100 videos (#288)" (#295)
This reverts commit cd1ac82fea.
2020-12-15 13:02:23 +02:00
lukaarma
cd1ac82fea Fixed parsing for group with more than 100 videos (#288)
* fixed parsing for group with more than 100 videos
* updated all packages to latest version
2020-12-15 12:55:10 +02:00
Adrian Calinescu
fbe8de00de Added link to aria branch in README 2020-12-03 00:07:13 +02:00
fulminemizzega
b48af65285 fix quotes in input file example in README.md (#283)
This should close #281
2020-11-22 20:54:42 +02:00
snobu
e9070511cf Srsly really fixed group download this time 2020-11-14 20:08:45 +02:00
snobu
ad483f3eb7 Fix group download, now limited to first 100 videos 2020-11-14 19:54:50 +02:00
Adrian Calinescu
ac0fdf5468 Fix MSEdge launch params 2020-10-15 16:07:20 +03:00
23 changed files with 1687 additions and 6247 deletions

View File

@@ -6,6 +6,9 @@ on:
- 'README.md'
branches:
- master
pull_request:
branches:
- master
jobs:
build:
@@ -14,7 +17,7 @@ jobs:
strategy:
matrix:
node-version: [8.x, 10.x, 12.x, 13.x]
node-version: [10.x, 12.x, 13.x]
steps:
- uses: actions/checkout@v1

3
.gitignore vendored
View File

@@ -3,8 +3,9 @@
*.log
*.js
*.zip
*.xml
.vscode\launch.json
yarn.lock
.chrome_data
node_modules

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// 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
]
}
]
}

View File

@@ -1,3 +1,4 @@
{
"eslint.enable": true
}
"eslint.enable": true,
"typescript.tsdk": "node_modules\\typescript\\lib"
}

View File

@@ -2,9 +2,7 @@
<img src="https://github.com/snobu/destreamer/workflows/Node%20CI/badge.svg" alt="CI build status" />
</a>
# destreamer v3.0 (aria2c as download manager)
## This is a pre-release branch so don't expect stability, do expect speed improvements. Tons of it.
**destreamer v3.0** is just around the corner. You can try out a pre-release today by cloning [this branch](https://github.com/snobu/destreamer/tree/aria2c_forRealNow).
![destreamer](assets/logo.png)
@@ -16,7 +14,7 @@ _(Alternative artwork proposals are welcome! Submit one through an Issue.)_
This release would not have been possible without the code and time contributed by two distinguished developers: [@lukaarma](https://github.com/lukaarma) and [@kylon](https://github.com/kylon). Thank you!
### Specialized vesions
### Specialized versions
- [Politecnico di Milano][polimi]: fork over at https://github.com/SamanFekri/destreamer
- [Università di Pisa][unipi]: fork over at https://github.com/Guray00/destreamer-unipi
@@ -40,12 +38,11 @@ Hopefully this doesn't break the end user agreement for Microsoft Stream. Since
## Prereqs
- [**Node.js**][node]: You'll need Node.js version 8.0 or higher. A GitHub Action runs tests on all major Node versions on every commit. One caveat for Node 8, if you get a `Parse Error` with `code: HPE_HEADER_OVERFLOW` you're out of luck and you'll need to upgrade to Node 10+.
- [**Node.js**][node]: You'll need Node.js version 8.0 or higher. A GitHub Action runs tests on all major Node versions on every commit. One caveat for Node 8, if you get a `Parse Error` with `code: HPE_HEADER_OVERFLOW` you're out of luck and you'll need to upgrade to Node 10+. PLEASE NOTE WE NO LONGER TEST BUILDS AGAINST NODE 8.x. YOU ARE ON YOUR OWN.
- **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).
- [**aria2**][aria2]: aria2 is a utility for downloading files with multiple threads, fast.
- [**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.
@@ -61,29 +58,25 @@ Note that destreamer won't run in an elevated (Administrator/root) shell. Runnin
## Can i plug in my own browser?
Yes, yes you can. This may be useful if your main browser has some authentication plugins that are required for you to logon to your Microsoft Stream tenant.
To use your own browser for the authentication part, locate the following snippet in `src/destreamer.ts`:
To use your own browser for the authentication part, locate the following snippet in `src/destreamer.ts` and `src/TokenCache.ts`:
```typescript
const browser: puppeteer.Browser = await puppeteer.launch({
executablePath: getPuppeteerChromiumPath(),
headless: false,
userDataDir: (argv.keepLoginCookies) ? chromeCacheFolder : undefined,
args: [
'--disable-dev-shm-usage',
'--fast-start',
'--no-sandbox'
]
});
executablePath: getPuppeteerChromiumPath(),
// …
});
```
Navigate to `chrome://version` in the browser you want to plug in and copy executable path from there. Use double backslash for Windows.
Now, change `executablePath` to reflect the path to your browser and profile (i.e. to use Microsoft Edge on Windows):
```typescript
executablePath: "'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' --profile-directory=Default",
executablePath: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
```
Note that for Mac/Linux the path will look a little different but no other changes are necessary.
You can add `userDataDir` right after `executablePath` with the path to your browser profile (also shown in `chrome://version`) if you want that loaded as well.
You need to rebuild (`npm run build`) every time you change this configuration.
Remember to rebuild (`npm run build`) every time you change this configuration.
## How to build
@@ -184,7 +177,7 @@ https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx
```
### Title template
The `-t` option allows users to input a template string for the output file names.
The `-t` option allows user to specify a custom filename for the videos.
You can use one or more of the following magic sequence which will get substituted at runtime. The magic sequence must be surrounded by curly brackets like this: `{title} {publishDate}`
@@ -196,8 +189,20 @@ You can use one or more of the following magic sequence which will get substitut
- `authorEmail`: E-mail of video publisher
- `uniqueId`: An _unique-enough_ ID generated from the video metadata
Example -
Examples -
```
Input:
-t 'This is an example'
Expected filename:
This is an example.mkv
Input:
-t 'This is an example by {author}'
Expected filename:
This is an example by lukaarma.mkv
Input:
-t '{title} - {duration} - {publishDate} - {publishTime} - {author} - {authorEmail} - {uniqueId}'
@@ -217,6 +222,14 @@ iTerm2 on a Mac -
By default, downloads are saved under project root `Destreamer/videos/` ( Not the system media Videos folder ), unless specified by `-o` (output directory).
## KNOWN BUGS
If you get a
```
[FATAL ERROR] Unknown error: exit code 4
````
when running destreamer, then make sure you're running a recent (post year 2019), stable version of **ffmpeg**.
## Contributing
Contributions are welcome. Open an issue first before sending in a pull request. All pull requests require at least one code review before they are merged to master.
@@ -227,10 +240,10 @@ Please open an [issue](https://github.com/snobu/destreamer/issues) and we'll loo
[ffmpeg]: https://www.ffmpeg.org/download.html
[aria2]: https://github.com/aria2/aria2/releases
[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/

5591
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,51 @@
{
"name": "destreamer",
"repository": {
"type": "git",
"url": "git://github.com/snobu/destreamer.git"
},
"version": "2.1.0",
"description": "Save Microsoft Stream videos for offline enjoyment.",
"main": "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",
"lint": "eslint src/*.ts"
},
"keywords": [],
"author": "snobu",
"license": "MIT",
"devDependencies": {
"@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.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": {
"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.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.2.1",
"tmp": "^0.2.1",
"winston": "^3.3.3",
"ws": "^7.4.0",
"yargs": "^16.1.1"
}
"name": "destreamer",
"repository": {
"type": "git",
"url": "git://github.com/snobu/destreamer.git"
},
"version": "2.1.0",
"description": "Save Microsoft Stream videos for offline enjoyment.",
"main": "build/src/destreamer.js",
"bin": "build/src/destreamer.js",
"scripts": {
"build": "echo Transpiling TypeScript to JavaScript... && tsc && echo Destreamer was built successfully.",
"watch": "tsc --watch",
"test": "mocha build/test",
"lint": "eslint src/*.ts"
},
"keywords": [],
"author": "snobu",
"license": "MIT",
"devDependencies": {
"@types/mocha": "^8.0.4",
"@types/puppeteer": "^5.4.0",
"@types/readline-sync": "^1.4.3",
"@types/tmp": "^0.2.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",
"tmp": "^0.2.1"
},
"dependencies": {
"@tedconf/fessonia": "^2.1.2",
"@types/cli-progress": "^3.8.0",
"@types/jwt-decode": "^2.2.1",
"axios": "^0.21.2",
"axios-retry": "^3.1.9",
"cli-progress": "^3.8.2",
"colors": "^1.4.0",
"is-elevated": "^3.0.0",
"iso8601-duration": "^1.3.0",
"jwt-decode": "^3.1.2",
"puppeteer": "5.5.0",
"readline-sync": "^1.4.10",
"sanitize-filename": "^1.6.3",
"terminal-image": "^1.2.1",
"typescript": "^4.1.2",
"winston": "^3.3.3",
"yargs": "^16.1.1"
}
}

View File

@@ -1,21 +1,23 @@
import { logger } from './Logger';
import { Session } from './Types';
import { ShareSession, StreamSession, Video } from './Types';
import { publishedDateToString, publishedTimeToString } from './VideoUtils';
import axios, { AxiosRequestConfig, AxiosResponse, AxiosInstance, AxiosError } from 'axios';
import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry';
// import fs from 'fs';
export class ApiClient {
private static instance: ApiClient;
export class StreamApiClient {
private static instance: StreamApiClient;
private axiosInstance?: AxiosInstance;
private session?: Session;
private session?: StreamSession;
private constructor(session?: Session) {
private constructor(session?: StreamSession) {
this.session = session;
this.axiosInstance = axios.create({
baseURL: session?.ApiGatewayUri,
// timeout: 7000,
headers: { 'User-Agent': 'destreamer/3.0 (Preview)' }
headers: { 'User-Agent': 'destreamer/2.0 (Hammer of Dawn)' }
});
axiosRetry(this.axiosInstance, {
@@ -34,10 +36,9 @@ export class ApiClient {
return true;
}
logger.warn(`Got HTTP code ${err?.response?.status ?? undefined}. Retrying request...`);
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}`);
logger.warn('Here is the error message: ');
console.dir(err.response?.data);
logger.warn('We called this URL: ' + err.response?.config.baseURL + err.response?.config.url);
const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);
@@ -51,16 +52,16 @@ export class ApiClient {
*
* @param session used if initializing
*/
public static getInstance(session?: Session): ApiClient {
if (!ApiClient.instance) {
ApiClient.instance = new ApiClient(session);
public static getInstance(session?: StreamSession): StreamApiClient {
if (!StreamApiClient.instance) {
StreamApiClient.instance = new StreamApiClient(session);
}
return ApiClient.instance;
return StreamApiClient.instance;
}
public setSession(session: Session): void {
if (!ApiClient.instance) {
public setSession(session: StreamSession): void {
if (!StreamApiClient.instance) {
logger.warn("Trying to update ApiCient session when it's not initialized!");
}
@@ -84,13 +85,6 @@ 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,
@@ -112,14 +106,6 @@ 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,
@@ -129,3 +115,134 @@ export class ApiClient {
});
}
}
export class ShareApiClient {
private axiosInstance: AxiosInstance;
private site: string;
public constructor(domain: string, site: string, session: ShareSession) {
this.axiosInstance = axios.create({
baseURL: domain,
// timeout: 7000,
headers: {
'User-Agent': 'destreamer/3.0 ALPHA',
'Cookie': `rtFa=${session.rtFa}; FedAuth=${session.FedAuth}`
}
});
this.site = site;
// FIXME: disabled because it was messing with the direct download check
// axiosRetry(this.axiosInstance, {
// // The following option is not working.
// // We should open an issue on the relative GitHub
// shouldResetTimeout: true,
// retries: 6,
// retryDelay: (retryCount: number) => {
// return retryCount * 2000;
// },
// retryCondition: (err: AxiosError) => {
// const retryCodes: Array<number> = [429, 500, 502, 503];
// if (isNetworkOrIdempotentRequestError(err)) {
// logger.warn(`${err}. Retrying request...`);
// return true;
// }
// logger.warn(`Got HTTP code ${err?.response?.status ?? undefined}.`);
// logger.warn('Here is the error message: ');
// console.dir(err.response?.data);
// logger.warn('We called this URL: ' + err.response?.config.baseURL + err.response?.config.url);
// const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);
// return shouldRetry;
// }
// });
}
public async getVideoInfo(filePath: string, outPath: string): Promise<Video> {
let playbackUrl: string;
// TODO: Ripped this straigth from chromium inspector. Don't know don't care what it is right now. Check later
const payload = {
parameters: {
__metadata: {
type: 'SP.RenderListDataParameters'
},
ViewXml: `<View Scope="RecursiveAll"><Query><Where><Eq><FieldRef Name="FileRef" /><Value Type="Text"><![CDATA[${filePath}]]></Value></Eq></Where></Query><RowLimit Paged="TRUE">1</RowLimit></View>`,
RenderOptions: 12295,
AddRequiredFields: true
}
};
const url = `${this.site}/_api/web/GetListUsingPath(DecodedUrl=@a1)/RenderListDataAsStream?@a1='${encodeURIComponent(filePath)}'`;
logger.verbose(`Requesting video info for '${url}'`);
const info = await this.axiosInstance.post(url, payload, {
headers: {
'Content-Type': 'application/json;odata=verbose'
}
}).then(res => res.data);
// fs.writeFileSync('info.json', JSON.stringify(info, null, 4));
// FIXME: very bad but usefull in alpha stage to check for edge cases
if (info.ListData.Row.length !== 1) {
logger.error('More than 1 row in SharePoint video info', { fatal: true });
process.exit(1000);
}
const direct = await this.canDirectDownload(filePath);
const b64VideoMetadata = JSON.parse(
info.ListData.Row[0].MediaServiceFastMetadata
).video.altManifestMetadata;
const durationSeconds = Math.ceil(
(JSON.parse(
Buffer.from(b64VideoMetadata, 'base64').toString()
).Duration100Nano) / 10 / 1000 / 1000
);
if (direct) {
playbackUrl = this.axiosInstance.defaults.baseURL + filePath;
// logger.verbose(playbackUrl);
}
else {
playbackUrl = info.ListSchema['.videoManifestUrl'];
playbackUrl = playbackUrl.replace('{.mediaBaseUrl}', info.ListSchema['.mediaBaseUrl']);
// the only filetype works I found
playbackUrl = playbackUrl.replace('{.fileType}', 'mp4');
playbackUrl = playbackUrl.replace('{.callerStack}', info.ListSchema['.callerStack']);
playbackUrl = playbackUrl.replace('{.spItemUrl}', info.ListData.Row[0]['.spItemUrl']);
playbackUrl = playbackUrl.replace('{.driveAccessToken}', info.ListSchema['.driveAccessToken']);
playbackUrl += '&part=index&format=dash';
}
return {
direct,
title: filePath.split('/').pop() ?? 'video.mp4',
duration: publishedTimeToString(durationSeconds),
publishDate: publishedDateToString(info.ListData.Row[0]['Modified.']),
publishTime: publishedTimeToString(info.ListData.Row[0]['Modified.']),
author: info.ListData.Row[0]['Author.title'],
authorEmail: info.ListData.Row[0]['Author.email'],
uniqueId: info.ListData.Row[0].GUID.substring(1, 9),
outPath,
playbackUrl,
totalChunks: durationSeconds
};
}
private async canDirectDownload(filePath: string): Promise<boolean> {
logger.verbose(`Checking direct download for '${filePath}'`);
return this.axiosInstance.head(
filePath, { maxRedirects: 0 }
).then(
res => (res.status === 200)
).catch(
() => false
);
}
}

View File

@@ -1,15 +1,14 @@
import { CLI_ERROR, ERROR_CODE } from './Errors';
import { checkOutDir } from './Utils';
import { CLI_ERROR } from './Errors';
import { makeOutDir } from './Utils';
import { logger } from './Logger';
import { templateElements } from './Types';
import fs from 'fs';
import readlineSync from 'readline-sync';
import sanitize from 'sanitize-filename';
import yargs from 'yargs';
export const argv: any = yargs.options({
export const argv = yargs.options({
username: {
alias: 'u',
type: 'string',
@@ -71,27 +70,32 @@ 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',
@@ -108,9 +112,17 @@ export const argv: any = yargs.options({
.wrap(120)
.check(() => noArguments())
.check((argv: any) => checkInputConflicts(argv.videoUrls, argv.inputFile))
.check((argv: any) => checkOutputDirectoryExistance(argv.outputDirectory))
.check((argv: any) => {
if (makeOutDir(argv.outputDirectory)) {
return true;
}
else {
logger.error(CLI_ERROR.INVALID_OUTDIR);
throw new Error(' ');
}
})
.check((argv: any) => isOutputTemplateValid(argv))
.check((argv: any) => checkQualityValue(argv))
.argv;
@@ -160,22 +172,9 @@ 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);
let match = elementRegEx.exec(finalTemplate);
let match = elementRegEx.exec(argv.outputTemplate);
// if no template elements this fails
if (match) {
@@ -190,46 +189,11 @@ function isOutputTemplateValid(argv: any): boolean {
process.exit(1);
}
match = elementRegEx.exec(finalTemplate);
match = elementRegEx.exec(argv.outputTemplate);
}
}
// bad template from user, switching to default
else {
logger.warn('Empty output template provided, using default one \n');
finalTemplate = '{title} - {publishDate} {uniqueId}';
}
argv.outputTemplate = sanitize(finalTemplate.trim());
argv.outputTemplate = sanitize(argv.outputTemplate.trim());
return true;
}
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 {
const index: number = readlineSync.keyInSelect(choices, 'Which resolution/format do you prefer?');
if (index === -1) {
process.exit(ERROR_CODE.CANCELLED_USER_INPUT);
}
return index;
}

View File

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

View File

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

335
src/Downloaders.ts Normal file
View File

@@ -0,0 +1,335 @@
import { ShareApiClient, StreamApiClient } from './ApiClient';
import { argv } from './CommandLineParser';
import { ERROR_CODE } from './Errors';
import { logger } from './Logger';
import { doShareLogin, doStreamLogin } from './LoginModules';
import { drawThumbnail } from './Thumbnail';
import { refreshSession, TokenCache } from './TokenCache';
import { Video, VideoUrl } from './Types';
import { ffmpegTimemarkToChunk } from './Utils';
import { createUniquePath, getStreamInfo } from './VideoUtils';
import cliProgress from 'cli-progress';
import fs from 'fs';
import { execSync } from 'child_process';
import path from 'path';
const { FFmpegCommand, FFmpegInput, FFmpegOutput } = require('@tedconf/fessonia')();
const tokenCache: TokenCache = new TokenCache();
export async function downloadStreamVideo(videoUrls: Array<VideoUrl>): Promise<void> {
logger.info('Downloading Microsoft Stream videos...');
let session = tokenCache.Read() ?? await doStreamLogin('https://web.microsoftstream.com/', tokenCache, argv.username);
logger.verbose(
'Session and API info \n' +
'\t API Gateway URL: '.cyan + session.ApiGatewayUri + '\n' +
'\t API Gateway version: '.cyan + session.ApiGatewayVersion + '\n'
);
logger.info('Fetching videos info... \n');
const videos: Array<Video> = createUniquePath(
await getStreamInfo(videoUrls, session, argv.closedCaptions),
argv.outputTemplate, argv.format, argv.skip
);
if (argv.simulate) {
videos.forEach((video: Video) => {
logger.info(
'\nTitle: '.green + video.title +
'\nOutPath: '.green + video.outPath +
'\nPublished Date: '.green + video.publishDate +
'\nPlayback URL: '.green + video.playbackUrl +
((video.captionsUrl) ? ('\nCC URL: '.green + video.captionsUrl) : '')
);
});
return;
}
for (const [index, video] of videos.entries()) {
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/' + video.guid);
StreamApiClient.getInstance().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,
});
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');
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.'
);
}
const headers: string = 'Authorization: Bearer ' + session.AccessToken;
if (!argv.noExperiments) {
if (video.posterImageUrl) {
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'
});
// 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]
]));
ffmpegCmd.addInput(captionsInpt);
}
ffmpegCmd.on('update', async (data: any) => {
const currentChunks: number = ffmpegTimemarkToChunk(data.out_time);
pbar.update(currentChunks, {
speed: data.bitrate
});
// 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`);
}
});
process.on('SIGINT', cleanupFn);
// 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();
});
process.removeListener('SIGINT', cleanupFn);
}
}
// TODO: complete overhaul of this function
export async function downloadShareVideo(videoUrls: Array<VideoUrl>): Promise<void> {
const shareUrlRegex = new RegExp(/(?<domain>https:\/\/.+\.sharepoint\.com).*?(?<baseSite>\/(?:teams|sites|personal)\/.*?)(?:(?<filename>\/.*\.mp4)|\/.*id=(?<paramFilename>.*mp4))/);
logger.info('Downloading SharePoint videos...\n\n');
// FIXME: this may change we need a smart login system if a request fails
const session = await doShareLogin(videoUrls[0].url, argv.username);
for (const videoUrl of videoUrls) {
const match = shareUrlRegex.exec(videoUrl.url);
if (!match) {
logger.error(`Invalid url '${videoUrl.url}', skipping...`);
continue;
}
const shareDomain = match.groups!.domain;
const shareSite = match.groups!.baseSite;
const shareFilepath = decodeURIComponent(match.groups?.filename ? (shareSite + match.groups.filename) : match.groups!.paramFilename);
// FIXME: hardcoded video.mp4
const title = shareFilepath.split('/').pop()?.split('.')[0] ?? 'video';
const apiClient = new ShareApiClient(shareDomain, shareSite, session);
const video = await apiClient.getVideoInfo(shareFilepath, videoUrl.outDir);
createUniquePath(video, title, argv.format, argv.skip);
if (argv.simulate) {
if (argv.verbose) {
console.dir(video);
}
else {
logger.info(
'\nTitle: '.green + video.title +
'\nOutPath: '.green + video.outPath +
'\nPublished Date: '.green + video.publishDate +
'\nPlayback URL: '.green + video.playbackUrl
);
}
continue;
}
if (video.direct) {
const headers = `Cookie: rtFa=${session.rtFa}; FedAuth=${session.FedAuth}`;
// FIXME: unstable and bad all-around
try {
execSync(
'aria2c --max-connection-per-server 8 --console-log-level warn ' +
`--header "${headers}" --dir "${path.dirname(video.outPath)}" --out "${path.basename(video.outPath)}" "${shareDomain + shareFilepath}"`,
{ stdio: 'inherit' }
);
}
catch (error: any) {
logger.error(`${error.status} \n\n${error.message} \n\n${error.stdout.toString()} \n\n${error.stderr.toString()}`);
}
}
else {
// FIXME: just a copy-paste, should move to separate function
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,
});
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');
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.'
);
}
const ffmpegInpt: any = new FFmpegInput(video.playbackUrl);
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'
});
// prepare ffmpeg command line
ffmpegCmd.addInput(ffmpegInpt);
ffmpegCmd.addOutput(ffmpegOutput);
ffmpegCmd.on('update', async (data: any) => {
const currentChunks: number = ffmpegTimemarkToChunk(data.out_time);
pbar.update(currentChunks, {
speed: data.bitrate
});
// 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`);
}
});
process.on('SIGINT', cleanupFn);
// 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();
});
process.removeListener('SIGINT', cleanupFn);
// logger.error('TODO: manifest download');
// continue;
}
}
}

View File

@@ -1,64 +1,55 @@
/* 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 = 1000,
UNHANDLED_ERROR = 200,
ELEVATED_SHELL,
CANCELLED_USER_INPUT,
MISSING_FFMPEG,
MISSING_ARIA2,
OUTDATED_FFMPEG,
UNK_FFMPEG_ERROR,
INVALID_VIDEO_GUID,
NO_SESSION_INFO,
NO_ENCRYPTION,
ARIA2C_CRASH,
NO_CONNECT_ARIA2C,
NO_DAEMON_PORT,
MISSING_ARIA2
NO_SESSION_INFO
}
export const errors: {[key: number]: string} = {
[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"',
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.ELEVATED_SHELL]: 'Destreamer cannot run in an elevated (Administrator/root) shell. \n' +
'Please run in a regular, non-elevated window.',
[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.CANCELLED_USER_INPUT]: 'Input was cancelled by user',
[ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing! Destreamer requires FFmpeg to merge videos',
[ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing!\n' +
'Destreamer requires a fairly recent release of FFmpeg to download videos',
[ERROR_CODE.MISSING_ARIA2]: 'Aria2c is missing! Destreamer requires Aria2c to download videos',
[ERROR_CODE.MISSING_ARIA2]: 'Aria2 is missing!\n' +
'Destreamer requires a fairly recent release of Aria2 to download videos',
[ERROR_CODE.UNK_FFMPEG_ERROR]: 'Unknown FFmpeg error',
[ERROR_CODE.OUTDATED_FFMPEG]: 'The FFmpeg version currently installed is too old!\n' +
'Destreamer requires a fairly recent release of FFmpeg to download videos',
[ERROR_CODE.INVALID_VIDEO_GUID]: 'Unable to get video GUID from URL',
[ERROR_CODE.UNK_FFMPEG_ERROR]: 'Unknown FFmpeg error',
[ERROR_CODE.NO_SESSION_INFO]: 'Could not evaluate sessionInfo on the page',
[ERROR_CODE.INVALID_VIDEO_GUID]: 'Unable to get video GUID from URL',
[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'
[ERROR_CODE.NO_SESSION_INFO]: 'Could not evaluate sessionInfo on the page'
};
export const enum CLI_ERROR {
MISSING_INPUT_ARG = 'You must specify a URLs source. \n' +
'Valid options are -i for one or more URLs separated by space or -f for input file. \n',
MISSING_INPUT_ARG = 'You must specify a URLs source. \n' +
'Valid options are -i for one or more URLs separated by space or -f for input file. \n',
INPUT_ARG_CONFLICT = 'Too many URLs sources specified! \n' +
'Please specify a single source, either -i or -f \n',
INPUT_ARG_CONFLICT = 'Too many URLs sources specified! \n' +
'Please specify a single source, either -i or -f \n',
INPUTFILE_WRONG_EXTENSION = 'The specified inputFile has the wrong extension \n' +
'Please make sure to use path/to/filename.txt when useing the -f option \n',
INPUTFILE_WRONG_EXTENSION = 'The specified inputFile has the wrong extension \n' +
'Please make sure to use path/to/filename.txt when useing the -f option \n',
INPUTFILE_NOT_FOUND = 'The specified inputFile does not exists \n'+
'Please check the filename and the path you provided \n',
INPUTFILE_NOT_FOUND = 'The specified inputFile does not exists \n' +
'Please check the filename and the path you provided \n',
INVALID_OUTDIR = 'Could not create the default/specified output directory \n' +
'Please check directory and permissions and try again. \n'
INVALID_OUTDIR = 'Could not create the default/specified output directory \n' +
'Please check directory and permissions and try again. \n'
}

View File

@@ -5,6 +5,8 @@ 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 {
@@ -19,16 +21,6 @@ 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) });

View File

@@ -35,9 +35,6 @@ 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}`;
}

176
src/LoginModules.ts Normal file
View File

@@ -0,0 +1,176 @@
import { logger } from './Logger';
import puppeteer from 'puppeteer';
import { getPuppeteerChromiumPath } from './PuppeteerHelper';
import { chromeCacheFolder } from './destreamer';
import { argv } from './CommandLineParser';
import { ShareSession, StreamSession } from './Types';
import { ERROR_CODE } from './Errors';
import { TokenCache } from './TokenCache';
export async function doStreamLogin(url: string, tokenCache: TokenCache, username?: string): Promise<StreamSession> {
logger.info('Launching headless Chrome to perform the OpenID Connect dance...');
const browser: puppeteer.Browser = await puppeteer.launch({
executablePath: getPuppeteerChromiumPath(),
headless: false,
userDataDir: (argv.keepLoginCookies) ? chromeCacheFolder : undefined,
defaultViewport: null,
args: [
'--disable-dev-shm-usage',
'--fast-start',
'--no-sandbox'
]
});
// try-finally because we were leaving zombie processes if there was an error
try {
const page: puppeteer.Page = (await browser.pages())[0];
logger.info('Navigating to login page...');
await page.goto(url, { waitUntil: 'load' });
try {
if (username) {
await page.waitForSelector('input[type="email"]', { timeout: 3000 });
await page.keyboard.type(username);
await page.click('input[type="submit"]');
}
else {
/* If a username was not provided we let the user take actions that
lead up to the video page. */
}
}
catch (e) {
/* If there is no email input selector we aren't in the login module,
we are probably using the cache to aid the login.
It could finish the login on its own if the user said 'yes' when asked to
remember the credentials or it could still prompt the user for a password */
}
await browser.waitForTarget((target: puppeteer.Target) => target.url().endsWith('microsoftstream.com/'), { timeout: 150000 });
logger.info('We are logged in.');
let session: StreamSession | null = null;
let tries = 1;
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) {
process.exit(ERROR_CODE.NO_SESSION_INFO);
}
session = null;
tries++;
await page.waitForTimeout(3000);
}
}
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");
return session;
}
finally {
await browser.close();
}
}
export async function doShareLogin(url: string, username?: string): Promise<ShareSession> {
logger.info('Launching headless Chrome to perform the OpenID Connect dance...');
let session: ShareSession | null = null;
const hostname = new URL(url).host;
const browser: puppeteer.Browser = await puppeteer.launch({
executablePath: getPuppeteerChromiumPath(),
headless: false,
devtools: argv.verbose,
userDataDir: (argv.keepLoginCookies) ? chromeCacheFolder : undefined,
defaultViewport: null,
args: [
'--disable-dev-shm-usage',
'--fast-start',
'--no-sandbox'
]
});
// try-finally because we were leaving zombie processes if there was an error
try {
const page: puppeteer.Page = (await browser.pages())[0];
logger.info('Navigating to login page...');
await page.goto(url, { waitUntil: 'load' });
try {
if (username) {
await page.waitForSelector('input[type="email"]', { timeout: 3000 });
await page.keyboard.type(username);
await page.click('input[type="submit"]');
}
else {
/* If a username was not provided we let the user take actions that
lead up to the video page. */
}
}
catch (e) {
/* If there is no email input selector we aren't in the login module,
we are probably using the cache to aid the login.
It could finish the login on its own if the user said 'yes' when asked to
remember the credentials or it could still prompt the user for a password */
}
logger.info('Waiting for target!');
await browser.waitForTarget((target: puppeteer.Target) => target.url().startsWith(`https://${hostname}`), { timeout: 150000 });
logger.info('We are logged in.');
let tries = 1;
while (!session) {
const cookieJar = (await page.cookies()).filter(
biscuit => biscuit.name == 'rtFa' || biscuit.name == 'FedAuth'
);
if (cookieJar.length != 2) {
if (tries > 5) {
process.exit(ERROR_CODE.NO_SESSION_INFO);
}
await page.waitForTimeout(1000 * tries++);
continue;
}
session = {
rtFa: cookieJar.find(biscuit => biscuit.name == 'rtFa')!.value,
FedAuth: cookieJar.find(biscuit => biscuit.name == 'FedAuth')!.value
};
}
logger.info("At this point Chromium's job is done, shutting it down...\n");
// await page.waitForTimeout(1000 * 60 * 60 * 60);
}
finally {
logger.verbose('Stream login browser closing...');
await browser.close();
logger.verbose('Stream login browser closed');
}
return session;
}

View File

@@ -1,12 +1,12 @@
import { ApiClient } from './ApiClient';
import { Session } from './Types';
import { StreamApiClient } from './ApiClient';
import { StreamSession } from './Types';
import terminalImage from 'terminal-image';
import { AxiosResponse } from 'axios';
export async function drawThumbnail(posterImage: string, session: Session): Promise<void> {
const apiClient: ApiClient = ApiClient.getInstance(session);
export async function drawThumbnail(posterImage: string, session: StreamSession): Promise<void> {
const apiClient: StreamApiClient = StreamApiClient.getInstance(session);
const thumbnail: Buffer = await apiClient.callUrl(posterImage, 'get', null, 'arraybuffer')
.then((response: AxiosResponse<any> | undefined) => response?.data);

View File

@@ -2,69 +2,60 @@ import { chromeCacheFolder } from './destreamer';
import { ERROR_CODE } from './Errors';
import { logger } from './Logger';
import { getPuppeteerChromiumPath } from './PuppeteerHelper';
import { Session } from './Types';
import { StreamSession } from './Types';
import fs from 'fs';
import jwtDecode from 'jwt-decode';
import puppeteer from 'puppeteer';
type Jwt = {
[key: string]: any
}
export class TokenCache {
private tokenCacheFile = '.token_cache';
public Read(): Session | null {
public Read(): StreamSession | null {
if (!fs.existsSync(this.tokenCacheFile)) {
logger.warn(`${this.tokenCacheFile} not found. \n`);
return null;
}
const session: Session = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8'));
const session: StreamSession = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8'));
const [isExpiring, timeLeft] = this.isExpiring(session);
type Jwt = {
[key: string]: any
}
const decodedJwt: Jwt = jwtDecode(session.AccessToken);
if (isExpiring) {
const now: number = Math.floor(Date.now() / 1000);
const exp: number = decodedJwt['exp'];
const timeLeft: number = exp - now;
if (timeLeft < 120) {
logger.warn('Access token has expired! \n');
return null;
}
else {
logger.info(`Access token still good for ${Math.floor(timeLeft / 60)} minutes.\n`.green);
return session;
}
logger.info(`Access token still good for ${Math.floor(timeLeft / 60)} minutes.\n`.green);
return session;
}
public Write(session: Session): void {
public Write(session: StreamSession): void {
const s: string = JSON.stringify(session, null, 4);
fs.writeFile('.token_cache', s, (err: any) => {
fs.writeFile(this.tokenCacheFile, s, (err: any) => {
if (err) {
return logger.error(err);
}
logger.info('Fresh access token dropped into .token_cachen \n'.green);
logger.info(`Fresh access token dropped into ${this.tokenCacheFile} \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> {
export async function refreshSession(url: string): Promise<StreamSession> {
const videoId: string = url.split('/').pop() ?? process.exit(ERROR_CODE.INVALID_VIDEO_GUID);
const browser: puppeteer.Browser = await puppeteer.launch({
executablePath: getPuppeteerChromiumPath(),
@@ -80,9 +71,9 @@ 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().endsWith('microsoftstream.com/'), { timeout: 150000 });
await browser.waitForTarget((target: puppeteer.Target) => target.url().includes(videoId), { timeout: 30000 });
let session: Session | null = null;
let session: StreamSession | null = null;
let tries = 1;
while (!session) {

View File

@@ -1,12 +1,34 @@
export type Session = {
export type StreamSession = {
AccessToken: string;
ApiGatewayUri: string;
ApiGatewayVersion: string;
}
export type ShareSession = {
FedAuth: string;
rtFa: string;
}
export type VideoUrl = {
url: string,
outDir: string
}
export type SharepointVideo = {
// if we can download the MP4 or we need to use DASH
direct: boolean;
playbackUrl: string;
title: string;
outPath: string
}
export type Video = {
// the following properties are all for the title template
guid?: string;
direct?: boolean;
title: string;
duration: string;
publishDate: string;
@@ -14,20 +36,15 @@ export type Video = {
author: string;
authorEmail: string;
uniqueId: string;
// 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;
totalChunks: number; // Abstraction of FFmpeg timemark
playbackUrl: string;
posterImageUrl?: string;
captionsUrl?: string
}
/* NOTE: expand this template once we are all on board with a list
/* TODO: 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',

View File

@@ -1,49 +1,62 @@
import { ApiClient } from './ApiClient';
import { StreamApiClient } from './ApiClient';
import { ERROR_CODE } from './Errors';
import { logger } from './Logger';
import { Session } from './Types';
import { StreamSession, VideoUrl } from './Types';
import { AxiosResponse } from 'axios';
import { execSync } from 'child_process';
import fs from 'fs';
import readlineSync from 'readline-sync';
async function extractGuids(url: string, client: ApiClient): Promise<Array<string> | null> {
const streamUrlRegex = new RegExp(/https?:\/\/web\.microsoftstream\.com.*/);
const shareUrlRegex = new RegExp(/https?:\/\/.+\.sharepoint\.com.*/);
/** we place the guid in the url fild in the return */
export async function extractStreamGuids(urlList: Array<VideoUrl>, session: StreamSession): Promise<Array<VideoUrl>> {
const videoRegex = new RegExp(/https:\/\/.*\/video\/(\w{8}-(?:\w{4}-){3}\w{12})/);
const groupRegex = new RegExp(/https:\/\/.*\/group\/(\w{8}-(?:\w{4}-){3}\w{12})/);
const videoMatch: RegExpExecArray | null = videoRegex.exec(url);
const groupMatch: RegExpExecArray | null = groupRegex.exec(url);
const apiClient: StreamApiClient = StreamApiClient.getInstance(session);
const guidList: Array<VideoUrl> = [];
if (videoMatch) {
return [videoMatch[1]];
}
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> = [];
for (const url of urlList) {
const videoMatch: RegExpExecArray | null = videoRegex.exec(url.url);
const groupMatch: RegExpExecArray | null = groupRegex.exec(url.url);
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);
if (videoMatch) {
guidList.push({
url: videoMatch[1],
outDir: url.outDir
});
}
else if (groupMatch) {
const videoNumber: number = await apiClient.callApi(`groups/${groupMatch[1]}`, 'get')
.then((response: AxiosResponse<any> | undefined) => response?.data.metrics.videos);
return result;
// 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++) {
await apiClient.callApi(
`groups/${groupMatch[1]}/videos?$skip=${100 * index}&` +
'$top=100&$orderby=publishedDate asc', 'get'
).then((response: AxiosResponse<any> | undefined) => {
response?.data.value.forEach((video: { id: string }) =>
guidList.push({
url: video.id,
outDir: url.outDir
})
);
});
}
}
else {
logger.warn(`Invalid url '${url.url}', skipping...`);
}
}
return null;
return guidList;
}
@@ -54,30 +67,32 @@ async function extractGuids(url: string, client: ApiClient): Promise<Array<strin
*
* @param {Array<string>} urlList list of link to parse
* @param {string} defaultOutDir the directry used to save the videos
* @param {Session} session used to call the API to get the GUIDs from group links
*
* @returns Array of 2 elements, 1st one being the GUIDs array, 2nd one the output directories array
* @returns Array of 2 elements: 1st an array of Microsoft Stream urls, 2nd an array of SharePoint urls
*/
export async function parseCLIinput(urlList: Array<string>, defaultOutDir: string,
session: Session): Promise<Array<Array<string>>> {
const apiClient: ApiClient = ApiClient.getInstance(session);
const guidList: Array<string> = [];
export function parseCLIinput(urlList: Array<string>, defaultOutDir: string): Array<Array<VideoUrl>> {
const stream: Array<VideoUrl> = [];
const share: Array<VideoUrl> = [];
for (const url of urlList) {
const guids: Array<string> | null = await extractGuids(url, apiClient);
if (guids) {
guidList.push(...guids);
if (streamUrlRegex.test(url)) {
stream.push({
url: url,
outDir: defaultOutDir
});
}
else if (shareUrlRegex.test(url)) {
share.push({
url: url,
outDir: defaultOutDir
});
}
else {
logger.warn(`Invalid url '${url}', skipping..`);
}
}
const outDirList: Array<string> = Array(guidList.length).fill(defaultOutDir);
return [guidList, outDirList];
return [stream, share];
}
@@ -88,94 +103,84 @@ export async function parseCLIinput(urlList: Array<string>, defaultOutDir: strin
*
* @param {string} inputFile path to the text file
* @param {string} defaultOutDir the default/fallback directory used to save the videos
* @param {Session} session used to call the API to get the GUIDs from group links
*
* @returns Array of 2 elements, 1st one being the GUIDs array, 2nd one the output directories array
*/
export async function parseInputFile(inputFile: string, defaultOutDir: string,
session: Session): Promise<Array<Array<string>>> {
export function parseInputFile(inputFile: string, defaultOutDir: string): Array<Array<VideoUrl>> {
// rawContent is a list of each line of the file
const rawContent: Array<string> = fs.readFileSync(inputFile).toString()
.split(/\r?\n/);
const apiClient: ApiClient = ApiClient.getInstance(session);
const guidList: Array<string> = [];
const outDirList: Array<string> = [];
// if the last line was an url set this
let foundUrl = false;
const rawContent: Array<string> = fs.readFileSync(inputFile).toString().split(/\r?\n/);
const stream: Array<VideoUrl> = [];
const share: Array<VideoUrl> = [];
let streamUrl = false;
for (let i = 0; i < rawContent.length; i++) {
const line: string = rawContent[i];
const nextLine: string | null = i < rawContent.length ? rawContent[i + 1] : null;
let outDir = defaultOutDir;
// filter out lines with no content
if (!line.match(/\S/)) {
logger.warn(`Line ${i + 1} is empty, skipping..`);
continue;
}
// parse if line is option
else if (line.includes('-dir')) {
if (foundUrl) {
const outDir: string | null = parseOption('-dir', line);
// check for urls
else if (streamUrlRegex.test(line)) {
streamUrl = true;
}
else if (shareUrlRegex.test(line)) {
streamUrl = false;
}
// now invalid line since we skip ahead one line if we find dir option
else {
logger.warn(`Line ${i + 1}: '${line}' is invalid, skipping..`);
if (outDir && checkOutDir(outDir)) {
outDirList.push(...Array(guidList.length - outDirList.length)
.fill(outDir));
}
else {
outDirList.push(...Array(guidList.length - outDirList.length)
.fill(defaultOutDir));
}
continue;
}
foundUrl = false;
continue;
}
else {
logger.warn(`Found options without preceding url at line ${i + 1}, skipping..`);
continue;
// we now have a valid url, check next line for option
if (nextLine) {
const optionDir = parseOption('-dir', nextLine);
if (optionDir && makeOutDir(optionDir)) {
outDir = optionDir;
// if there was an option we skip a line
i++;
}
}
/* now line is not empty nor an option line.
If foundUrl is still true last line didn't have a directory option
so we stil need to add the default outDir to outDirList to */
if (foundUrl) {
outDirList.push(...Array(guidList.length - outDirList.length)
.fill(defaultOutDir));
foundUrl = false;
}
const guids: Array<string> | null = await extractGuids(line, apiClient);
if (guids) {
guidList.push(...guids);
foundUrl = true;
if (streamUrl) {
stream.push({
url: line,
outDir
});
}
else {
logger.warn(`Invalid url at line ${i + 1}, skipping..`);
share.push({
url: line,
outDir
});
}
}
// if foundUrl is still true after the loop we have some url without an outDir
if (foundUrl) {
outDirList.push(...Array(guidList.length - outDirList.length)
.fill(defaultOutDir));
}
return [guidList, outDirList];
return [stream, share];
}
// This leaves us the option to add more options (badum tss) _Luca
function parseOption(optionSyntax: string, item: string): string | null {
const match: RegExpMatchArray | null = item.match(
RegExp(`^\\s*${optionSyntax}\\s?=\\s?['"](.*)['"]`)
RegExp(`^\\s+${optionSyntax}\\s*=\\s*['"](.*)['"]`)
);
return match ? match[1] : null;
}
export function checkOutDir(directory: string): boolean {
/**
* @param directory path to create
* @returns true on success, false otherwise
*/
export function makeOutDir(directory: string): boolean {
if (!fs.existsSync(directory)) {
try {
fs.mkdirSync(directory);
@@ -193,16 +198,15 @@ 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 copyrightYearRe = new RegExp(/\d{4}-(\d{4})/);
const ffmpegVer: string = execSync('ffmpeg -version').toString().split('\n')[0];
if (parseInt(copyrightYearRe.exec(ffmpegVer)?.[1] ?? '0') <= 2019) {
process.exit(ERROR_CODE.OUTDATED_FFMPEG);
}
logger.verbose(`Using ${ffmpegVer}\n`);
}
catch (e) {
@@ -210,20 +214,38 @@ export function checkRequirements(): void {
}
try {
const aria2Ver: string = execSync('aria2c --version').toString().split('\n')[0];
logger.verbose(`Using ${aria2Ver}\n`);
const versionRegex = new RegExp(/aria2 version (.*)/);
const aira2Ver: string = execSync('aria2c --version').toString().split('\n')[0];
if (versionRegex.test(aira2Ver)) {
logger.verbose(`Using ${aira2Ver}\n`);
}
else {
throw new Error();
}
}
catch (e) {
process.exit(ERROR_CODE.MISSING_ARIA2);
}
}
// number of seconds
export function ffmpegTimemarkToChunk(timemark: string): number {
const timeVals: Array<string> = timemark.split(':');
const hrs: number = parseInt(timeVals[0]);
const mins: number = parseInt(timeVals[1]);
const secs: number = parseInt(timeVals[2]);
return (hrs * 60) + mins + (secs / 60);
return (hrs * 60 * 60) + (mins * 60) + secs;
}
export function promptUser(choices: Array<string>): number {
const index: number = readlineSync.keyInSelect(choices, 'Which resolution/format do you prefer?');
if (index === -1) {
process.exit(ERROR_CODE.CANCELLED_USER_INPUT);
}
return index;
}

View File

@@ -1,16 +1,16 @@
import { ApiClient } from './ApiClient';
import { promptUser } from './CommandLineParser';
import { StreamApiClient } from './ApiClient';
import { promptUser } from './Utils';
import { logger } from './Logger';
import { Video, Session } from './Types';
import { Video, StreamSession, VideoUrl } from './Types';
import { AxiosResponse } from 'axios';
import fs from 'fs';
import { parse as parseDuration, Duration } from 'iso8601-duration';
import path from 'path';
import sanitizeWindowsName from 'sanitize-filename';
import { extractStreamGuids } from './Utils';
function publishedDateToString(date: string): string {
export function publishedDateToString(date: string): string {
const dateJs: Date = new Date(date);
const day: string = dateJs.getDate().toString().padStart(2, '0');
const month: string = (dateJs.getMonth() + 1).toString(10).padStart(2, '0');
@@ -18,29 +18,45 @@ function publishedDateToString(date: string): string {
return `${dateJs.getFullYear()}-${month}-${day}`;
}
export function publishedTimeToString(seconds: number): string
export function publishedTimeToString(date: string): string
export function publishedTimeToString(date: string | number): string {
let dateJs: Date;
if (typeof (date) === 'number') {
dateJs = new Date(0, 0, 0, 0, 0, date);
}
else {
dateJs = new Date(date);
}
function publishedTimeToString(date: string): string {
const dateJs: Date = new Date(date);
const hours: string = dateJs.getHours().toString();
const minutes: string = dateJs.getMinutes().toString();
const seconds: string = dateJs.getSeconds().toString();
return `${hours}.${minutes}.${seconds}`;
return `${hours}h ${minutes}m ${seconds}s`;
}
function isoDurationToString(time: string): string {
export function isoDurationToString(time: string): string {
const duration: Duration = parseDuration(time);
return `${duration.hours ?? '00'}.${duration.minutes ?? '00'}.${duration.seconds?.toFixed(0) ?? '00'}`;
}
// it's the number of seconds in the video
export 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 * 60) + (mins * 60) + secs;
}
export async function getStreamInfo(videoUrls: Array<VideoUrl>, session: StreamSession, subtitles?: boolean): Promise<Array<Video>> {
const metadata: Array<Video> = [];
let title: string;
let duration: string;
let publishDate: string;
@@ -48,19 +64,23 @@ export async function getVideosInfo(videoGuids: Array<string>,
let author: string;
let authorEmail: string;
let uniqueId: string;
let totalChunks: number;
let playbackUrl: string;
let posterImageUrl: string;
let captionsUrl: string | undefined;
const apiClient: ApiClient = ApiClient.getInstance(session);
const apiClient: StreamApiClient = StreamApiClient.getInstance(session);
/* See 'https://github.com/snobu/destreamer/pull/203' for API throttling mitigation */
for (const guid of videoGuids) {
// we place the guid in the url field
const videoGUIDs = await extractStreamGuids(videoUrls, 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 */
for (const guid of videoGUIDs) {
const response: AxiosResponse<any> | undefined =
await apiClient.callApi('videos/' + guid + '?$expand=creator', 'get');
await apiClient.callApi('videos/' + guid.url + '?$expand=creator', 'get');
title = sanitizeWindowsName(response?.data['name']);
@@ -74,7 +94,9 @@ export async function getVideosInfo(videoGuids: Array<string>,
authorEmail = response?.data['creator'].mail;
uniqueId = '#' + guid.split('-')[0];
uniqueId = '#' + guid.url.split('-')[0];
totalChunks = durationToTotalChunks(response?.data.media['duration']);
playbackUrl = response?.data['playbackUrls']
.filter((item: { [x: string]: string; }) =>
@@ -86,7 +108,7 @@ export async function getVideosInfo(videoGuids: Array<string>,
posterImageUrl = response?.data['posterImage']['medium']['url'];
if (subtitles) {
const captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid}/texttracks`, 'get');
const captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid.url}/texttracks`, 'get');
if (!captions?.data.value.length) {
captionsUrl = undefined;
@@ -104,21 +126,19 @@ export async function getVideosInfo(videoGuids: Array<string>,
}
metadata.push({
title: title,
duration: duration,
publishDate: publishDate,
publishTime: publishTime,
author: author,
authorEmail: authorEmail,
uniqueId: uniqueId,
// totalChunks: totalChunks, // Abstraction of FFmpeg timemark
playbackUrl: playbackUrl,
posterImageUrl: posterImageUrl,
captionsUrl: captionsUrl,
filename: '',
outPath: '',
guid: guid.url,
title,
duration,
publishDate,
publishTime,
author,
authorEmail,
uniqueId,
outPath: guid.outDir,
totalChunks, // Abstraction of FFmpeg timemark
playbackUrl,
posterImageUrl,
captionsUrl
});
}
@@ -126,17 +146,24 @@ export async function getVideosInfo(videoGuids: Array<string>,
}
export function createUniquePaths(videos: Array<Video>, outDirs: Array<string>,
template: string, format: string, skip?: boolean): Array<Video> {
export function createUniquePath(videos: Array<Video>, template: string, format: string, skip?: boolean): Array<Video>
export function createUniquePath(videos: Video, template: string, format: string, skip?: boolean): Video
export function createUniquePath(videos: Array<Video> | Video, template: string, format: string, skip?: boolean): Array<Video> | Video {
let singleInput = false;
videos.forEach((video: Video, index: number) => {
if (!Array.isArray(videos)) {
videos = [videos];
singleInput = true;
}
videos.forEach((video: Video) => {
let title: string = template;
let finalTitle: string;
const elementRegEx = RegExp(/{(.*?)}/g);
let match = elementRegEx.exec(template);
while (match) {
const 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);
}
@@ -144,24 +171,23 @@ export function createUniquePaths(videos: Array<Video>, outDirs: Array<string>,
let i = 0;
finalTitle = title;
while (!skip && fs.existsSync(path.join(outDirs[index], finalTitle + '.' + format))) {
while (!skip && fs.existsSync(path.join(video.outPath, finalTitle + '.' + format))) {
finalTitle = `${title}.${++i}`;
}
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);
video.outPath = path.join(video.outPath, finalFileName);
});
if (singleInput) {
return videos[0];
}
return videos;
}

View File

@@ -1,38 +1,23 @@
import { ApiClient } from './ApiClient';
import { argv, promptUser } from './CommandLineParser';
import { getDecrypter } from './Decrypter';
import { DownloadManager } from './DownloadManager';
import { argv } from './CommandLineParser';
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 { Video, Session } from './Types';
import { checkRequirements, parseInputFile, parseCLIinput, getUrlsFromPlaylist} from './Utils';
import { getVideosInfo, createUniquePaths } from './VideoUtils';
import { VideoUrl } from './Types';
import { checkRequirements, parseInputFile, parseCLIinput } from './Utils';
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 path from 'path';
import tmp from 'tmp';
import { downloadShareVideo, downloadStreamVideo } from './Downloaders';
// 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!
logger.level = argv.debug ? 'debug' : (argv.verbose ? 'verbose' : 'info');
if (argv.verbose) {
logger.level = 'verbose';
}
if (await isElevated()) {
process.exit(ERROR_CODE.ELEVATED_SHELL);
@@ -50,353 +35,31 @@ async function init(): Promise<void> {
}
async function DoInteractiveLogin(url: string, username?: string): Promise<Session> {
logger.info('Launching headless Chrome to perform the OpenID Connect dance...');
const browser: puppeteer.Browser = await puppeteer.launch({
executablePath: getPuppeteerChromiumPath(),
headless: false,
userDataDir: (argv.keepLoginCookies) ? chromeCacheFolder : undefined,
args: [
'--disable-dev-shm-usage',
'--fast-start',
'--no-sandbox'
]
});
const page: puppeteer.Page = (await browser.pages())[0];
logger.info('Navigating to login page...');
await page.goto(url, { waitUntil: 'load' });
try {
if (username) {
await page.waitForSelector('input[type="email"]', {timeout: 3000});
await page.keyboard.type(username);
await page.click('input[type="submit"]');
}
else {
/* If a username was not provided we let the user take actions that
lead up to the video page. */
}
}
catch (e) {
/* If there is no email input selector we aren't in the login module,
we are probably using the cache to aid the login.
It could finish the login on its own if the user said 'yes' when asked to
remember the credentials or it could still prompt the user for a password */
}
await browser.waitForTarget((target: puppeteer.Target) => target.url().endsWith('microsoftstream.com/'), { timeout: 150000 });
logger.info('We are logged in.');
let session: Session | null = null;
let tries = 1;
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) {
process.exit(ERROR_CODE.NO_SESSION_INFO);
}
session = null;
tries++;
await page.waitFor(3000);
}
}
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\n");
await browser.close();
return session;
}
async function downloadVideo(videoGUIDs: Array<string>,
outputDirectories: Array<string>, session: Session): Promise<void> {
const apiClient = ApiClient.getInstance(session);
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 => {
logger.info(
'\nTitle: '.green + video.title +
'\nOutPath: '.green + video.outPath +
'\nPublished Date: '.green + video.publishDate + ' ' + video.publishTime +
'\nPlayback URL: '.green + video.playbackUrl +
((video.captionsUrl) ? ('\nCC URL: '.green + video.captionsUrl) : '')
);
});
return;
}
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;
}
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);
}
masterParser.push(await apiClient.callUrl(video.playbackUrl).then(res => res?.data));
masterParser.end();
// 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'));
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;
}
// 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);
}
// video download
const videoSegmentsDir = tmp.dirSync({
prefix: 'video',
tmpdir: path.dirname(video.outPath),
unsafeCleanup: true
});
logger.info('\nDownloading video segments \n');
await downloadManager.downloadUrls(videoUrls, videoSegmentsDir.name);
// 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));
}
logger.info('\n\nMerging and decrypting video and audio segments...\n');
const cmd = (process.platform == 'win32') ? 'copy /b *.encr ' : 'cat *.encr > ';
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);
});
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'));
const decryptAudioPromise = new Promise(resolve => {
audioDecryptOutput.on('finish', resolve);
audioDecryptInput.pipe(audioDecrypter).pipe(audioDecryptOutput);
});
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
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' +
'\t API Gateway version: '.cyan + session.ApiGatewayVersion + '\n');
let videoGUIDs: Array<string>;
let outDirs: Array<string>;
let streamVideos: Array<VideoUrl>, shareVideos: Array<VideoUrl>;
if (argv.videoUrls) {
logger.info('Parsing video/group urls');
[videoGUIDs, outDirs] = await parseCLIinput(argv.videoUrls as Array<string>, argv.outputDirectory, session);
[streamVideos, shareVideos] = await parseCLIinput(argv.videoUrls as Array<string>, argv.outputDirectory);
}
else {
logger.info('Parsing input file');
[videoGUIDs, outDirs] = await parseInputFile(argv.inputFile!, argv.outputDirectory, session);
[streamVideos, shareVideos] = await parseInputFile(argv.inputFile!, argv.outputDirectory);
}
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(''));
logger.verbose(
'List of urls and corresponding output directory \n' +
streamVideos.map(video => `\t${video.url} => ${video.outDir} \n`).join('') +
shareVideos.map(video => `\t${video.url} => ${video.outDir} \n`).join('')
);
// fuck you bug, I WON!!!
await downloadVideo(videoGUIDs, outDirs, session);
if (streamVideos.length) {
await downloadStreamVideo(streamVideos);
}
if (shareVideos.length) {
await downloadShareVideo(shareVideos);
}
}

View File

@@ -1,32 +1,15 @@
import { parseInputFile } from '../src/Utils';
import puppeteer from 'puppeteer';
import { extractStreamGuids, parseInputFile } from '../src/Utils';
import assert from 'assert';
import tmp from 'tmp';
import fs from 'fs';
import { Session } from './Types';
describe('Puppeteer', () => {
it('should grab GitHub page title', async () => {
const browser = await puppeteer.launch({
headless: true,
args: ['--disable-dev-shm-usage', '--fast-start', '--no-sandbox']
});
const page = await browser.newPage();
await page.goto('https://github.com/', { waitUntil: 'load' });
let pageTitle = await page.title();
assert.equal(true, pageTitle.includes('GitHub'));
await browser.close();
}).timeout(30000); // yeah, this may take a while...
});
import { StreamSession, VideoUrl } from './Types';
// we cannot test groups parsing as that requires an actual session
// TODO: add SharePoint urls
describe('Destreamer parsing', () => {
it('Input file to arrays of URLs and DIRs', async () => {
const testSession: Session = {
it('Input file to arrays of guids', async () => {
const testSession: StreamSession = {
AccessToken: '',
ApiGatewayUri: '',
ApiGatewayVersion: ''
@@ -44,33 +27,42 @@ describe('Destreamer parsing', () => {
'https://web.microsoftstream.com/video/xxxxxx-gggg-xxxx-xxxx-xxxxxxxxxxxx',
''
];
const expectedGUIDsOut: Array<string> = [
'xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx',
'xxxxxxxx-bbbb-xxxx-xxxx-xxxxxxxxxxxx',
'xxxxxxxx-cccc-xxxx-xxxx-xxxxxxxxxxxx',
'xxxxxxxx-dddd-xxxx-xxxx-xxxxxxxxxxxx',
'xxxxxxxx-eeee-xxxx-xxxx-xxxxxxxxxxxx'
];
const expectedDirOut: Array<string> = [
'videos',
'luca',
'videos',
'videos',
'videos'
const expectedStreamOut: Array<VideoUrl> = [
{
url: 'xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx',
outDir: 'videos'
},
{
url: 'xxxxxxxx-bbbb-xxxx-xxxx-xxxxxxxxxxxx',
outDir: 'luca'
},
{
url: 'xxxxxxxx-cccc-xxxx-xxxx-xxxxxxxxxxxx',
outDir: 'videos'
},
{
url: 'xxxxxxxx-dddd-xxxx-xxxx-xxxxxxxxxxxx',
outDir: 'videos'
},
{
url: 'xxxxxxxx-eeee-xxxx-xxxx-xxxxxxxxxxxx',
outDir: 'videos'
},
];
const tmpFile = tmp.fileSync({ postfix: '.txt' });
fs.writeFileSync(tmpFile.fd, testIn.join('\r\n'));
const [testUrlOut , testDirOut]: Array<Array<string>> = await parseInputFile(tmpFile.name, 'videos', testSession);
if (testUrlOut.length !== expectedGUIDsOut.length) {
throw "Expected url list and test list don't have the same number of elements".red;
}
else if (testDirOut.length !== expectedDirOut.length) {
throw "Expected dir list and test list don't have the same number of elements".red;
}
assert.deepStrictEqual(testUrlOut, expectedGUIDsOut,
'Error in parsing the URLs, missmatch between test and expected'.red);
assert.deepStrictEqual(testUrlOut, expectedGUIDsOut,
'Error in parsing the DIRs, missmatch between test and expected'.red);
const [testStreamUrls]: Array<Array<VideoUrl>> = parseInputFile(tmpFile.name, 'videos');
assert.deepStrictEqual(
await extractStreamGuids(testStreamUrls, testSession),
expectedStreamOut,
'Error in parsing the URLs, missmatch between test and expected'.red
);
// assert.deepStrictEqual(testUrlOut, expectedGUIDsOut,
// 'Error in parsing the DIRs, missmatch between test and expected'.red);
assert.ok('Parsing of input file ok');
});
});