// ==UserScript==
// @name Amazon Video - subtitle downloader
// @description Allows you to download subtitles from Amazon Video
// @license MIT
// @version 1.9.14
// @namespace tithen-firion.github.io
// @match https://*.amazon.com/*
// @match https://*.amazon.de/*
// @match https://*.amazon.co.uk/*
// @match https://*.amazon.co.jp/*
// @match https://*.primevideo.com/*
// @grant unsafeWindow
// @grant GM.xmlHttpRequest
// @grant GM_xmlhttpRequest
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @require https://cdn.jsdelivr.net/gh/Tithen-Firion/UserScripts@7bd6406c0d264d60428cfea16248ecfb4753e5e3/libraries/xhrHijacker.js?version=1.0
// @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5
// @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29
// ==/UserScript==
class ProgressBar {
constructor() {
let container = document.querySelector('#userscript_progress_bars');
if(container === null) {
container = document.createElement('div');
container.id = 'userscript_progress_bars'
document.body.appendChild(container)
container.style
container.style.position = 'fixed';
container.style.top = 0;
container.style.left = 0;
container.style.width = '100%';
container.style.background = 'red';
container.style.zIndex = '99999999';
}
self.container = container;
}
init() {
this.current = 0;
this.max = 0;
this.progressElement = document.createElement('div');
this.progressElement.style.width = 0;
this.progressElement.style.height = '10px';
this.progressElement.style.background = 'green';
self.container.appendChild(this.progressElement);
}
increment() {
this.current += 1;
if(this.current <= this.max)
this.progressElement.style.width = this.current / this.max * 100 + '%';
}
incrementMax() {
this.max += 1;
if(this.current <= this.max)
this.progressElement.style.width = this.current / this.max * 100 + '%';
}
destroy() {
this.progressElement.remove();
}
}
var progressBar = new ProgressBar();
// add CSS style
var s = document.createElement('style');
s.innerHTML = `
p.download {
text-align: center;
grid-column: 1/-1;
}
p.download:hover {
cursor: pointer;
}
`;
document.head.appendChild(s);
// XML to SRT
function parseTTMLLine(line, parentStyle, styles) {
const topStyle = line.getAttribute('style') || parentStyle;
let prefix = '';
let suffix = '';
let italic = line.getAttribute('tts:fontStyle') === 'italic';
let bold = line.getAttribute('tts:fontWeight') === 'bold';
let ruby = line.getAttribute('tts:ruby') === 'text';
if(topStyle !== null) {
italic = italic || styles[topStyle][0];
bold = bold || styles[topStyle][1];
ruby = ruby || styles[topStyle][2];
}
if(italic) {
prefix = '<i>';
suffix = '</i>';
}
if(bold) {
prefix += '<b>';
suffix = '</b>' + suffix;
}
if(ruby) {
prefix += '(';
suffix = ')' + suffix;
}
let result = '';
for(const node of line.childNodes) {
if(node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.split(':').pop().toUpperCase();
if(tagName === 'BR') {
result += '\n';
}
else if(tagName === 'SPAN') {
result += parseTTMLLine(node, topStyle, styles);
}
else {
console.log('unknown node:', node);
throw 'unknown node';
}
}
else if(node.nodeType === Node.TEXT_NODE) {
result += prefix + node.textContent + suffix;
}
}
return result;
}
function xmlToSrt(xmlString, lang) {
try {
let parser = new DOMParser();
var xmlDoc = parser.parseFromString(xmlString, 'text/xml');
const styles = {};
for(const style of xmlDoc.querySelectorAll('head styling style')) {
const id = style.getAttribute('xml:id');
if(id === null) throw "style ID not found";
const italic = style.getAttribute('tts:fontStyle') === 'italic';
const bold = style.getAttribute('tts:fontWeight') === 'bold';
const ruby = style.getAttribute('tts:ruby') === 'text';
styles[id] = [italic, bold, ruby];
}
const regionsTop = {};
for(const style of xmlDoc.querySelectorAll('head layout region')) {
const id = style.getAttribute('xml:id');
if(id === null) throw "style ID not found";
const origin = style.getAttribute('tts:origin') || "0% 80%";
const position = parseInt(origin.match(/\s(\d+)%/)[1]);
regionsTop[id] = position < 50;
}
const topStyle = xmlDoc.querySelector('body').getAttribute('style');
console.log(topStyle, styles, regionsTop);
const lines = [];
const textarea = document.createElement('textarea');
let i = 0;
for(const line of xmlDoc.querySelectorAll('body p')) {
let parsedLine = parseTTMLLine(line, topStyle, styles);
if(parsedLine != '') {
if(lang.indexOf('ar') == 0)
parsedLine = parsedLine.replace(/^(?!\u202B|\u200F)/gm, '\u202B');
textarea.innerHTML = parsedLine;
parsedLine = textarea.value;
parsedLine = parsedLine.replace(/\n{2,}/g, '\n');
const region = line.getAttribute('region');
if(regionsTop[region] === true) {
parsedLine = '{\\an8}' + parsedLine;
}
lines.push(++i);
lines.push((line.getAttribute('begin') + ' --> ' + line.getAttribute('end')).replace(/\./g,','));
lines.push(parsedLine);
lines.push('');
}
}
return lines.join('\n');
}
catch(e) {
console.error(e);
alert('Failed to parse XML subtitle file, see browser console for more details');
return null;
}
}
function sanitizeTitle(title) {
return title.replace(/[:*?"<>|\\\/]+/g, '_').replace(/ /g, '.');
}
// download subs and save them
function downloadSubs(url, title, downloadVars, lang) {
GM.xmlHttpRequest({
url: url,
method: 'get',
onload: function(resp) {
progressBar.increment();
var srt = xmlToSrt(resp.responseText, lang);
if(srt === null) {
srt = resp.responseText;
title = title.replace(/\.[^\.]+$/, '.ttml2');
}
if(downloadVars) {
downloadVars.zip.file(title, srt);
--downloadVars.subCounter;
if((downloadVars.subCounter|downloadVars.infoCounter) === 0)
downloadVars.zip.generateAsync({type:"blob"})
.then(function(content) {
saveAs(content, sanitizeTitle(downloadVars.title) + '.zip');
progressBar.destroy();
});
}
else {
var blob = new Blob([srt], {type: 'text/plain;charset=utf-8'});
saveAs(blob, title, true);
progressBar.destroy();
}
}
});
}
// download episodes/movie info and start downloading subs
function downloadInfo(url, downloadVars) {
var req = new XMLHttpRequest();
req.open('get', url);
req.withCredentials = true;
req.onload = function() {
var info = JSON.parse(req.response);
try {
var catalogMetadata = info.catalogMetadata;
if(typeof catalogMetadata === 'undefined')
catalogMetadata = {catalog:{type: 'MOVIE', title: info.returnedTitleRendition.asin}};
var epInfo = catalogMetadata.catalog;
var ep = epInfo.episodeNumber;
var title, season;
if(epInfo.type == 'MOVIE' || ep === 0) {
title = epInfo.title;
downloadVars.title = title;
}
else {
info.catalogMetadata.family.tvAncestors.forEach(function(tvAncestor) {
switch(tvAncestor.catalog.type) {
case 'SEASON':
season = tvAncestor.catalog.seasonNumber;
break;
case 'SHOW':
title = tvAncestor.catalog.title;
break;
}
});
title += '.S' + season.toString().padStart(2, '0');
if(downloadVars.type === 'all')
downloadVars.title = title;
title += 'E' + ep.toString().padStart(2, '0');
if(downloadVars.type === 'one')
downloadVars.title = title;
title += '.' + epInfo.title;
}
title = sanitizeTitle(title);
title += '.WEBRip.Amazon.';
var languages = new Set();
var forced = info.forcedNarratives || [];
forced.forEach(function(forcedInfo) {
forcedInfo.languageCode += '-forced';
});
var subs = (info.subtitleUrls || []).concat(forced);
subs.forEach(function(subInfo) {
let lang = subInfo.languageCode;
if(subInfo.type === 'subtitle' || subInfo.type === 'subtitle') {}
else if(subInfo.type === 'shd')
lang += '[cc]';
else
lang += `[${subInfo.type}]`;
if(languages.has(lang)) {
let index = 0;
let newLang;
do {
newLang = `${lang}_${++index}`;
} while(languages.has(newLang));
lang = newLang;
}
languages.add(lang);
++downloadVars.subCounter;
progressBar.incrementMax();
downloadSubs(subInfo.url, title + lang + '.srt', downloadVars, lang);
});
}
catch(e) {
console.log(info);
alert(e);
}
if(--downloadVars.infoCounter === 0 && downloadVars.subCounter === 0) {
alert("No subs found, make sure you're logged in and you have access to watch this video!");
progressBar.destroy();
}
};
req.send(null);
}
function downloadThis(e) {
progressBar.init();
var id = e.target.getAttribute('data-id');
var downloadVars = {
type: 'one',
subCounter: 0,
infoCounter: 1,
zip: new JSZip()
};
downloadInfo(gUrl + id, downloadVars);
}
function downloadAll(e) {
progressBar.init();
var IDs = e.target.getAttribute('data-id').split(';');
var downloadVars = {
type: 'all',
subCounter: 0,
infoCounter: IDs.length,
zip: new JSZip()
};
IDs.forEach(function(id) {
downloadInfo(gUrl + id, downloadVars);
});
}
// remove unnecessary parameters from URL
function parseURL(url) {
var filter = ['consumptionType', 'deviceID', 'deviceTypeID', 'firmware', 'gascEnabled', 'marketplaceID', 'userWatchSessionId', 'videoMaterialType', 'clientId', 'operatingSystemName', 'operatingSystemVersion', 'customerID', 'token'];
var urlParts = url.split('?');
var params = ['desiredResources=CatalogMetadata%2CSubtitleUrls%2CForcedNarratives'];
urlParts[1].split('&').forEach(function(param) {
var p = param.split('=');
if(filter.indexOf(p[0]) > -1)
params.push(param);
});
params.push('resourceUsage=CacheResources');
params.push('titleDecorationScheme=primary-content');
params.push('subtitleFormat=TTMLv2');
params.push('asin=');
urlParts[1] = params.join('&');
return urlParts.join('?');
}
function createDownloadButton(id, type) {
var p = document.createElement('p');
p.classList.add('download');
p.setAttribute('data-id', id);
p.innerHTML = 'Download subs for this ' + type;
p.addEventListener('click', (type == 'season' ? downloadAll : downloadThis));
return p;
}
function getArgs(a) {
return a.initArgs || a.args;
}
function findMovieID() {
let movieId;
for(const templateElement of document.querySelectorAll('script[type="text/template"]')) {
let data;
try {
data = JSON.parse(templateElement.innerHTML);
}
catch(ignore) {
continue;
}
for(let i = 0; i < 3; ++i) {
try {
if(i === 0) {
movieId = getArgs(getArgs(data).apexes[0]).titleID;
}
else if(i === 1) {
movieId = getArgs(data).titleID;
}
else if(i === 2) {
movieId = getArgs(data.props.body[0]).titleID;
}
if(typeof movieId !== "undefined") {
return movieId;
}
}
catch(ignore) {}
}
}
for(const name of ["titleId", "titleID"]) {
try {
movieId = document.querySelector(`input[name="${name}"]`).value;
if(typeof movieId !== "undefined" && movieId !== "") {
return movieId;
}
}
catch(ignore) {}
}
throw Error("Couldn't find movie ID");
}
function allLoaded(resolve, epCount) {
if(epCount !== document.querySelectorAll('.js-node-episode-container, li[id^=av-ep-episodes-], li[id^=av-ep-episode-]').length)
resolve();
else
window.setTimeout(allLoaded, 200, resolve, epCount);
}
function showAll() {
return new Promise(resolve => {
let btn = document.querySelector('[data-automation-id="ep-expander"]');
if(btn === null)
resolve();
let epCount = document.querySelectorAll('.js-node-episode-container, li[id^=av-ep-episodes-], li[id^=av-ep-episode-]').length;
btn.click();
allLoaded(resolve, epCount);
});
}
// add download buttons
async function init(url) {
initialied = true;
gUrl = parseURL(url);
console.log(gUrl);
await showAll();
let button;
let epElems = document.querySelectorAll('.dv-episode-container, .avu-context-card, .js-node-episode-container, li[id^=av-ep-episodes-], li[id^=av-ep-episode-]');
if(epElems.length > 0) {
let IDs = [];
for(let i=epElems.length; i--; ) {
let selector, id, el;
if((el = epElems[i].querySelector('input[name="highlight-list-selector"]')) !== null) {
id = el.id.replace('selector-', '');
selector = '.js-episode-offers';
}
else if((el = epElems[i].querySelector('input[name="ep-list-selector"]')) !== null) {
id = el.value;
selector = '.av-episode-meta-info';
}
else if(id = epElems[i].getAttribute('data-aliases'))
selector = '.dv-el-title';
else
continue;
id = id.split(',')[0];
epElems[i].querySelector(selector).parentNode.appendChild(createDownloadButton(id, 'episode'));
IDs.push(id);
}
button = createDownloadButton(IDs.join(';'), 'season');
}
else {
let id = findMovieID();
id = id.split(',')[0];
button = createDownloadButton(id, 'movie');
}
document.querySelector('.dv-node-dp-badges, .av-badges').appendChild(button);
}
var initialied = false, gUrl;
// hijack xhr, we need to find out tokens and other parameters needed for subtitle info
xhrHijacker(function(xhr, id, origin, args) {
if(!initialied && origin === 'open')
if(args[1].indexOf('/GetPlaybackResources') > -1) {
init(args[1])
.catch(error => {
console.log(error);
alert(`subtitle downloader error: ${error.message}`);
});
}
});