1
0
mirror of https://github.com/snobu/destreamer.git synced 2026-03-16 17:05:56 +00:00

1 Commits

Author SHA1 Message Date
Luca Armaroli
f4a9934efd videoInfo fetching per videorather then in bulk
fixed side effects in main function of this change
2020-08-12 18:45:14 +01:00
5 changed files with 85 additions and 137 deletions

View File

@@ -2,22 +2,13 @@
<img src="https://github.com/snobu/destreamer/workflows/Node%20CI/badge.svg" alt="CI build status" />
</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>
![desilva](https://user-images.githubusercontent.com/6472374/93003437-54a7fd00-f547-11ea-8473-e4602993e69d.jpg)
Help us pick a codename for the new release:<br><br>
![codename](https://user-images.githubusercontent.com/6472374/93003896-20ced680-f54b-11ea-8be1-2c14e0bd3751.png)<br><br>
Comment in this thread: https://github.com/snobu/destreamer/issues/223
![destreamer](assets/logo.png)
_(Alternative artwork proposals are welcome! Submit one through an Issue.)_
# 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!
@@ -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
- [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à degli Studi di Parma][unipr]: fork over at https://github.com/vRuslan/destreamer-unipr
## What's new
### 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].
## 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
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
[unipi]: https://www.unipi.it/
[unical]: https://www.unical.it/portale/
[unipr]: https://www.unipr.it/

View File

@@ -34,7 +34,6 @@ export class ApiClient {
return true;
}
logger.warn(`Got HTTP code ${err?.response?.status ?? undefined}. Retrying request...`);
logger.verbose(`Here is the error message: \n '${err.response?.data}`);
const shouldRetry: boolean = retryCodes.includes(err?.response?.status ?? 0);

View File

@@ -72,7 +72,7 @@ export const argv: any = yargs.options({
},
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 aviable and let the user choose which one to download (will not ask if only one aviable)',
type: 'boolean',
default: false,
demandOption: false
@@ -183,8 +183,8 @@ function isOutputTemplateValid(argv: any): boolean {
while (match) {
if (!templateElements.includes(match[1])) {
logger.error(
`'${match[0]}' is not available as a template element \n` +
`Available templates elements: '${templateElements.join("', '")}' \n`,
`'${match[0]}' is not aviable as a template element \n` +
`Aviable templates elements: '${templateElements.join("', '")}' \n`,
{ fatal: true }
);

View File

@@ -24,14 +24,14 @@ function publishedTimeToString(date: string): string {
const minutes: string = dateJs.getMinutes().toString();
const seconds: string = dateJs.getSeconds().toString();
return `${hours}.${minutes}.${seconds}`;
return `${hours}:${minutes}:${seconds}`;
}
function isoDurationToString(time: string): string {
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>> {
let metadata: Array<Video> = [];
export async function getVideoInfo(videoGuid: string, session: Session, subtitles?: boolean): Promise<Video> {
// template elements
let title: string;
let duration: string;
let publishDate: string;
@@ -54,113 +54,101 @@ export async function getVideoInfo(videoGuids: Array<string>, session: Session,
let author: string;
let authorEmail: string;
let uniqueId: string;
// final video path (here for consistency with typedef)
const outPath = '';
// ffmpeg magic (abstraction of FFmpeg timemark)
let totalChunks: number;
// various sources
let playbackUrl: string;
let posterImageUrl: string;
let captionsUrl: string | undefined;
const apiClient: ApiClient = ApiClient.getInstance(session);
/* TODO: change this to a single guid at a time to ease our footprint on the
MSS servers or we get throttled after 10 sequential reqs */
for (const guid of videoGuids) {
let response: AxiosResponse<any> | undefined =
await apiClient.callApi('videos/' + guid + '?$expand=creator', 'get');
let response: AxiosResponse<any> | undefined =
await apiClient.callApi('videos/' + videoGuid + '?$expand=creator', 'get');
title = sanitizeWindowsName(response?.data['name']);
title = sanitizeWindowsName(response?.data['name']);
duration = isoDurationToString(response?.data.media['duration']);
duration = isoDurationToString(response?.data.media['duration']);
publishDate = publishedDateToString(response?.data['publishedDate']);
publishDate = publishedDateToString(response?.data['publishedDate']);
publishTime = publishedTimeToString(response?.data['publishedDate']);
publishTime = publishedTimeToString(response?.data['publishedDate']);
author = response?.data['creator'].name;
author = response?.data['creator'].name;
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']);
playbackUrl = response?.data['playbackUrls']
.filter((item: { [x: string]: string; }) =>
item['mimeType'] == 'application/vnd.apple.mpegurl')
.map((item: { [x: string]: string }) => {
return item['playbackUrl'];
})[0];
playbackUrl = response?.data['playbackUrls']
.filter((item: { [x: string]: string; }) =>
item['mimeType'] == 'application/vnd.apple.mpegurl')
.map((item: { [x: string]: string }) => {
return item['playbackUrl'];
})[0];
posterImageUrl = response?.data['posterImage']['medium']['url'];
posterImageUrl = response?.data['posterImage']['medium']['url'];
if (subtitles) {
let captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${guid}/texttracks`, 'get');
if (subtitles) {
let captions: AxiosResponse<any> | undefined = await apiClient.callApi(`videos/${videoGuid}/texttracks`, 'get');
if (!captions?.data.value.length) {
captionsUrl = undefined;
}
else if (captions?.data.value.length === 1) {
logger.info(`Found subtitles for ${title}. \n`);
captionsUrl = captions?.data.value.pop().url;
}
else {
const index: number = promptUser(captions.data.value.map((item: { language: string; autoGenerated: string; }) => {
return `[${item.language}] autogenerated: ${item.autoGenerated}`;
}));
captionsUrl = captions.data.value[index].url;
}
if (!captions?.data.value.length) {
captionsUrl = undefined;
}
else if (captions?.data.value.length === 1) {
logger.info(`Found subtitles for ${title}. \n`);
captionsUrl = captions?.data.value.pop().url;
}
else {
const index: number = promptUser(captions.data.value.map((item: { language: string; autoGenerated: string; }) => {
return `[${item.language}] autogenerated: ${item.autoGenerated}`;
}));
captionsUrl = captions.data.value[index].url;
}
metadata.push({
title: title,
duration: duration,
publishDate: publishDate,
publishTime: publishTime,
author: author,
authorEmail: authorEmail,
uniqueId: uniqueId,
outPath: outPath,
totalChunks: totalChunks, // Abstraction of FFmpeg timemark
playbackUrl: playbackUrl,
posterImageUrl: posterImageUrl,
captionsUrl: captionsUrl
});
}
return metadata;
return {
title: title,
duration: duration,
publishDate: publishDate,
publishTime: publishTime,
author: author,
authorEmail: authorEmail,
uniqueId: uniqueId,
outPath: outPath,
totalChunks: totalChunks,
playbackUrl: playbackUrl,
posterImageUrl: posterImageUrl,
captionsUrl: captionsUrl
};
}
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 finalTitle: string;
const elementRegEx = RegExp(/{(.*?)}/g);
let match = elementRegEx.exec(template);
let title: string = template;
let finalTitle: string;
const elementRegEx = RegExp(/{(.*?)}/g);
let match = elementRegEx.exec(template);
while (match) {
let value = video[match[1] as keyof Video] as string;
title = title.replace(match[0], value);
match = elementRegEx.exec(template);
}
while (match) {
let value = video[match[1] as keyof Video] as string;
title = title.replace(match[0], value);
match = elementRegEx.exec(template);
}
let i = 0;
finalTitle = title;
let i = 0;
finalTitle = title;
while (!skip && fs.existsSync(path.join(outDirs[index], finalTitle + '.' + format))) {
finalTitle = `${title}.${++i}`;
}
while (!skip && fs.existsSync(path.join(outDir, 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.`);
}
video.outPath = path.join(outDir, finalTitle + '.' + format);
video.outPath = path.join(outDirs[index], finalFileName);
});
return videos;
return video;
}

View File

@@ -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');
const videos: Array<Video> = createUniquePath (
await getVideoInfo(videoGUIDs, session, argv.closedCaptions),
outputDirectories, argv.outputTemplate, argv.format, argv.skip
for (const [index, videoGuid] of videoGuidArray.entries()) {
logger.info(`Fetching video's #${index} info... \n`);
const video: Video = createUniquePath (
await getVideoInfo(videoGuid, session, argv.closedCaptions),
outputDirectoryArray[index], argv.outputTemplate, argv.format, argv.skip
);
if (argv.simulate) {
videos.forEach((video: Video) => {
if (argv.simulate) {
logger.info(
'\nTitle: '.green + video.title +
'\nOutPath: '.green + video.outPath +
@@ -136,21 +137,19 @@ async function downloadVideo(videoGUIDs: Array<string>, outputDirectories: Array
'\nPlayback URL: '.green + video.playbackUrl +
((video.captionsUrl) ? ('\nCC URL: '.green + video.captionsUrl) : '')
);
});
return;
}
for (const [index, video] of videos.entries()) {
continue;
}
if (argv.skip && fs.existsSync(video.outPath)) {
logger.info(`File already exists, skipping: ${video.outPath} \n`);
continue;
}
if (argv.keepLoginCookies && index !== 0) {
logger.info('Trying to refresh token...');
session = await refreshSession('https://web.microsoftstream.com/video/' + videoGUIDs[index]);
session = await refreshSession('https://web.microsoftstream.com/video/' + videoGuidArray[index]);
}
const pbar: cliProgress.SingleBar = new cliProgress.SingleBar({