Last active
January 15, 2021 02:28
-
-
Save iczero/e8b06c4bd2d6869852650debbb26c20f to your computer and use it in GitHub Desktop.
Script to get information and download mediasite videos
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
// @ts-check | |
// $ npm install got path-to-regexp yargs cli-progress | |
// put value of MediasiteAuth cookie in a file named mediasite-auth-cookie.txt | |
// in the same directory as this script | |
const fs = require('fs'); | |
const fsP = fs.promises; | |
const path = require('path'); | |
const childProcess = require('child_process'); | |
const got = require('got'); | |
const GOT_VERSION = require('got/package.json').version; | |
const pathToRegexp = require('path-to-regexp'); | |
const yargs = require('yargs'); | |
const cliProgress = require('cli-progress'); | |
const INFO_ENDPOINT = '/Mediasite/PlayerService/PlayerService.svc/json/GetPlayerOptions'; | |
/** @type {pathToRegexp.MatchFunction<{ id: string }>} */ | |
const parsePlayEndpoint = pathToRegexp.match('/Mediasite/Play/:id'); | |
const MPV_FORMAT_PRIORITY = ['video/x-mpeg-dash', 'video/mp4', 'audio/x-mpegurl']; | |
const SOURCE_URL = 'https://gist.github.com/iczero/e8b06c4bd2d6869852650debbb26c20f'; | |
const VERSION = '0.1.0'; | |
const USER_AGENT = `mediasite-fetch/${VERSION} (${process.platform}; +${SOURCE_URL}) Node.js/${process.version} got/${GOT_VERSION}`; | |
async function main() { | |
const argv = yargs(process.argv.slice(2)) | |
.usage('$0 <url>', 'Retrieve mediasite presentation', yargs => { | |
yargs | |
.positional('url', { | |
description: 'Presentation URL', | |
type: 'string' | |
}) | |
.option('verbose', { | |
description: 'Enable more logging', | |
alias: 'v', | |
type: 'boolean', | |
default: false | |
}) | |
.option('download', { | |
description: 'Enable downloading of videos', | |
alias: 'd', | |
type: 'boolean', | |
default: false | |
}) | |
.option('play', { | |
description: 'Open provided stream with mpv', | |
alias: 'p', | |
type: 'array' | |
}); | |
}).argv; | |
if (argv.play) argv.play = argv.play.map(Number).filter(n => !Number.isNaN(n)); | |
else argv.play = []; | |
let authCookie = (await fsP.readFile(path.join(__dirname, 'mediasite-auth-cookie.txt'))) | |
.toString() | |
.replace(/[\r\n]+$/, ''); | |
let url; | |
try { | |
url = new URL(/** @type {string} */ (argv.url)); | |
} catch (err) { | |
if (err.code === 'ERR_INVALID_URL') { | |
console.error('error: invalid url provided'); | |
} else console.error('error: parsing url:', err); | |
return 1; | |
} | |
let parsed = parsePlayEndpoint(url.pathname); | |
if (!parsed) { | |
console.error('error: invalid url', url.toString()); | |
return 1; | |
} | |
let resourceId = parsed.params.id; | |
console.log('fetching resource', resourceId); | |
let infoEndpoint = new URL(INFO_ENDPOINT, url.origin); | |
let result = await got.post(infoEndpoint, { | |
headers: { | |
'Accept': 'application/json', | |
'Content-Type': 'application/json', | |
'User-Agent': USER_AGENT, | |
'Cookie': 'MediasiteAuth=' + authCookie // pure laziness | |
}, | |
json: { | |
getPlayerOptionsRequest: { | |
ResourceId: resourceId, | |
QueryString: '', | |
UseScreenReader: false, | |
UrlReferrer: '' | |
} | |
} | |
}).json(); | |
if (argv.verbose) console.log('response:', result.d || result); | |
if (!result.d) { | |
console.error('malformed response!'); | |
return 1; | |
} | |
let data = result.d; | |
if (data.PlayerPresentationStatus === 6) { | |
console.error('error: not authorized'); | |
console.error('error: your cookie may be invalid'); | |
} else if (data.PlayerPresentationStatus !== 1) { | |
console.warn('warning: unknown PlayerPresentationStatus', data.PlayerPresentationStatus); | |
} | |
if (data.PlayerPresentationStatusMessage) { | |
console.log('message from server:', data.PlayerPresentationStatusMessage); | |
} | |
let presentation = data.Presentation; | |
if (!presentation) { | |
console.error('error: no presentation found'); | |
return 1; | |
} | |
console.log(''); | |
console.log('title:', presentation.Title); | |
console.log('description:', presentation.Description); | |
console.log('date:', new Date(presentation.UnixTime).toString()); | |
console.log(''); | |
let streams = data.Presentation.Streams; | |
if (argv.verbose) console.log('streams:', streams); | |
if (!streams || !streams.length) { | |
console.error('error: no streams found'); | |
return 1; | |
} | |
/** @type {{ filename: string, url: string }[]} */ | |
let toDownload = []; | |
let digitsLength = Math.floor(Math.log10(streams.length)) + 1; | |
for (let [i, stream] of streams.entries()) { | |
console.group(`Stream ${i}`); | |
if (stream.HasSlideContent) { | |
console.log('stream has slides'); | |
console.log(`slides url: ${stream.SlideBaseUrl}${stream.SlideImageFileNameTemplate}` + | |
`?playbackTicket=${stream.SlidePlaybackTicketId}`); | |
if (stream.Slides.length > 0) { | |
console.log(`slide numbers: ${stream.Slides[0].Number}..${stream.Slides[stream.Slides.length - 1].Number}`); | |
} else { | |
console.log('no slides in slides array?'); | |
} | |
console.log(''); | |
} | |
let types = new Map(); | |
for (let videoSource of stream.VideoUrls) types.set(videoSource.MimeType, videoSource.Location); | |
for (let [mime, url] of types) console.log(`mime: ${mime}, url: ${url}`); | |
if (argv.download) { | |
console.log(''); | |
let url = types.get('video/mp4'); | |
if (url) { | |
let filename = `${presentation.Title}-s${i.toString().padStart(digitsLength, '0')}-${presentation.PresentationId}.mp4`; | |
filename = filename.replace(/\//g, '_'); | |
if (process.platform === 'win32') filename = filename.replace(/[<>:"\\|?*]/g, '_'); | |
console.log(`will download [${filename}]`); | |
toDownload.push({ filename, url }); | |
} else { | |
console.log('warning: no media type video/mp4 found, will not download'); | |
} | |
} | |
if (argv.play.includes(i)) { | |
let targetFormat = null; | |
let url = null; | |
for (let format of MPV_FORMAT_PRIORITY) { | |
if (types.has(format)) { | |
targetFormat = format; | |
url = types.get(format); | |
break; | |
} | |
} | |
if (!url) { | |
console.warn('\nwarning: no compatible formats found for playback'); | |
console.warn('warning: available formats are', [...types.keys()]); | |
} else { | |
console.log('\nstarting mpv for format', targetFormat); | |
childProcess.spawn('mpv', ['--', url], { | |
stdio: ['ignore', 'inherit', 'inherit'], | |
detached: true | |
}); | |
} | |
} | |
console.groupEnd(); | |
console.log(''); | |
} | |
if (argv.download) await download(toDownload); | |
return 0; | |
} | |
/** | |
* Download multiple files | |
* @param {{ filename: string, url: string }[]} urls | |
*/ | |
async function download(urls) { | |
console.log(''); | |
console.log(`downloading ${urls.length} file(s)...`); | |
let bar = new cliProgress.MultiBar({ | |
format: '{filename} | [{bar}] {percentage}% | {value}/{total}', | |
hideCursor: false, | |
clearOnComplete: false | |
}); | |
/** @type {Promise<void>[]} */ | |
let promises = []; | |
for (let { filename, url } of urls) { | |
let progress = bar.create(1, 0, { filename }); | |
let writeStream = fs.createWriteStream(filename); | |
let source = got.stream(url, { headers: { 'User-Agent': USER_AGENT } }); | |
source.on('downloadProgress', s => { | |
progress.update(s.transferred); | |
progress.setTotal(s.total); | |
}); | |
source.pipe(writeStream); | |
promises.push(new Promise((resolve, reject) => { | |
writeStream.on('finish', resolve); | |
writeStream.on('error', reject); | |
source.on('error', reject); | |
})); | |
} | |
await Promise.all(promises); | |
bar.stop(); | |
console.log('\nDone'); | |
} | |
main() | |
.then(code => process.exit(code)) | |
.catch(err => console.error('error: uncaught exception:', err)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment