Amazon Video - subtitle downloader

Allows you to download subtitles from Amazon Video

  1. // ==UserScript==
  2. // @name Amazon Video - subtitle downloader
  3. // @description Allows you to download subtitles from Amazon Video
  4. // @license MIT
  5. // @version 2.0.0
  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. // @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5
  14. // @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29
  15. // ==/UserScript==
  16.  
  17. class ProgressBar {
  18. constructor(max) {
  19. this.current = 0;
  20. this.max = max;
  21.  
  22. let container = document.querySelector("#userscript_progress_bars");
  23. if(container === null) {
  24. container = document.createElement("div");
  25. container.id = "userscript_progress_bars"
  26. document.body.appendChild(container)
  27. container.style
  28. container.style.position = "fixed";
  29. container.style.top = 0;
  30. container.style.left = 0;
  31. container.style.width = "100%";
  32. container.style.background = "red";
  33. container.style.zIndex = "99999999";
  34. }
  35.  
  36. this.progressElement = document.createElement("div");
  37. this.progressElement.innerHTML = "Click to stop";
  38. this.progressElement.style.cursor = "pointer";
  39. this.progressElement.style.fontSize = "16px";
  40. this.progressElement.style.textAlign = "center";
  41. this.progressElement.style.width = "100%";
  42. this.progressElement.style.height = "20px";
  43. this.progressElement.style.background = "transparent";
  44. this.stop = new Promise(resolve => {
  45. this.progressElement.addEventListener("click", () => {resolve(STOP_THE_DOWNLOAD)});
  46. });
  47.  
  48. container.appendChild(this.progressElement);
  49. }
  50.  
  51. increment() {
  52. this.current += 1;
  53. if(this.current <= this.max) {
  54. let p = this.current / this.max * 100;
  55. this.progressElement.style.background = `linear-gradient(to right, green ${p}%, transparent ${p}%)`;
  56. }
  57. }
  58.  
  59. destroy() {
  60. this.progressElement.remove();
  61. }
  62. }
  63.  
  64. const STOP_THE_DOWNLOAD = "AMAZON_SUBTITLE_DOWNLOADER_STOP_THE_DOWNLOAD";
  65. const TIMEOUT_ERROR = "AMAZON_SUBTITLE_DOWNLOADER_TIMEOUT_ERROR";
  66. const DOWNLOADER_MENU = "subtitle-downloader-menu";
  67.  
  68. const DOWNLOADER_MENU_HTML = `
  69. <ol>
  70. <li class="header">Amazon subtitle downloader</li>
  71. <li class="ep-title-in-filename">Add episode title to filename: <span></span></li>
  72. <li class="incomplete">Scroll to the bottom to load more episodes</li>
  73. </ol>
  74. `;
  75.  
  76. const SCRIPT_CSS = `
  77. #${DOWNLOADER_MENU} {
  78. position: absolute;
  79. display: none;
  80. width: 600px;
  81. top: 0;
  82. left: calc( 50% - 150px );
  83. }
  84. #${DOWNLOADER_MENU} ol {
  85. list-style: none;
  86. position: relative;
  87. width: 300px;
  88. background: #333;
  89. color: #fff;
  90. padding: 0;
  91. margin: 0;
  92. font-size: 12px;
  93. z-index: 99999998;
  94. }
  95. body:hover #${DOWNLOADER_MENU} { display: block; }
  96. #${DOWNLOADER_MENU} li {
  97. padding: 10px;
  98. position: relative;
  99. }
  100. #${DOWNLOADER_MENU} li.header { font-weight: bold; }
  101. #${DOWNLOADER_MENU} li:not(.header):hover { background: #666; }
  102. #${DOWNLOADER_MENU} li:not(.header) {
  103. display: none;
  104. cursor: pointer;
  105. }
  106. #${DOWNLOADER_MENU}:hover li { display: block; }
  107. #${DOWNLOADER_MENU} li > div {
  108. display: none;
  109. position: absolute;
  110. top: 0;
  111. left: 300px;
  112. }
  113. #${DOWNLOADER_MENU} li:hover > div { display: block; }
  114.  
  115. body:not(.asd-more-eps) #${DOWNLOADER_MENU} .incomplete { display: none; }
  116.  
  117. #${DOWNLOADER_MENU}:not(.series) .series{ display: none; }
  118. #${DOWNLOADER_MENU}.series .not-series{ display: none; }
  119. `;
  120.  
  121. const EXTENSIONS = {
  122. "TTMLv2": "ttml2",
  123. "DFXP": "dfxp"
  124. }
  125.  
  126. let INFO_URL = null;
  127. const INFO_CACHE = new Map();
  128.  
  129. let epTitleInFilename = localStorage.getItem("ASD_ep-title-in-filename") === "true";
  130.  
  131. const setEpTitleInFilename = () => {
  132. document.querySelector(`#${DOWNLOADER_MENU} .ep-title-in-filename > span`).innerHTML = (epTitleInFilename ? "on" : "off");
  133. };
  134.  
  135. const toggleEpTitleInFilename = () => {
  136. epTitleInFilename = !epTitleInFilename;
  137. if(epTitleInFilename)
  138. localStorage.setItem("ASD_ep-title-in-filename", epTitleInFilename);
  139. else
  140. localStorage.removeItem("ASD_ep-title-in-filename");
  141. setEpTitleInFilename();
  142. };
  143.  
  144. const showIncompleteWarning = () => {
  145. document.body.classList.add("asd-more-eps");
  146. };
  147. const hideIncompleteWarning = () => {
  148. try {
  149. document.body.classList.remove("asd-more-eps");
  150. }
  151. catch(ignore) {}
  152. };
  153. const scrollDown = () => {
  154. (
  155. document.querySelector('[data-testid="dp-episode-list-pagination-marker"]')
  156. || document.querySeledtor("#navFooter")
  157. ).scrollIntoView();
  158. };
  159.  
  160. // XML to SRT
  161. const parseTTMLLine = (line, parentStyle, styles) => {
  162. const topStyle = line.getAttribute("style") || parentStyle;
  163. let prefix = "";
  164. let suffix = "";
  165. let italic = line.getAttribute("tts:fontStyle") === "italic";
  166. let bold = line.getAttribute("tts:fontWeight") === "bold";
  167. let ruby = line.getAttribute("tts:ruby") === "text";
  168. if(topStyle !== null) {
  169. italic = italic || styles[topStyle][0];
  170. bold = bold || styles[topStyle][1];
  171. ruby = ruby || styles[topStyle][2];
  172. }
  173.  
  174. if(italic) {
  175. prefix = "<i>";
  176. suffix = "</i>";
  177. }
  178. if(bold) {
  179. prefix += "<b>";
  180. suffix = "</b>" + suffix;
  181. }
  182. if(ruby) {
  183. prefix += "(";
  184. suffix = ")" + suffix;
  185. }
  186.  
  187. let result = "";
  188.  
  189. for(const node of line.childNodes) {
  190. if(node.nodeType === Node.ELEMENT_NODE) {
  191. const tagName = node.tagName.split(":").pop().toUpperCase();
  192. if(tagName === "BR") {
  193. result += "\n";
  194. }
  195. else if(tagName === "SPAN") {
  196. result += parseTTMLLine(node, topStyle, styles);
  197. }
  198. else {
  199. console.log("unknown node:", node);
  200. throw "unknown node";
  201. }
  202. }
  203. else if(node.nodeType === Node.TEXT_NODE) {
  204. result += prefix + node.textContent + suffix;
  205. }
  206. }
  207.  
  208. return result;
  209. };
  210. const xmlToSrt = (xmlString, lang) => {
  211. try {
  212. let parser = new DOMParser();
  213. var xmlDoc = parser.parseFromString(xmlString, "text/xml");
  214.  
  215. const styles = {};
  216. for(const style of xmlDoc.querySelectorAll("head styling style")) {
  217. const id = style.getAttribute("xml:id");
  218. if(id === null) throw "style ID not found";
  219. const italic = style.getAttribute("tts:fontStyle") === "italic";
  220. const bold = style.getAttribute("tts:fontWeight") === "bold";
  221. const ruby = style.getAttribute("tts:ruby") === "text";
  222. styles[id] = [italic, bold, ruby];
  223. }
  224.  
  225. const regionsTop = {};
  226. for(const style of xmlDoc.querySelectorAll("head layout region")) {
  227. const id = style.getAttribute("xml:id");
  228. if(id === null) throw "style ID not found";
  229. const origin = style.getAttribute("tts:origin") || "0% 80%";
  230. const position = parseInt(origin.match(/\s(\d+)%/)[1]);
  231. regionsTop[id] = position < 50;
  232. }
  233.  
  234. const topStyle = xmlDoc.querySelector("body").getAttribute("style");
  235.  
  236. console.log(topStyle, styles, regionsTop);
  237.  
  238. const lines = [];
  239. const textarea = document.createElement("textarea");
  240.  
  241. let i = 0;
  242. for(const line of xmlDoc.querySelectorAll("body p")) {
  243. let parsedLine = parseTTMLLine(line, topStyle, styles);
  244. if(parsedLine != "") {
  245. if(lang.indexOf("ar") == 0)
  246. parsedLine = parsedLine.replace(/^(?!\u202B|\u200F)/gm, "\u202B");
  247.  
  248. textarea.innerHTML = parsedLine;
  249. parsedLine = textarea.value;
  250. parsedLine = parsedLine.replace(/\n{2,}/g, "\n");
  251.  
  252. const region = line.getAttribute("region");
  253. if(regionsTop[region] === true) {
  254. parsedLine = "{\\an8}" + parsedLine;
  255. }
  256.  
  257. lines.push(++i);
  258. lines.push((line.getAttribute("begin") + " --> " + line.getAttribute("end")).replace(/\./g,","));
  259. lines.push(parsedLine);
  260. lines.push("");
  261. }
  262. }
  263. return lines.join("\n");
  264. }
  265. catch(e) {
  266. console.error(e);
  267. alert("Failed to parse XML subtitle file, see browser console for more details");
  268. return null;
  269. }
  270. };
  271.  
  272. const sanitizeName = name => name.replace(/[:*?"<>|\\\/]+/g, "_").replace(/ /g, ".").replace(/\.{2,}/g, ".");
  273.  
  274. const asyncSleep = (seconds, value) => new Promise(resolve => {
  275. window.setTimeout(resolve, seconds * 1000, value);
  276. });
  277.  
  278. const getName = (episodeId, addTitle, addSeriesName) => {
  279. let seasonNumber = 0;
  280. let digits = 2;
  281. let seriesName = "UNKNOWN";
  282.  
  283. const info = INFO_CACHE.get(episodeId);
  284. const season = INFO_CACHE.get(info.show);
  285. if(typeof season !== "undefined") {
  286. seasonNumber = season.season;
  287. digits = season.digits;
  288. seriesName = season.title;
  289. }
  290.  
  291. let title = (
  292. "S" + seasonNumber.toString().padStart(2, "0")
  293. + "E" + info.episode.toString().padStart(digits, "0")
  294. );
  295.  
  296. if(addTitle)
  297. title += " " + info.title;
  298.  
  299. if(addSeriesName)
  300. title = seriesName + " " + title;
  301.  
  302. return title;
  303. };
  304.  
  305. const createQueue = ids => {
  306. let archiveName = null;
  307. const names = new Set();
  308. const queue = new Map();
  309. for(const id of ids) {
  310. const info = JSON.parse(JSON.stringify(INFO_CACHE.get(id)));
  311. let name;
  312. if(info.type === "movie") {
  313. archiveName = sanitizeName(info.title + "." + info.year);
  314. name = archiveName;
  315. }
  316. else if(info.type === "episode") {
  317. name = sanitizeName(getName(id, epTitleInFilename, true));
  318. if(archiveName === null) {
  319. try {
  320. const series = INFO_CACHE.get(info.show);
  321. archiveName = sanitizeName(series.title + ".S" + series.season.toString().padStart(2, "0"));
  322. }
  323. catch(ignore) {}
  324. }
  325. }
  326. else
  327. continue;
  328.  
  329. let subName = name;
  330. let i = 2;
  331. while(names.has(subName)) {
  332. sub_name = `${name}_${i}`;
  333. ++i;
  334. }
  335. names.add(subName);
  336. info.filename = subName;
  337. queue.set(id, info);
  338. }
  339. if(archiveName === null)
  340. archiveName = "subs";
  341.  
  342. return [archiveName + ".zip", queue];
  343. };
  344.  
  345. const getSubInfo = async envelope => {
  346. const response = await fetch(
  347. INFO_URL,
  348. {
  349. "credentials": "include",
  350. "method": "POST",
  351. "mode": "cors",
  352. "body": JSON.stringify({
  353. "globalParameters": {
  354. "deviceCapabilityFamily": "WebPlayer",
  355. "playbackEnvelope": envelope
  356. },
  357. "timedTextUrlsRequest": {
  358. "supportedTimedTextFormats": ["TTMLv2","DFXP"]
  359. }
  360. })
  361. }
  362. );
  363. const data = await response.json();
  364. if(data.globalError) {
  365. if(data.globalError.code && data.globalError.code === "PlaybackEnvelope.Expired")
  366. throw "authentication expired, refresh the page and try again";
  367. else
  368. throw data.globalError;
  369. }
  370. try {
  371. return data.timedTextUrls.result;
  372. }
  373. catch(error) {
  374. console.log(data);
  375. throw error;
  376. }
  377. };
  378.  
  379. const download = async e => {
  380. const ids = e.target.getAttribute("data-id").split(";");
  381. if(ids.length === 1 && ids[0] === "")
  382. return;
  383.  
  384. const [archiveName, queue] = createQueue(ids);
  385. const metadataProgress = new ProgressBar(queue.size);
  386. const subs = new Map();
  387. for(const [id, info] of queue) {
  388. const resultPromise = getSubInfo(info.envelope);
  389. let result;
  390. let error = null;
  391. try {
  392. // Promise.any isn't supported in all browsers, use Promise.race instead
  393. result = await Promise.race([resultPromise, metadataProgress.stop, asyncSleep(30, TIMEOUT_ERROR)]);
  394. }
  395. catch(e) {
  396. console.log(e);
  397. error = `error: ${e}`;
  398. }
  399. if(result === STOP_THE_DOWNLOAD)
  400. error = "stopped by user";
  401. else if(result === TIMEOUT_ERROR)
  402. error = "timeout error";
  403. if(error !== null) {
  404. alert(error);
  405. metadataProgress.destroy();
  406. return;
  407. }
  408.  
  409. metadataProgress.increment();
  410. if(typeof result === "undefined")
  411. continue;
  412.  
  413. for(const subtitle of [].concat(result.subtitleUrls || [], result.forcedNarrativeUrls || [])) {
  414. let lang = subtitle.languageCode;
  415. if(subtitle.subtype !== "Dialog")
  416. lang += `[${subtitle.subtype}]`;
  417.  
  418. if(subtitle.type === "Subtitle") {}
  419. else if(subtitle.type === "Sdh")
  420. lang += "[cc]";
  421. else if(subtitle.type === "ForcedNarrative")
  422. lang += "-forced";
  423. else if(subtitle.type === "SubtitleMachineGenerated")
  424. lang += "[machine-generated]";
  425. else
  426. lang += `[${subtitle.type}]`;
  427.  
  428. const name = info.filename + "." + lang;
  429. let subName = name;
  430. let i = 2;
  431. while(subs.has(subName)) {
  432. sub_name = `${name}_${i}`;
  433. ++i;
  434. }
  435. subs.set(
  436. subName,
  437. {
  438. "url": subtitle.url,
  439. "type": subtitle.format,
  440. "language": subtitle.languageCode
  441. }
  442. )
  443. }
  444. }
  445. metadataProgress.destroy();
  446.  
  447. if(subs.size === 0) {
  448. alert("no subtitles found");
  449. return;
  450. }
  451.  
  452. const _zip = new JSZip();
  453. const progress = new ProgressBar(subs.size);
  454. for(const [filename, details] of subs) {
  455. let extension = EXTENSIONS[details.type];
  456. if(typeof extension === "undefined") {
  457. const match = details.url.match(/\.([^\/]+)$/);
  458. if(match === null)
  459. extension = details.type.toLocaleLowerCase();
  460. else
  461. extension = match[1];
  462. }
  463.  
  464. const subFilename = filename + "." + extension;
  465. const resultPromise = fetch(details.url, {"mode": "cors"});
  466. let result;
  467. let error = null;
  468. try {
  469. // Promise.any isn't supported in all browsers, use Promise.race instead
  470. result = await Promise.race([resultPromise, progress.stop, asyncSleep(30, TIMEOUT_ERROR)]);
  471. }
  472. catch(e) {
  473. error = `error: ${e}`;
  474. }
  475. if(result === STOP_THE_DOWNLOAD)
  476. error = STOP_THE_DOWNLOAD;
  477. else if(result === TIMEOUT_ERROR)
  478. error = "timeout error";
  479. if(error !== null) {
  480. if(error !== STOP_THE_DOWNLOAD)
  481. alert(error);
  482. break;
  483. }
  484. progress.increment();
  485. let data;
  486. if(extension === "ttml2") {
  487. data = await result.text();
  488. try {
  489. const srtFilename = filename + ".srt";
  490. const srtText = xmlToSrt(data, details.language);
  491. if(srtText !== null)
  492. _zip.file(srtFilename, srtText);
  493. }
  494. catch(ignore) {}
  495. }
  496. else
  497. data = await result.arrayBuffer();
  498. _zip.file(subFilename, data);
  499. }
  500. progress.destroy();
  501.  
  502. const content = await _zip.generateAsync({type: "blob"});
  503. saveAs(content, archiveName);
  504. };
  505.  
  506. const addDownloadButtons = parsedActions => {
  507. const menu = document.querySelector(`#${DOWNLOADER_MENU} > ol`);
  508.  
  509. for(const [type, details] of parsedActions) {
  510. const li = document.createElement("li");
  511. let ids = null;
  512. if(type === "movie") {
  513. li.innerHTML = "Download subtitles for this movie";
  514. ids = details;
  515. }
  516. else if(type === "batch" && details.length > 0) {
  517. li.innerHTML = "Download subtitles for this batch <div><ol></ol></div>";
  518. ids = details.join(";");
  519. const ol = li.querySelector("ol");
  520. for(const episodeId of details) {
  521. const li = document.createElement("li");
  522. li.setAttribute("data-id", episodeId);
  523. li.innerHTML = getName(episodeId, true, false);
  524. ol.append(li);
  525. }
  526. }
  527. else
  528. continue;
  529.  
  530. li.setAttribute("data-id", ids);
  531. li.addEventListener("click", download, true);
  532. menu.append(li);
  533. }
  534. };
  535.  
  536. const parseActions = actions => {
  537. const parsed = [];
  538. const series = {};
  539. for(const [id, playback] of actions) {
  540. const info = INFO_CACHE.get(id);
  541. if(typeof info === "undefined")
  542. continue;
  543. if(info.type !== "movie" && info.type !== "episode")
  544. continue;
  545. if(typeof info.envelope !== "undefined")
  546. continue;
  547.  
  548. try {
  549. let envelopeFound = false;
  550. for(const child of playback.main.children) {
  551. if(typeof child.playbackEnvelope !== "undefined") {
  552. info.envelope = child.playbackEnvelope;
  553. info.expiry = child.expiryTime;
  554. envelopeFound = true;
  555. break;
  556. }
  557. }
  558. if(!envelopeFound)
  559. continue;
  560. }
  561. catch(error) {
  562. continue;
  563. }
  564.  
  565. if(info.type === "movie") {
  566. parsed.push(["movie", id])
  567. }
  568. else if(info.type === "episode") {
  569. let show = series[info.show];
  570. if(typeof show === "undefined") {
  571. series[info.show] = [];
  572. show = series[info.show];
  573. }
  574. show.push([id, info.episode]);
  575. }
  576. }
  577.  
  578. for(const show of Object.values(series)) {
  579. show.sort((a, b) => a[1] - b[1]);
  580. const tmp = [];
  581. for(const [id, ep] of show) {
  582. tmp.push(id);
  583. }
  584. parsed.push(["batch", tmp]);
  585. }
  586.  
  587. return parsed;
  588. };
  589.  
  590. const parseDetails = (pageTitleId, state, id, details) => {
  591. if(typeof INFO_CACHE.get(id) !== "undefined")
  592. return;
  593.  
  594. const info = {
  595. "title": details.title,
  596. "type": details.titleType
  597. };
  598. if(info.type === "movie") {
  599. info["year"] = details.releaseYear;
  600. }
  601. else if(info.type === "episode") {
  602. info["episode"] = details.episodeNumber;
  603. info["show"] = pageTitleId;
  604. }
  605. else if(info.type === "season") {
  606. info["season"] = details.seasonNumber;
  607. info["title"] = details.parentTitle;
  608. info["digits"] = 2;
  609. if(pageTitleId === id) {
  610. try {
  611. const epCount = state.episodeList.totalCardSize;
  612. info["digits"] = Math.max(Math.floor(Math.log10(epCount)), 1) + 1;
  613. if(epCount > state.episodeList.cardTitleIds.length)
  614. showIncompleteWarning();
  615. }
  616. catch(ignore) {}
  617. }
  618. }
  619. else {
  620. console.log(id, details);
  621. return;
  622. }
  623.  
  624. INFO_CACHE.set(id, info);
  625. };
  626.  
  627. const init = (url, fromFetch) => {
  628. let props = undefined;
  629.  
  630. if(typeof fromFetch === "undefined") {
  631. if(INFO_URL !== null)
  632. return;
  633.  
  634. INFO_URL = url;
  635.  
  636. for(const templateElement of document.querySelectorAll('script[type="text/template"]')) {
  637. let data;
  638. try {
  639. data = JSON.parse(templateElement.innerHTML);
  640. props = data.props.body[0].props;
  641. }
  642. catch(ignore) {
  643. continue;
  644. }
  645.  
  646. if(typeof props !== "undefined")
  647. break;
  648. }
  649. }
  650. else {
  651. props = fromFetch.page[0].assembly.body[0].props;
  652. INFO_CACHE.clear();
  653. hideIncompleteWarning();
  654. const menu = document.querySelector(`#${DOWNLOADER_MENU}`);
  655. if(menu !== null)
  656. menu.remove();
  657. }
  658.  
  659. const pageTitleId = props.btf.state.pageTitleId;
  660. for(const [id, details] of Object.entries(props.btf.state.detail.detail)) {
  661. parseDetails(pageTitleId, props.btf.state, id, details);
  662. }
  663.  
  664. const actions = [];
  665. for(const [id, action] of Object.entries(props.atf.state.action.atf)) {
  666. actions.push([id, action.playbackActions]);
  667. }
  668. for(const [id, action] of Object.entries(props.btf.state.action.btf)) {
  669. actions.push([id, action.playbackActions]);
  670. }
  671. const parsedActions = parseActions(actions);
  672. if(parsedActions.length === 0)
  673. return;
  674.  
  675. if(document.querySelector(`#${DOWNLOADER_MENU}`) === null) {
  676. const menu = document.createElement("div");
  677. menu.id = DOWNLOADER_MENU;
  678. menu.innerHTML = DOWNLOADER_MENU_HTML;
  679. document.body.appendChild(menu);
  680. menu.querySelector(".ep-title-in-filename").addEventListener("click", toggleEpTitleInFilename);
  681. menu.querySelector(".incomplete").addEventListener("click", scrollDown);
  682. setEpTitleInFilename();
  683. }
  684.  
  685. addDownloadButtons(parsedActions);
  686. };
  687.  
  688. const parseEpisodes = data => {
  689. const pageTitleId = data.widgets.pageContext.pageTitleId;
  690.  
  691. const actions = [];
  692. for(const episode of data.widgets.episodeList.episodes) {
  693. parseDetails(pageTitleId, {}, episode.titleID, episode.detail);
  694. actions.push([episode.titleID, episode.action.playbackActions]);
  695. }
  696. const parsedActions = parseActions(actions);
  697. addDownloadButtons(parsedActions);
  698. };
  699.  
  700. const processMessage = e => {
  701. const {type, data} = e.detail;
  702.  
  703. if(type === "url")
  704. init(data);
  705. else if(type === "episodes")
  706. parseEpisodes(data);
  707. else if(type === "page")
  708. init(null, data);
  709. }
  710.  
  711. const injection = () => {
  712. // hijack functions
  713. ((open, realFetch) => {
  714. let urlGrabbed = false;
  715.  
  716. XMLHttpRequest.prototype.open = function() {
  717. if(!urlGrabbed && arguments[1] && arguments[1].includes("/GetVodPlaybackResources?")) {
  718. window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "url", data: arguments[1]}}));
  719. urlGrabbed = true;
  720. }
  721. open.apply(this, arguments);
  722. };
  723.  
  724. window.fetch = async (...args) => {
  725. const response = realFetch(...args);
  726. if(!urlGrabbed && args[0] && args[0].includes("/GetVodPlaybackResources?")) {
  727. window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "url", data: args[0]}}));
  728. urlGrabbed = true;
  729. }
  730. if(args[0] && args[0].includes("/getDetailWidgets?")) {
  731. const copied = (await response).clone();
  732. const data = await copied.json();
  733. window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "episodes", data: data}}));
  734. }
  735. else if(args[1] && args[1].headers && args[1].headers["x-requested-with"] === "WebSPA") {
  736. const copied = (await response).clone();
  737. const data = await copied.json();
  738. window.dispatchEvent(new CustomEvent("amazon_sub_downloader_data", {detail: {type: "page", data: data}}));
  739. }
  740. return response;
  741. };
  742. })(XMLHttpRequest.prototype.open, window.fetch);
  743. }
  744.  
  745. window.addEventListener("amazon_sub_downloader_data", processMessage, false);
  746.  
  747. // inject script
  748. const sc = document.createElement("script");
  749. sc.innerHTML = "(" + injection.toString() + ")()";
  750. document.head.appendChild(sc);
  751. document.head.removeChild(sc);
  752.  
  753. // add CSS style
  754. const s = document.createElement("style");
  755. s.innerHTML = SCRIPT_CSS;
  756. document.head.appendChild(s);