mirror of
https://github.com/snobu/destreamer.git
synced 2026-03-17 17:35:57 +00:00
Compare commits
1 Commits
v3.0
...
api_thrott
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4a9934efd |
40
README.md
40
README.md
@@ -2,22 +2,13 @@
|
|||||||
<img src="https://github.com/snobu/destreamer/workflows/Node%20CI/badge.svg" alt="CI build status" />
|
<img src="https://github.com/snobu/destreamer/workflows/Node%20CI/badge.svg" alt="CI build status" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
# BREAKING
|
|
||||||
|
|
||||||
**destreamer v3.0** is just around the corner. Download speed improvement is astonishing and we have a never before seen photo from the design sessions:<br><br>
|
|
||||||

|
|
||||||
|
|
||||||
Help us pick a codename for the new release:<br><br>
|
|
||||||
<br><br>
|
|
||||||
Comment in this thread: https://github.com/snobu/destreamer/issues/223
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
_(Alternative artwork proposals are welcome! Submit one through an Issue.)_
|
_(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 Release, codename _Hammer of Dawn<sup>TM</sup>_
|
### v2.1 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!
|
||||||
|
|
||||||
@@ -26,7 +17,6 @@ This release would not have been possible without the code and time contributed
|
|||||||
- [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
|
||||||
@@ -61,33 +51,6 @@ 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`:
|
|
||||||
|
|
||||||
```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'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
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",
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that for Mac/Linux the path will look a little different but no other changes are necessary.
|
|
||||||
|
|
||||||
You need 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 -
|
||||||
@@ -233,4 +196,3 @@ 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/
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ 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.verbose(`Here is the error message: \n '${err.response?.data}`);
|
|
||||||
|
|
||||||
const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);
|
const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const argv: any = yargs.options({
|
|||||||
},
|
},
|
||||||
closedCaptions: {
|
closedCaptions: {
|
||||||
alias: 'cc',
|
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 aviable and let the user choose which one to download (will not ask if only one aviable)',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
demandOption: false
|
demandOption: false
|
||||||
@@ -183,8 +183,8 @@ 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 available as a template element \n` +
|
`'${match[0]}' is not aviable as a template element \n` +
|
||||||
`Available templates elements: '${templateElements.join("', '")}' \n`,
|
`Aviable templates elements: '${templateElements.join("', '")}' \n`,
|
||||||
{ fatal: true }
|
{ fatal: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,8 +45,8 @@ function durationToTotalChunks(duration: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getVideoInfo(videoGuids: Array<string>, session: Session, subtitles?: boolean): Promise<Array<Video>> {
|
export async function getVideoInfo(videoGuid: string, session: Session, subtitles?: boolean): Promise<Video> {
|
||||||
let metadata: Array<Video> = [];
|
// template elements
|
||||||
let title: string;
|
let title: string;
|
||||||
let duration: string;
|
let duration: string;
|
||||||
let publishDate: string;
|
let publishDate: string;
|
||||||
@@ -54,19 +54,19 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
|||||||
let author: string;
|
let author: string;
|
||||||
let authorEmail: string;
|
let authorEmail: string;
|
||||||
let uniqueId: string;
|
let uniqueId: string;
|
||||||
|
// final video path (here for consistency with typedef)
|
||||||
const outPath = '';
|
const outPath = '';
|
||||||
|
// ffmpeg magic (abstraction of FFmpeg timemark)
|
||||||
let totalChunks: number;
|
let totalChunks: number;
|
||||||
|
// various sources
|
||||||
let playbackUrl: string;
|
let playbackUrl: string;
|
||||||
let posterImageUrl: string;
|
let posterImageUrl: string;
|
||||||
let captionsUrl: string | undefined;
|
let captionsUrl: string | undefined;
|
||||||
|
|
||||||
const apiClient: ApiClient = ApiClient.getInstance(session);
|
const apiClient: ApiClient = ApiClient.getInstance(session);
|
||||||
|
|
||||||
/* TODO: change this to a single guid at a time to ease our footprint on the
|
|
||||||
MSS servers or we get throttled after 10 sequential reqs */
|
|
||||||
for (const guid of videoGuids) {
|
|
||||||
let response: AxiosResponse<any> | undefined =
|
let response: AxiosResponse<any> | undefined =
|
||||||
await apiClient.callApi('videos/' + guid + '?$expand=creator', 'get');
|
await apiClient.callApi('videos/' + videoGuid + '?$expand=creator', 'get');
|
||||||
|
|
||||||
title = sanitizeWindowsName(response?.data['name']);
|
title = sanitizeWindowsName(response?.data['name']);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
|||||||
|
|
||||||
authorEmail = response?.data['creator'].mail;
|
authorEmail = response?.data['creator'].mail;
|
||||||
|
|
||||||
uniqueId = '#' + guid.split('-')[0];
|
uniqueId = '#' + videoGuid.split('-')[0];
|
||||||
|
|
||||||
totalChunks = durationToTotalChunks(response?.data.media['duration']);
|
totalChunks = durationToTotalChunks(response?.data.media['duration']);
|
||||||
|
|
||||||
@@ -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');
|
let captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${videoGuid}/texttracks`, 'get');
|
||||||
|
|
||||||
if (!captions?.data.value.length) {
|
if (!captions?.data.value.length) {
|
||||||
captionsUrl = undefined;
|
captionsUrl = undefined;
|
||||||
@@ -111,7 +111,7 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.push({
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
publishDate: publishDate,
|
publishDate: publishDate,
|
||||||
@@ -120,20 +120,16 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
|
|||||||
authorEmail: authorEmail,
|
authorEmail: authorEmail,
|
||||||
uniqueId: uniqueId,
|
uniqueId: uniqueId,
|
||||||
outPath: outPath,
|
outPath: outPath,
|
||||||
totalChunks: totalChunks, // Abstraction of FFmpeg timemark
|
totalChunks: totalChunks,
|
||||||
playbackUrl: playbackUrl,
|
playbackUrl: playbackUrl,
|
||||||
posterImageUrl: posterImageUrl,
|
posterImageUrl: posterImageUrl,
|
||||||
captionsUrl: captionsUrl
|
captionsUrl: captionsUrl
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return metadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, template: string, format: string, skip?: boolean): Array<Video> {
|
export function createUniquePath(video: Video, outDir: string, template: string, format: string, skip?: boolean): Video {
|
||||||
|
|
||||||
videos.forEach((video: Video, index: number) => {
|
|
||||||
let title: string = template;
|
let title: string = template;
|
||||||
let finalTitle: string;
|
let finalTitle: string;
|
||||||
const elementRegEx = RegExp(/{(.*?)}/g);
|
const elementRegEx = RegExp(/{(.*?)}/g);
|
||||||
@@ -148,19 +144,11 @@ export function createUniquePath(videos: Array<Video>, outDirs: Array<string>, t
|
|||||||
let i = 0;
|
let i = 0;
|
||||||
finalTitle = title;
|
finalTitle = title;
|
||||||
|
|
||||||
while (!skip && fs.existsSync(path.join(outDirs[index], finalTitle + '.' + format))) {
|
while (!skip && fs.existsSync(path.join(outDir, finalTitle + '.' + format))) {
|
||||||
finalTitle = `${title}.${++i}`;
|
finalTitle = `${title}.${++i}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalFileName = `${finalTitle}.${format}`;
|
video.outPath = path.join(outDir, finalTitle + '.' + format);
|
||||||
const cleanFileName = sanitizeWindowsName(finalFileName, { replacement: '_' });
|
|
||||||
if (finalFileName !== cleanFileName) {
|
return video;
|
||||||
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);
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
return videos;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,16 +119,17 @@ async function DoInteractiveLogin(url: string, username?: string): Promise<Sessi
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array<string>, session: Session): Promise<void> {
|
async function downloadVideo(videoGuidArray: Array<string>, outputDirectoryArray: Array<string>, session: Session): Promise<void> {
|
||||||
|
|
||||||
logger.info('Fetching videos info... \n');
|
for (const [index, videoGuid] of videoGuidArray.entries()) {
|
||||||
const videos: Array<Video> = createUniquePath (
|
logger.info(`Fetching video's #${index} info... \n`);
|
||||||
await getVideoInfo(videoGUIDs, session, argv.closedCaptions),
|
|
||||||
outputDirectories, argv.outputTemplate, argv.format, argv.skip
|
const video: Video = createUniquePath (
|
||||||
|
await getVideoInfo(videoGuid, session, argv.closedCaptions),
|
||||||
|
outputDirectoryArray[index], argv.outputTemplate, argv.format, argv.skip
|
||||||
);
|
);
|
||||||
|
|
||||||
if (argv.simulate) {
|
if (argv.simulate) {
|
||||||
videos.forEach((video: Video) => {
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'\nTitle: '.green + video.title +
|
'\nTitle: '.green + video.title +
|
||||||
'\nOutPath: '.green + video.outPath +
|
'\nOutPath: '.green + video.outPath +
|
||||||
@@ -136,21 +137,19 @@ async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array
|
|||||||
'\nPlayback URL: '.green + video.playbackUrl +
|
'\nPlayback URL: '.green + video.playbackUrl +
|
||||||
((video.captionsUrl) ? ('\nCC URL: '.green + video.captionsUrl) : '')
|
((video.captionsUrl) ? ('\nCC URL: '.green + video.captionsUrl) : '')
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, video] of videos.entries()) {
|
|
||||||
|
|
||||||
if (argv.skip && fs.existsSync(video.outPath)) {
|
if (argv.skip && fs.existsSync(video.outPath)) {
|
||||||
logger.info(`File already exists, skipping: ${video.outPath} \n`);
|
logger.info(`File already exists, skipping: ${video.outPath} \n`);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
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/' + videoGuidArray[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pbar: cliProgress.SingleBar = new cliProgress.SingleBar({
|
const pbar: cliProgress.SingleBar = new cliProgress.SingleBar({
|
||||||
|
|||||||
Reference in New Issue
Block a user