NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Amazon Video - subtitle downloader // @description Allows you to download subtitles from Amazon Video // @license MIT // @version 1.9.15 // @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 manualShowAll(resolve) { alert( "Some episodes are not loaded yet! Scroll to the bottom of the page to load them." + "\n\n" + "Once all episodes are loaded - click on the button at the bottom of your screen." ); const btn = document.createElement("div"); btn.innerHTML = "Click here after all episodes load"; btn.style.position = "fixed"; btn.style.bottom = "0"; btn.style.left = "0"; btn.style.padding = "10px"; btn.style.zIndex = "999999"; btn.style.background = "white"; btn.addEventListener("click", () => { btn.remove(); resolve(); }); document.body.append(btn); } function showAll() { return new Promise(resolve => { for(const templateElement of document.querySelectorAll('script[type="text/template"]')) { let data; if(templateElement.innerHTML.includes("NextPage")) { manualShowAll(resolve); return; } } 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}`); }); } });