1
0
mirror of https://github.com/snobu/destreamer.git synced 2026-02-08 07:59:42 +00:00

63 Commits

Author SHA1 Message Date
Adrian Calinescu
516ca54ff1 Create trigger_codeQL 2024-06-11 14:36:38 +03:00
Adrian Calinescu
b6a3e8f1c3 Destreamer is abandoned 2024-04-01 13:36:40 +03: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
lukaarma
15c420333e Update report-trouble.md (#240) 2020-10-08 16:02:36 +03:00
lukaarma
22968f42ea Misc fixes (#244)
* cleared up docs

* fix 401 bug for images thumbnail
2020-10-08 16:00:28 +03:00
lukaarma
ec24ff9e1b Merge pull request #242 from rohit404404/patch-1
Update README.md
2020-10-03 21:00:46 +02:00
Rohit Devmore
389be33f74 Update README.md
made videos path clearer
2020-10-04 00:18:33 +05:30
Rohit Devmore
8848f293d7 Update README.md
made videos path clear
2020-10-04 00:07:40 +05:30
Adrian Calinescu
de6ab1d8af Fix syntax highlight 2020-09-17 12:12:54 +03:00
Adrian Calinescu
01014df068 Added README section on plugging in your own browser 2020-09-17 12:11:58 +03:00
Adrian Calinescu
f2b7a9ba96 Add help us pick a codename 2020-09-12 22:59:42 +03:00
Adrian Calinescu
b6c0dfe98d Update README.md 2020-09-12 22:58:51 +03:00
Adrian Calinescu
1fbe36629b Add v3 teaser 2020-09-12 22:33:20 +03:00
lukaarma
4545b010b3 Mino changes (#217)
* added Unipr info in the Specialized vesion section

* added response body on API call error (verbose)
2020-09-05 19:03:11 +03:00
beppe9000
7fc7c4733a fix lint problems (#207) 2020-08-17 23:37:51 +03:00
beppe9000
a9f8b02f08 fix typo & sanitize video names on win32 (#205)
* fix typo & sanitize video names on win32
* add warning for invalid path
2020-08-15 16:38:01 +03:00
Adrian Calinescu
5b62c50b22 Fix version number 2020-08-14 18:15:48 +03:00
17 changed files with 1541 additions and 1544 deletions

View File

@@ -7,6 +7,24 @@ assignees: ''
--- ---
## PLEASE NEVER PASTE YOUR ACCESS TOKEN INTO A GITHUB ISSUE AS IT MAY CONTAIN PRIVATE INFORMATION <!--
# BEFORE OPENING A NEW ISSUE CHECK THE EXISTING ONES AND RUN DESTREAMER WITH THE -v/--verbose flag and paste down below the output
# NEVER PASTE YOUR ACCESS TOKEN INTO A GITHUB ISSUE AS IT MAY CONTAIN PRIVATE INFORMATION
When you paste in output from destreamer, locate your access token (it looks like this: `Authorization: Bearer eyJ....<a lot more base64 encoded text>.....`) and redact it. When you paste in output from destreamer, locate your access token (it looks like this: `Authorization: Bearer eyJ....<a lot more base64 encoded text>.....`) and redact it.
# Please fill the form below to give us some more info.
-->
OS:
Launch command used:
<details>
<summary>Verbose log</summary>
```
PASTE VERBOSE LOG HERE
```
</details>

View File

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

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ node_modules
videos videos
release release
build build
yarn.lock

View File

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

View File

@@ -1,6 +1,10 @@
<a href="https://github.com/snobu/destreamer/actions"> # This project is abandoned. It will probably not work anymore against your MS Stream tenant.
<img src="https://github.com/snobu/destreamer/workflows/Node%20CI/badge.svg" alt="CI build status" />
</a> # A heartfelt thank you to all the contributors over the years. You are the real MVPs. 💖
## Check out kylon's Sharedown for a SharePoint-backend implementation - https://github.com/kylon/Sharedown
<hr>
![destreamer](assets/logo.png) ![destreamer](assets/logo.png)
@@ -8,15 +12,16 @@ _(Alternative artwork proposals are welcome! Submit one through an Issue.)_
# Saves Microsoft Stream videos for offline enjoyment # Saves Microsoft Stream videos for offline enjoyment
### v2.1 Release, codename _Hammer of Dawn<sup>TM</sup>_ ### v2 Release, codename _Hammer of Dawn<sup>TM</sup>_
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! 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 - [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 - [Università di Pisa][unipi]: fork over at https://github.com/Guray00/destreamer-unipi
- [Università della Calabria][unical]: fork over at https://github.com/peppelongo96/UnicalDown - [Università della Calabria][unical]: fork over at https://github.com/peppelongo96/UnicalDown
- [Università degli Studi di Parma][unipr]: fork over at https://github.com/vRuslan/destreamer-unipr
## What's new ## What's new
### v2.2 ### v2.2
@@ -35,7 +40,7 @@ Hopefully this doesn't break the end user agreement for Microsoft Stream. Since
## Prereqs ## 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 - **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). - [**ffmpeg**][ffmpeg]: a recent version (year 2019 or above), in `$PATH` or in the same directory as this README file (project root).
- [**git**][git]: one or more npm dependencies require git. - [**git**][git]: one or more npm dependencies require git.
@@ -51,6 +56,29 @@ Note that destreamer won't run in an elevated (Administrator/root) shell. Runnin
**WSL** (Windows Subsystem for Linux) is not supported as it can't easily pop up a browser window. It *may* work by installing an X Window server (like [Xming][xming]) and exporting the default display to it (`export DISPLAY=:0`) before running destreamer. See [this issue for more on WSL v1 and v2][wsl]. **WSL** (Windows Subsystem for Linux) is not supported as it can't easily pop up a browser window. It *may* work by installing an X Window server (like [Xming][xming]) and exporting the default display to it (`export DISPLAY=:0`) before running destreamer. See [this issue for more on WSL v1 and v2][wsl].
## 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` and `src/TokenCache.ts`:
```typescript
const browser: puppeteer.Browser = await puppeteer.launch({
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',
```
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.
Remember to rebuild (`npm run build`) every time you change this configuration.
## How to build ## How to build
To build destreamer clone this repository, install dependencies and run the build script - To build destreamer clone this repository, install dependencies and run the build script -
@@ -71,31 +99,35 @@ Options:
--help Show help [boolean] --help Show help [boolean]
--version Show version number [boolean] --version Show version number [boolean]
--username, -u The username used to log into Microsoft Stream (enabling this will fill in the email field for --username, -u The username used to log into Microsoft Stream (enabling this will fill in the email field for
you) [string] you). [string]
--videoUrls, -i List of video urls [array] --videoUrls, -i List of urls to videos or Microsoft Stream groups. [array]
--inputFile, -f Path to text file containing URLs and optionally outDirs. See the README for more on outDirs. --inputFile, -f Path to text file containing URLs and optionally outDirs. See the README for more on outDirs.
[string] [string]
--outputDirectory, -o The directory where destreamer will save your downloads. [string] [default: "videos"]
--outputTemplate, -t The template for the title. See the README for more info. --outputTemplate, -t The template for the title. See the README for more info.
[string] [default: "{title} - {publishDate} {uniqueId}"] [string] [default: "{title} - {publishDate} {uniqueId}"]
--outputDirectory, -o The directory where destreamer will save your downloads [string] [default: "videos"] --keepLoginCookies, -k Let Chromium cache identity provider cookies so you can use "Remember me" during login.
--keepLoginCookies, -k Let Chromium cache identity provider cookies so you can use "Remember me" during login Must be used every subsequent time you launch Destreamer if you want to log in automatically.
[boolean] [default: false] [boolean] [default: false]
--noExperiments, -x Do not attempt to render video thumbnails in the console [boolean] [default: false] --noExperiments, -x Do not attempt to render video thumbnails in the console. [boolean] [default: false]
--simulate, -s Disable video download and print metadata information to the console[boolean] [default: false] --simulate, -s Disable video download and print metadata information to the console.
--verbose, -v Print additional information to the console (use this before opening an issue on GitHub)
[boolean] [default: false] [boolean] [default: false]
--closedCaptions, --cc Check if closed captions are aviable and let the user choose which one to download (will not --verbose, -v Print additional information to the console (use this before opening an issue on GitHub).
ask if only one aviable) [boolean] [default: false] [boolean] [default: false]
--noCleanup, --nc Do not delete the downloaded video file when an FFmpeg error occurs [boolean] [default: false] --closedCaptions, --cc Check if closed captions are available and let the user choose which one to download (will not
ask if only one available). [boolean] [default: false]
--noCleanup, --nc Do not delete the downloaded video file when an FFmpeg error occurs.[boolean] [default: false]
--vcodec Re-encode video track. Specify FFmpeg codec (e.g. libx265) or set to "none" to disable video. --vcodec Re-encode video track. Specify FFmpeg codec (e.g. libx265) or set to "none" to disable video.
[string] [default: "copy"] [string] [default: "copy"]
--acodec Re-encode audio track. Specify FFmpeg codec (e.g. libopus) or set to "none" to disable audio. --acodec Re-encode audio track. Specify FFmpeg codec (e.g. libopus) or set to "none" to disable audio.
[string] [default: "copy"] [string] [default: "copy"]
--format Output container format (mkv, mp4, mov, anything that FFmpeg supports) --format Output container format (mkv, mp4, mov, anything that FFmpeg supports).
[string] [default: "mkv"] [string] [default: "mkv"]
--skip Skip download if file already exists [boolean] [default: false] --skip Skip download if file already exists. [boolean] [default: false]
``` ```
- both --videoUrls and --inputFile also accept Microsoft Teams Groups url so if your Organization placed the videos you are interested in a group you can copy the link and Destreamer will download all the videos it can inside it! A group url looks like this https://web.microsoftstream.com/group/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
- Passing `--username` is optional. It's there to make logging in faster (the username field will be populated automatically on the login form). - Passing `--username` is optional. It's there to make logging in faster (the username field will be populated automatically on the login form).
- You can use an absolute path for `-o` (output directory), for example `/mnt/videos`. - You can use an absolute path for `-o` (output directory), for example `/mnt/videos`.
@@ -140,13 +172,13 @@ These optional lines must start with white space(s).
Usage - Usage -
``` ```
https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx
-dir=videos/lessons/week1 -dir="videos/lessons/week1"
https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx https://web.microsoftstream.com/video/xxxxxxxx-aaaa-xxxx-xxxx-xxxxxxxxxxxx
-dir=videos/lessons/week2" -dir="videos/lessons/week2"
``` ```
### Title template ### 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}` 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}`
@@ -158,8 +190,20 @@ You can use one or more of the following magic sequence which will get substitut
- `authorEmail`: E-mail of video publisher - `authorEmail`: E-mail of video publisher
- `uniqueId`: An _unique-enough_ ID generated from the video metadata - `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: Input:
-t '{title} - {duration} - {publishDate} - {publishTime} - {author} - {authorEmail} - {uniqueId}' -t '{title} - {duration} - {publishDate} - {publishTime} - {author} - {authorEmail} - {uniqueId}'
@@ -177,7 +221,15 @@ iTerm2 on a Mac -
![screenshot](assets/screenshot-mac.png) ![screenshot](assets/screenshot-mac.png)
By default, downloads are saved under `videos/` unless specified by `-o` (output directory). 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 ## Contributing
@@ -196,3 +248,4 @@ Please open an [issue](https://github.com/snobu/destreamer/issues) and we'll loo
[polimi]: https://www.polimi.it [polimi]: https://www.polimi.it
[unipi]: https://www.unipi.it/ [unipi]: https://www.unipi.it/
[unical]: https://www.unical.it/portale/ [unical]: https://www.unical.it/portale/
[unipr]: https://www.unipr.it/

2705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,34 +17,34 @@
"author": "snobu", "author": "snobu",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/mocha": "^7.0.2", "@types/mocha": "^8.0.4",
"@types/puppeteer": "^1.20.4", "@types/puppeteer": "^5.4.0",
"@types/readline-sync": "^1.4.3", "@types/readline-sync": "^1.4.3",
"@types/tmp": "^0.1.0", "@types/tmp": "^0.2.0",
"@types/yargs": "^15.0.3", "@types/yargs": "^15.0.11",
"@typescript-eslint/eslint-plugin": "^2.25.0", "@typescript-eslint/eslint-plugin": "^4.9.0",
"@typescript-eslint/parser": "^2.25.0", "@typescript-eslint/parser": "^4.9.0",
"eslint": "^6.8.0", "eslint": "^7.14.0",
"mocha": "^7.1.1", "mocha": "^8.2.1",
"tmp": "^0.1.0" "tmp": "^0.2.1"
}, },
"dependencies": { "dependencies": {
"@tedconf/fessonia": "^2.1.0", "@tedconf/fessonia": "^2.1.2",
"@types/cli-progress": "^3.4.2", "@types/cli-progress": "^3.8.0",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"axios": "^0.19.2", "axios": "^0.21.2",
"axios-retry": "^3.1.8", "axios-retry": "^3.1.9",
"cli-progress": "^3.7.0", "cli-progress": "^3.8.2",
"colors": "^1.4.0", "colors": "^1.4.0",
"is-elevated": "^3.0.0", "is-elevated": "^3.0.0",
"iso8601-duration": "^1.2.0", "iso8601-duration": "^1.3.0",
"jwt-decode": "^2.2.0", "jwt-decode": "^3.1.2",
"puppeteer": "2.1.1", "puppeteer": "5.5.0",
"readline-sync": "^1.4.10", "readline-sync": "^1.4.10",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"terminal-image": "^1.0.1", "terminal-image": "^1.2.1",
"typescript": "^3.8.3", "typescript": "^4.1.2",
"winston": "^3.3.2", "winston": "^3.3.3",
"yargs": "^15.0.3" "yargs": "^16.1.1"
} }
} }

View File

@@ -34,6 +34,9 @@ export class ApiClient {
return true; return true;
} }
logger.warn(`Got HTTP code ${err?.response?.status ?? undefined}. Retrying request...`); logger.warn(`Got HTTP code ${err?.response?.status ?? undefined}. Retrying request...`);
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); const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);
@@ -42,6 +45,11 @@ export class ApiClient {
}); });
} }
/**
* Used to initialize/retrive the active ApiClient
*
* @param session used if initializing
*/
public static getInstance(session?: Session): ApiClient { public static getInstance(session?: Session): ApiClient {
if (!ApiClient.instance) { if (!ApiClient.instance) {
ApiClient.instance = new ApiClient(session); ApiClient.instance = new ApiClient(session);
@@ -50,6 +58,16 @@ export class ApiClient {
return ApiClient.instance; return ApiClient.instance;
} }
public setSession(session: Session): void {
if (!ApiClient.instance) {
logger.warn("Trying to update ApiCient session when it's not initialized!");
}
this.session = session;
return;
}
/** /**
* Call Microsoft Stream API. Base URL is sourced from * Call Microsoft Stream API. Base URL is sourced from
* the session object and prepended automatically. * the session object and prepended automatically.

View File

@@ -13,12 +13,12 @@ export const argv: any = yargs.options({
username: { username: {
alias: 'u', alias: 'u',
type: 'string', type: 'string',
describe: 'The username used to log into Microsoft Stream (enabling this will fill in the email field for you)', describe: 'The username used to log into Microsoft Stream (enabling this will fill in the email field for you).',
demandOption: false demandOption: false
}, },
videoUrls: { videoUrls: {
alias: 'i', alias: 'i',
describe: 'List of video urls', describe: 'List of urls to videos or Microsoft Stream groups.',
type: 'array', type: 'array',
demandOption: false demandOption: false
}, },
@@ -30,7 +30,7 @@ export const argv: any = yargs.options({
}, },
outputDirectory: { outputDirectory: {
alias: 'o', alias: 'o',
describe: 'The directory where destreamer will save your downloads', describe: 'The directory where destreamer will save your downloads.',
type: 'string', type: 'string',
default: 'videos', default: 'videos',
demandOption: false demandOption: false
@@ -44,42 +44,43 @@ export const argv: any = yargs.options({
}, },
keepLoginCookies: { keepLoginCookies: {
alias: 'k', alias: 'k',
describe: 'Let Chromium cache identity provider cookies so you can use "Remember me" during login', describe: 'Let Chromium cache identity provider cookies so you can use "Remember me" during login.\n' +
'Must be used every subsequent time you launch Destreamer if you want to log in automatically.',
type: 'boolean', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
}, },
noExperiments: { noExperiments: {
alias: 'x', alias: 'x',
describe: 'Do not attempt to render video thumbnails in the console', describe: 'Do not attempt to render video thumbnails in the console.',
type: 'boolean', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
}, },
simulate: { simulate: {
alias: 's', alias: 's',
describe: 'Disable video download and print metadata information to the console', describe: 'Disable video download and print metadata information to the console.',
type: 'boolean', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
}, },
verbose: { verbose: {
alias: 'v', alias: 'v',
describe: 'Print additional information to the console (use this before opening an issue on GitHub)', describe: 'Print additional information to the console (use this before opening an issue on GitHub).',
type: 'boolean', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
}, },
closedCaptions: { closedCaptions: {
alias: 'cc', alias: 'cc',
describe: 'Check if closed captions are aviable and let the user choose which one to download (will not ask if only one aviable)', 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', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
}, },
noCleanup: { noCleanup: {
alias: 'nc', alias: 'nc',
describe: 'Do not delete the downloaded video file when an FFmpeg error occurs', describe: 'Do not delete the downloaded video file when an FFmpeg error occurs.',
type: 'boolean', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
@@ -97,13 +98,13 @@ export const argv: any = yargs.options({
demandOption: false demandOption: false
}, },
format: { format: {
describe: 'Output container format (mkv, mp4, mov, anything that FFmpeg supports)', describe: 'Output container format (mkv, mp4, mov, anything that FFmpeg supports).',
type: 'string', type: 'string',
default: 'mkv', default: 'mkv',
demandOption: false demandOption: false
}, },
skip: { skip: {
describe: 'Skip download if file already exists', describe: 'Skip download if file already exists.',
type: 'boolean', type: 'boolean',
default: false, default: false,
demandOption: false demandOption: false
@@ -173,9 +174,8 @@ function checkInputConflicts(videoUrls: Array<string | number> | undefined,
function isOutputTemplateValid(argv: any): boolean { function isOutputTemplateValid(argv: any): boolean {
let finalTemplate: string = argv.outputTemplate;
const elementRegEx = RegExp(/{(.*?)}/g); const elementRegEx = RegExp(/{(.*?)}/g);
let match = elementRegEx.exec(finalTemplate); let match = elementRegEx.exec(argv.outputTemplate);
// if no template elements this fails // if no template elements this fails
if (match) { if (match) {
@@ -183,30 +183,25 @@ function isOutputTemplateValid(argv: any): boolean {
while (match) { while (match) {
if (!templateElements.includes(match[1])) { if (!templateElements.includes(match[1])) {
logger.error( logger.error(
`'${match[0]}' is not aviable as a template element \n` + `'${match[0]}' is not available as a template element \n` +
`Aviable templates elements: '${templateElements.join("', '")}' \n`, `Available templates elements: '${templateElements.join("', '")}' \n`,
{ fatal: true } { fatal: true }
); );
process.exit(1); 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; return true;
} }
export function promptUser(choices: Array<string>): number { export function promptUser(choices: Array<string>): number {
let index: number = readlineSync.keyInSelect(choices, 'Which resolution/format do you prefer?'); const index: number = readlineSync.keyInSelect(choices, 'Which resolution/format do you prefer?');
if (index === -1) { if (index === -1) {
process.exit(ERROR_CODE.CANCELLED_USER_INPUT); process.exit(ERROR_CODE.CANCELLED_USER_INPUT);

View File

@@ -3,6 +3,7 @@ export const enum ERROR_CODE {
ELEVATED_SHELL, ELEVATED_SHELL,
CANCELLED_USER_INPUT, CANCELLED_USER_INPUT,
MISSING_FFMPEG, MISSING_FFMPEG,
OUTDATED_FFMPEG,
UNK_FFMPEG_ERROR, UNK_FFMPEG_ERROR,
INVALID_VIDEO_GUID, INVALID_VIDEO_GUID,
NO_SESSION_INFO NO_SESSION_INFO
@@ -21,6 +22,9 @@ export const errors: {[key: number]: string} = {
[ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing!\n' + [ERROR_CODE.MISSING_FFMPEG]: 'FFmpeg is missing!\n' +
'Destreamer requires a fairly recent release of FFmpeg to download videos', 'Destreamer requires a fairly recent release of FFmpeg to download videos',
[ERROR_CODE.MISSING_FFMPEG]: 'The FFmpeg version currently installed is too old!\n' +
'Destreamer requires a fairly recent release of FFmpeg to download videos',
[ERROR_CODE.UNK_FFMPEG_ERROR]: 'Unknown FFmpeg error', [ERROR_CODE.UNK_FFMPEG_ERROR]: 'Unknown FFmpeg error',
[ERROR_CODE.INVALID_VIDEO_GUID]: 'Unable to get video GUID from URL', [ERROR_CODE.INVALID_VIDEO_GUID]: 'Unable to get video GUID from URL',

View File

@@ -8,7 +8,7 @@ import { AxiosResponse } from 'axios';
export async function drawThumbnail(posterImage: string, session: Session): Promise<void> { export async function drawThumbnail(posterImage: string, session: Session): Promise<void> {
const apiClient: ApiClient = ApiClient.getInstance(session); const apiClient: ApiClient = ApiClient.getInstance(session);
let thumbnail: Buffer = await apiClient.callUrl(posterImage, 'get', null, 'arraybuffer') const thumbnail: Buffer = await apiClient.callUrl(posterImage, 'get', null, 'arraybuffer')
.then((response: AxiosResponse<any> | undefined) => response?.data); .then((response: AxiosResponse<any> | undefined) => response?.data);
console.log(await terminalImage.buffer(thumbnail, { width: 70 } )); console.log(await terminalImage.buffer(thumbnail, { width: 70 } ));

View File

@@ -19,16 +19,16 @@ export class TokenCache {
return null; return null;
} }
let session: Session = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8')); const session: Session = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8'));
type Jwt = { type Jwt = {
[key: string]: any [key: string]: any
} }
const decodedJwt: Jwt = jwtDecode(session.AccessToken); const decodedJwt: Jwt = jwtDecode(session.AccessToken);
let now: number = Math.floor(Date.now() / 1000); const now: number = Math.floor(Date.now() / 1000);
let exp: number = decodedJwt['exp']; const exp: number = decodedJwt['exp'];
let timeLeft: number = exp - now; const timeLeft: number = exp - now;
if (timeLeft < 120) { if (timeLeft < 120) {
logger.warn('Access token has expired! \n'); logger.warn('Access token has expired! \n');
@@ -42,12 +42,13 @@ export class TokenCache {
} }
public Write(session: Session): void { public Write(session: Session): void {
let s: string = JSON.stringify(session, null, 4); const s: string = JSON.stringify(session, null, 4);
fs.writeFile('.token_cache', s, (err: any) => { fs.writeFile(this.tokenCacheFile, s, (err: any) => {
if (err) { if (err) {
return logger.error(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);
}); });
} }
} }

View File

@@ -16,7 +16,7 @@ export type Video = {
outPath: string; outPath: string;
totalChunks: number; // Abstraction of FFmpeg timemark totalChunks: number; // Abstraction of FFmpeg timemark
playbackUrl: string; playbackUrl: string;
posterImageUrl: string; posterImageUrl: string | null;
captionsUrl?: string captionsUrl?: string
} }

View File

@@ -22,9 +22,21 @@ async function extractGuids(url: string, client: ApiClient): Promise<Array<strin
else if (groupMatch) { else if (groupMatch) {
const videoNumber: number = await client.callApi(`groups/${groupMatch[1]}`, 'get') const videoNumber: number = await client.callApi(`groups/${groupMatch[1]}`, 'get')
.then((response: AxiosResponse<any> | undefined) => response?.data.metrics.videos); .then((response: AxiosResponse<any> | undefined) => response?.data.metrics.videos);
const result: Array<string> = [];
let result: Array<string> = await client.callApi(`groups/${groupMatch[1]}/videos?$top=${videoNumber}&$orderby=publishedDate asc`, 'get') // Anything above $top=100 results in 400 Bad Request
.then((response: AxiosResponse<any> | undefined) => response?.data.value.map((item: any) => item.id)); // Use $skip to skip the first 100 and get another 100 and so on
for (let index = 0; index <= Math.floor(videoNumber / 100); index++) {
const partial: Array<string> = await client.callApi(
`groups/${groupMatch[1]}/videos?$skip=${100 * index}&` +
'$top=100&$orderby=publishedDate asc', 'get')
.then(
(response: AxiosResponse<any> | undefined) =>
response?.data.value.map((item: any) => item.id)
);
result.push(...partial);
}
return result; return result;
} }
@@ -48,7 +60,7 @@ export async function parseCLIinput(urlList: Array<string>, defaultOutDir: strin
session: Session): Promise<Array<Array<string>>> { session: Session): Promise<Array<Array<string>>> {
const apiClient: ApiClient = ApiClient.getInstance(session); const apiClient: ApiClient = ApiClient.getInstance(session);
let guidList: Array<string> = []; const guidList: Array<string> = [];
for (const url of urlList) { for (const url of urlList) {
const guids: Array<string> | null = await extractGuids(url, apiClient); const guids: Array<string> | null = await extractGuids(url, apiClient);
@@ -85,8 +97,8 @@ export async function parseInputFile(inputFile: string, defaultOutDir: string,
.split(/\r?\n/); .split(/\r?\n/);
const apiClient: ApiClient = ApiClient.getInstance(session); const apiClient: ApiClient = ApiClient.getInstance(session);
let guidList: Array<string> = []; const guidList: Array<string> = [];
let outDirList: Array<string> = []; const outDirList: Array<string> = [];
// if the last line was an url set this // if the last line was an url set this
let foundUrl = false; let foundUrl = false;
@@ -101,7 +113,7 @@ export async function parseInputFile(inputFile: string, defaultOutDir: string,
// parse if line is option // parse if line is option
else if (line.includes('-dir')) { else if (line.includes('-dir')) {
if (foundUrl) { if (foundUrl) {
let outDir: string | null = parseOption('-dir', line); const outDir: string | null = parseOption('-dir', line);
if (outDir && checkOutDir(outDir)) { if (outDir && checkOutDir(outDir)) {
outDirList.push(...Array(guidList.length - outDirList.length) outDirList.push(...Array(guidList.length - outDirList.length)
@@ -181,7 +193,13 @@ export function checkOutDir(directory: string): boolean {
export function checkRequirements(): void { export function checkRequirements(): void {
try { try {
const copyrightYearRe = new RegExp(/\d{4}-(\d{4})/);
const ffmpegVer: string = execSync('ffmpeg -version').toString().split('\n')[0]; 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`); logger.verbose(`Using ${ffmpegVer}\n`);
} }
catch (e) { catch (e) {

View File

@@ -24,14 +24,14 @@ function publishedTimeToString(date: string): string {
const minutes: string = dateJs.getMinutes().toString(); const minutes: string = dateJs.getMinutes().toString();
const seconds: string = dateJs.getSeconds().toString(); const seconds: string = dateJs.getSeconds().toString();
return `${hours}:${minutes}:${seconds}`; return `${hours}.${minutes}.${seconds}`;
} }
function isoDurationToString(time: string): string { function isoDurationToString(time: string): string {
const duration: Duration = parseDuration(time); const duration: Duration = parseDuration(time);
return `${duration.hours ?? '00'}:${duration.minutes ?? '00'}:${duration.seconds?.toFixed(0) ?? '00'}`; return `${duration.hours ?? '00'}.${duration.minutes ?? '00'}.${duration.seconds?.toFixed(0) ?? '00'}`;
} }
@@ -46,7 +46,7 @@ function durationToTotalChunks(duration: string): number {
export async function getVideoInfo(videoGuids: Array<string>, session: Session, subtitles?: boolean): Promise<Array<Video>> { export async function getVideoInfo(videoGuids: Array<string>, session: Session, subtitles?: boolean): Promise<Array<Video>> {
let metadata: Array<Video> = []; const metadata: Array<Video> = [];
let title: string; let title: string;
let duration: string; let duration: string;
let publishDate: string; let publishDate: string;
@@ -65,7 +65,7 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
/* TODO: change this to a single guid at a time to ease our footprint on the /* 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 */ MSS servers or we get throttled after 10 sequential reqs */
for (const guid of videoGuids) { for (const guid of videoGuids) {
let response: AxiosResponse<any> | undefined = const response: AxiosResponse<any> | undefined =
await apiClient.callApi('videos/' + guid + '?$expand=creator', 'get'); await apiClient.callApi('videos/' + guid + '?$expand=creator', 'get');
title = sanitizeWindowsName(response?.data['name']); title = sanitizeWindowsName(response?.data['name']);
@@ -94,7 +94,7 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
posterImageUrl = response?.data['posterImage']['medium']['url']; posterImageUrl = response?.data['posterImage']['medium']['url'];
if (subtitles) { if (subtitles) {
let captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid}/texttracks`, 'get'); const captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid}/texttracks`, 'get');
if (!captions?.data.value.length) { if (!captions?.data.value.length) {
captionsUrl = undefined; captionsUrl = undefined;
@@ -140,7 +140,7 @@ export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, t
let match = elementRegEx.exec(template); let match = elementRegEx.exec(template);
while (match) { while (match) {
let value = video[match[1] as keyof Video] as string; const value = video[match[1] as keyof Video] as string;
title = title.replace(match[0], value); title = title.replace(match[0], value);
match = elementRegEx.exec(template); match = elementRegEx.exec(template);
} }
@@ -152,8 +152,14 @@ export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, t
finalTitle = `${title}.${++i}`; 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.`);
}
video.outPath = path.join(outDirs[index], finalFileName);
video.outPath = path.join(outDirs[index], finalTitle + '.' + format);
}); });
return videos; return videos;

View File

@@ -13,6 +13,7 @@ import cliProgress from 'cli-progress';
import fs from 'fs'; import fs from 'fs';
import isElevated from 'is-elevated'; import isElevated from 'is-elevated';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { ApiClient } from './ApiClient';
const { FFmpegCommand, FFmpegInput, FFmpegOutput } = require('@tedconf/fessonia')(); const { FFmpegCommand, FFmpegInput, FFmpegOutput } = require('@tedconf/fessonia')();
@@ -151,6 +152,7 @@ async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array
if (argv.keepLoginCookies && index !== 0) { if (argv.keepLoginCookies && index !== 0) {
logger.info('Trying to refresh token...'); logger.info('Trying to refresh token...');
session = await refreshSession('https://web.microsoftstream.com/video/' + videoGUIDs[index]); session = await refreshSession('https://web.microsoftstream.com/video/' + videoGUIDs[index]);
ApiClient.getInstance().setSession(session);
} }
const pbar: cliProgress.SingleBar = new cliProgress.SingleBar({ const pbar: cliProgress.SingleBar = new cliProgress.SingleBar({
@@ -183,8 +185,10 @@ async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array
const headers: string = 'Authorization: Bearer ' + session.AccessToken; const headers: string = 'Authorization: Bearer ' + session.AccessToken;
if (!argv.noExperiments) { if (!argv.noExperiments) {
if (video.posterImageUrl) {
await drawThumbnail(video.posterImageUrl, session); await drawThumbnail(video.posterImageUrl, session);
} }
}
const ffmpegInpt: any = new FFmpegInput(video.playbackUrl, new Map([ const ffmpegInpt: any = new FFmpegInput(video.playbackUrl, new Map([
['headers', headers] ['headers', headers]
@@ -268,6 +272,7 @@ async function main(): Promise<void> {
await init(); // must be first await init(); // must be first
let session: Session; let session: Session;
// eslint-disable-next-line prefer-const
session = tokenCache.Read() ?? await DoInteractiveLogin('https://web.microsoftstream.com/', argv.username); session = tokenCache.Read() ?? await DoInteractiveLogin('https://web.microsoftstream.com/', argv.username);
logger.verbose('Session and API info \n' + logger.verbose('Session and API info \n' +

1
trigger_codeQL Normal file
View File

@@ -0,0 +1 @@