Amazon Video - subtitle downloader

Allows you to download subtitles from Amazon Video

As of 2023-07-05. See the latest version.

  1. // ==UserScript==
  2. // @name Amazon Video - subtitle downloader
  3. // @description Allows you to download subtitles from Amazon Video
  4. // @license MIT
  5. // @version 1.9.8
  6. // @namespace tithen-firion.github.io
  7. // @match https://*.amazon.com/*
  8. // @match https://*.amazon.de/*
  9. // @match https://*.amazon.co.uk/*
  10. // @match https://*.amazon.co.jp/*
  11. // @match https://*.primevideo.com/*
  12. // @grant unsafeWindow
  13. // @grant GM.xmlHttpRequest
  14. // @grant GM_xmlhttpRequest
  15. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
  16. // @require https://cdn.jsdelivr.net/gh/Tithen-Firion/UserScripts@7bd6406c0d264d60428cfea16248ecfb4753e5e3/libraries/xhrHijacker.js?version=1.0
  17. // @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5
  18. // @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29
  19. // ==/UserScript==
  20.  
  21. class ProgressBar {
  22. constructor() {
  23. let container = document.querySelector('#userscript_progress_bars');
  24. if(container === null) {
  25. container = document.createElement('div');
  26. container.id = 'userscript_progress_bars'
  27. document.body.appendChild(container)
  28. container.style
  29. container.style.position = 'fixed';
  30. container.style.top = 0;
  31. container.style.left = 0;
  32. container.style.width = '100%';
  33. container.style.background = 'red';
  34. container.style.zIndex = '99999999';
  35. }
  36. self.container = container;
  37. }
  38.  
  39. init() {
  40. this.current = 0;
  41. this.max = 0;
  42.  
  43. this.progressElement = document.createElement('div');
  44. this.progressElement.style.width = 0;
  45. this.progressElement.style.height = '10px';
  46. this.progressElement.style.background = 'green';
  47.  
  48. self.container.appendChild(this.progressElement);
  49. }
  50.  
  51. increment() {
  52. this.current += 1;
  53. if(this.current <= this.max)
  54. this.progressElement.style.width = this.current / this.max * 100 + '%';
  55. }
  56.  
  57. incrementMax() {
  58. this.max += 1;
  59. if(this.current <= this.max)
  60. this.progressElement.style.width = this.current / this.max * 100 + '%';
  61. }
  62.  
  63. destroy() {
  64. this.progressElement.remove();
  65. }
  66. }
  67.  
  68. var progressBar = new ProgressBar();
  69.  
  70. // add CSS style
  71. var s = document.createElement('style');
  72. s.innerHTML = 'p.download:hover { cursor:pointer }';
  73. document.head.appendChild(s);
  74.  
  75. // XML to SRT
  76. function parseTTMLLine(line, parentStyle, styles) {
  77. const topStyle = line.getAttribute('style') || parentStyle;
  78. let prefix = '';
  79. let suffix = '';
  80. let italic = line.getAttribute('tts:fontStyle') === 'italic';
  81. let bold = line.getAttribute('tts:fontWeight') === 'bold';
  82. let ruby = line.getAttribute('tts:ruby') === 'text';
  83. if(topStyle !== null) {
  84. italic = italic || styles[topStyle][0];
  85. bold = bold || styles[topStyle][1];
  86. ruby = ruby || styles[topStyle][2];
  87. }
  88.  
  89. if(italic) {
  90. prefix = '<i>';
  91. suffix = '</i>';
  92. }
  93. if(bold) {
  94. prefix += '<b>';
  95. suffix = '</b>' + suffix;
  96. }
  97. if(ruby) {
  98. prefix += '(';
  99. suffix = ')' + suffix;
  100. }
  101.  
  102. let result = '';
  103.  
  104. for(const node of line.childNodes) {
  105. if(node.nodeType === Node.ELEMENT_NODE) {
  106. const tagName = node.tagName.split(':').pop().toUpperCase();
  107. if(tagName === 'BR') {
  108. result += '\n';
  109. }
  110. else if(tagName === 'SPAN') {
  111. result += parseTTMLLine(node, topStyle, styles);
  112. }
  113. else {
  114. console.log('unknown node:', node);
  115. throw 'unknown node';
  116. }
  117. }
  118. else if(node.nodeType === Node.TEXT_NODE) {
  119. result += prefix + node.textContent + suffix;
  120. }
  121. }
  122.  
  123. return result;
  124. }
  125. function xmlToSrt(xmlString, lang) {
  126. try {
  127. let parser = new DOMParser();
  128. var xmlDoc = parser.parseFromString(xmlString, 'text/xml');
  129.  
  130. const styles = {};
  131. for(const style of xmlDoc.querySelectorAll('head styling style')) {
  132. const id = style.getAttribute('xml:id');
  133. if(id === null) throw "style ID not found";
  134. const italic = style.getAttribute('tts:fontStyle') === 'italic';
  135. const bold = style.getAttribute('tts:fontWeight') === 'bold';
  136. const ruby = style.getAttribute('tts:ruby') === 'text';
  137. styles[id] = [italic, bold, ruby];
  138. }
  139.  
  140. const regionsTop = {};
  141. for(const style of xmlDoc.querySelectorAll('head layout region')) {
  142. const id = style.getAttribute('xml:id');
  143. if(id === null) throw "style ID not found";
  144. const origin = style.getAttribute('tts:origin') || "0% 80%";
  145. const position = parseInt(origin.match(/\s(\d+)%/)[1]);
  146. regionsTop[id] = position < 50;
  147. }
  148.  
  149. const topStyle = xmlDoc.querySelector('body').getAttribute('style');
  150.  
  151. console.log(topStyle, styles, regionsTop);
  152.  
  153. const lines = [];
  154. const textarea = document.createElement('textarea');
  155.  
  156. let i = 0;
  157. for(const line of xmlDoc.querySelectorAll('body p')) {
  158. let parsedLine = parseTTMLLine(line, topStyle, styles);
  159. if(parsedLine != '') {
  160. if(lang.indexOf('ar') == 0)
  161. parsedLine = parsedLine.replace(/^(?!\u202B|\u200F)/gm, '\u202B');
  162.  
  163. textarea.innerHTML = parsedLine;
  164. parsedLine = textarea.value;
  165.  
  166. const region = line.getAttribute('region');
  167. if(regionsTop[region] === true) {
  168. parsedLine = '{\\an8}' + parsedLine;
  169. }
  170.  
  171. lines.push(++i);
  172. lines.push((line.getAttribute('begin') + ' --> ' + line.getAttribute('end')).replace(/\./g,','));
  173. lines.push(parsedLine);
  174. lines.push('');
  175. }
  176. }
  177. return lines.join('\n');
  178. }
  179. catch(e) {
  180. console.error(e);
  181. alert('Failed to parse XML subtitle file, see browser console for more details');
  182. return null;
  183. }
  184. }
  185.  
  186. function sanitizeTitle(title) {
  187. return title.replace(/[:*?"<>|\\\/]+/g, '_').replace(/ /g, '.');
  188. }
  189.  
  190. // download subs and save them
  191. function downloadSubs(url, title, downloadVars, lang) {
  192. GM.xmlHttpRequest({
  193. url: url,
  194. method: 'get',
  195. onload: function(resp) {
  196.  
  197. progressBar.increment();
  198. var srt = xmlToSrt(resp.responseText, lang);
  199. if(srt === null) {
  200. srt = resp.responseText;
  201. title = title.replace(/\.[^\.]+$/, '.ttml2');
  202. }
  203. if(downloadVars) {
  204. downloadVars.zip.file(title, srt);
  205. --downloadVars.subCounter;
  206. if((downloadVars.subCounter|downloadVars.infoCounter) === 0)
  207. downloadVars.zip.generateAsync({type:"blob"})
  208. .then(function(content) {
  209. saveAs(content, sanitizeTitle(downloadVars.title) + '.zip');
  210. progressBar.destroy();
  211. });
  212. }
  213. else {
  214. var blob = new Blob([srt], {type: 'text/plain;charset=utf-8'});
  215. saveAs(blob, title, true);
  216. progressBar.destroy();
  217. }
  218.  
  219. }
  220. });
  221. }
  222.  
  223. // download episodes/movie info and start downloading subs
  224. function downloadInfo(url, downloadVars) {
  225. var req = new XMLHttpRequest();
  226. req.open('get', url);
  227. req.withCredentials = true;
  228. req.onload = function() {
  229. var info = JSON.parse(req.response);
  230. try {
  231. var catalogMetadata = info.catalogMetadata;
  232. if(typeof catalogMetadata === 'undefined')
  233. catalogMetadata = {catalog:{type: 'MOVIE', title: info.returnedTitleRendition.asin}};
  234. var epInfo = catalogMetadata.catalog;
  235. var ep = epInfo.episodeNumber;
  236. var title, season;
  237. if(epInfo.type == 'MOVIE' || ep === 0) {
  238. title = epInfo.title;
  239. downloadVars.title = title;
  240. }
  241. else {
  242. info.catalogMetadata.family.tvAncestors.forEach(function(tvAncestor) {
  243. switch(tvAncestor.catalog.type) {
  244. case 'SEASON':
  245. season = tvAncestor.catalog.seasonNumber;
  246. break;
  247. case 'SHOW':
  248. title = tvAncestor.catalog.title;
  249. break;
  250. }
  251. });
  252. title += '.S' + season.toString().padStart(2, '0');
  253. if(downloadVars.type === 'all')
  254. downloadVars.title = title;
  255. title += 'E' + ep.toString().padStart(2, '0');
  256. if(downloadVars.type === 'one')
  257. downloadVars.title = title;
  258. title += '.' + epInfo.title;
  259. }
  260. title = sanitizeTitle(title);
  261. title += '.WEBRip.Amazon.';
  262. var languages = new Set();
  263.  
  264. var forced = info.forcedNarratives || [];
  265. forced.forEach(function(forcedInfo) {
  266. forcedInfo.languageCode += '-forced';
  267. });
  268.  
  269. var subs = (info.subtitleUrls || []).concat(forced);
  270.  
  271. subs.forEach(function(subInfo) {
  272. let lang = subInfo.languageCode;
  273. if(subInfo.type === 'subtitle' || subInfo.type === 'subtitle') {}
  274. else if(subInfo.type === 'shd')
  275. lang += '[cc]';
  276. else
  277. lang += `[${subInfo.type}]`;
  278. if(languages.has(lang)) {
  279. let index = 0;
  280. let newLang;
  281. do {
  282. newLang = `${lang}_${++index}`;
  283. } while(languages.has(newLang));
  284. lang = newLang;
  285. }
  286. languages.add(lang);
  287. ++downloadVars.subCounter;
  288. progressBar.incrementMax();
  289. downloadSubs(subInfo.url, title + lang + '.srt', downloadVars, lang);
  290. });
  291. }
  292. catch(e) {
  293. console.log(info);
  294. alert(e);
  295. }
  296. if(--downloadVars.infoCounter === 0 && downloadVars.subCounter === 0) {
  297. alert("No subs found, make sure you're logged in and you have access to watch this video!");
  298. progressBar.destroy();
  299. }
  300. };
  301. req.send(null);
  302. }
  303.  
  304. function downloadThis(e) {
  305. progressBar.init();
  306. var id = e.target.getAttribute('data-id');
  307. var downloadVars = {
  308. type: 'one',
  309. subCounter: 0,
  310. infoCounter: 1,
  311. zip: new JSZip()
  312. };
  313. downloadInfo(gUrl + id, downloadVars);
  314. }
  315. function downloadAll(e) {
  316. progressBar.init();
  317. var IDs = e.target.getAttribute('data-id').split(';');
  318. var downloadVars = {
  319. type: 'all',
  320. subCounter: 0,
  321. infoCounter: IDs.length,
  322. zip: new JSZip()
  323. };
  324. IDs.forEach(function(id) {
  325. downloadInfo(gUrl + id, downloadVars);
  326. });
  327. }
  328.  
  329. // remove unnecessary parameters from URL
  330. function parseURL(url) {
  331. var filter = ['consumptionType', 'deviceID', 'deviceTypeID', 'firmware', 'gascEnabled', 'marketplaceID', 'userWatchSessionId', 'videoMaterialType', 'clientId', 'operatingSystemName', 'operatingSystemVersion', 'customerID', 'token'];
  332. var urlParts = url.split('?');
  333. var params = ['desiredResources=CatalogMetadata%2CSubtitleUrls%2CForcedNarratives'];
  334. urlParts[1].split('&').forEach(function(param) {
  335. var p = param.split('=');
  336. if(filter.indexOf(p[0]) > -1)
  337. params.push(param);
  338. });
  339. params.push('resourceUsage=CacheResources');
  340. params.push('titleDecorationScheme=primary-content');
  341. params.push('subtitleFormat=TTMLv2');
  342. params.push('asin=');
  343. urlParts[1] = params.join('&');
  344. return urlParts.join('?');
  345. }
  346.  
  347. function createDownloadButton(id, type) {
  348. var p = document.createElement('p');
  349. p.classList.add('download');
  350. p.setAttribute('data-id', id);
  351. p.innerHTML = 'Download subs for this ' + type;
  352. p.addEventListener('click', (type == 'season' ? downloadAll : downloadThis));
  353. return p;
  354. }
  355.  
  356. function findMovieID() {
  357. for(const templateElement of document.querySelectorAll('script[type="text/template"]')) {
  358. let data;
  359. try {
  360. data = JSON.parse(templateElement.innerHTML);
  361. }
  362. catch(ignore) {
  363. continue;
  364. }
  365. const args = data.initArgs || data.args;
  366. if(typeof args !== 'undefined' && typeof args.titleID !== 'undefined')
  367. return args.titleID;
  368. }
  369. throw Error("Couldn't find movie ID");
  370. }
  371.  
  372. function allLoaded(resolve, epCount) {
  373. if(epCount !== document.querySelectorAll('.js-node-episode-container, li[id^=av-ep-episodes-]').length)
  374. resolve();
  375. else
  376. window.setTimeout(allLoaded, 200, resolve, epCount);
  377. }
  378.  
  379. function showAll() {
  380. return new Promise(resolve => {
  381. let btn = document.querySelector('[data-automation-id="ep-expander"]');
  382. if(btn === null)
  383. resolve();
  384.  
  385. let epCount = document.querySelectorAll('.js-node-episode-container, li[id^=av-ep-episodes-]').length;
  386. btn.click();
  387. allLoaded(resolve, epCount);
  388. });
  389. }
  390.  
  391. // add download buttons
  392. async function init(url) {
  393. initialied = true;
  394. gUrl = parseURL(url);
  395. console.log(gUrl);
  396.  
  397. await showAll();
  398.  
  399. let button;
  400. let epElems = document.querySelectorAll('.dv-episode-container, .avu-context-card, .js-node-episode-container, li[id^=av-ep-episodes-]');
  401. if(epElems.length > 0) {
  402. let IDs = [];
  403. for(let i=epElems.length; i--; ) {
  404. let selector, id, el;
  405. if((el = epElems[i].querySelector('input[name="highlight-list-selector"]')) !== null) {
  406. id = el.id.replace('selector-', '');
  407. selector = '.js-episode-offers';
  408. }
  409. else if((el = epElems[i].querySelector('input[name="ep-list-selector"]')) !== null) {
  410. id = el.value;
  411. selector = '.av-episode-meta-info';
  412. }
  413. else if(id = epElems[i].getAttribute('data-aliases'))
  414. selector = '.dv-el-title';
  415. else
  416. continue;
  417. id = id.split(',')[0];
  418. epElems[i].querySelector(selector).parentNode.appendChild(createDownloadButton(id, 'episode'));
  419. IDs.push(id);
  420. }
  421. button = createDownloadButton(IDs.join(';'), 'season');
  422. }
  423. else {
  424. let id = findMovieID();
  425. id = id.split(',')[0];
  426. button = createDownloadButton(id, 'movie');
  427. }
  428. document.querySelector('.dv-node-dp-badges, .av-badges').appendChild(button);
  429. }
  430.  
  431. var initialied = false, gUrl;
  432. // hijack xhr, we need to find out tokens and other parameters needed for subtitle info
  433. xhrHijacker(function(xhr, id, origin, args) {
  434. if(!initialied && origin === 'open')
  435. if(args[1].indexOf('/GetPlaybackResources') > -1) {
  436. init(args[1])
  437. .catch(error => {
  438. console.log(error);
  439. alert(`subtitle downloader error: ${error.message}`);
  440. });
  441. }
  442. });