├── .gitattributes ├── Google_Images_-_search_by_paste.user.js ├── README.md ├── libraries └── xhrHijacker.js ├── iTunes_-_subtitle_downloader.user.js ├── Amazon_Video_-_subtitle_downloader.user.js └── Netflix_-_subtitle_downloader.user.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /Google_Images_-_search_by_paste.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Google Images - search by paste 3 | // @description Reverse search an image by pasting it 4 | // @license MIT 5 | // @version 1.1.0 6 | // @namespace tithen-firion.github.io 7 | // @match *://images.google.com/* 8 | // @match *://www.google.com/* 9 | // @grant GM.xmlHttpRequest 10 | // @grant GM_xmlhttpRequest 11 | // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js 12 | // ==/UserScript== 13 | 14 | document.body.addEventListener('paste', e => { 15 | for(let item of e.clipboardData.items) { 16 | if(item.type.indexOf('image') > -1) { 17 | let progress = document.createElement('div'); 18 | progress.style.position = 'fixed'; 19 | progress.style.top = 0; 20 | progress.style.left = 0; 21 | progress.style.width = '5%'; 22 | progress.style.height = '5px'; 23 | progress.style.background = 'green'; 24 | document.body.appendChild(progress); 25 | 26 | let data = new FormData(); 27 | let file = item.getAsFile(); 28 | let fileSize = file.size; 29 | data.set('encoded_image', file); 30 | GM.xmlHttpRequest({ 31 | url: 'https://images.google.com/searchbyimage/upload', 32 | method: 'post', 33 | data: data, 34 | onload: response => { 35 | document.location = response.finalUrl; 36 | }, 37 | onprogress: response => { 38 | progress.style.width = response.loaded / fileSize * 100 + '%'; 39 | } 40 | }); 41 | e.preventDefault(); 42 | return; 43 | } 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UserScripts 2 | 3 | ## Amazon Video - subtitle downloader 4 | 5 | Adds buttons to download subtitles in `.srt` format for movie, season and episode. 6 | 7 | Install from [here](https://github.com/Tithen-Firion/UserScripts/raw/master/Amazon_Video_-_subtitle_downloader.user.js), [OpenUserJS](https://openuserjs.org/scripts/Tithen-Firion/Amazon_Video_-_subtitle_downloader) or [Greasyfork](https://greasyfork.org/pl/scripts/34885-amazon-video-subtitle-downloader). 8 | 9 | ## Netflix - subtitle downloader 10 | 11 | Allows you to download subs from Netflix shows and movies. 12 | 13 | Text based subtitles are downloaded in `.srt` format. Basic font formatting is supported: bold, italic, underline, color and position (by default turned off in options; only top and bottom of a screen). 14 | 15 | Image based subtitles are downloaded as a `.zip`. Inside you've got all subs in `.png` format and `.xml` file with timestamps which can be opened in Subtitle Edit for OCR. Let me know if other programs can open it. 16 | 17 | You can also convert them to other image based formats: 18 | Select **Tools** -> **Batch** convert, add `.xml` file(s) to **Input files** box, select **Format** and hit **Convert**. 19 | 20 | Or using command line: 21 | `SubtitleEdit /convert "F:\subs\test\manifest_ttml2.xml" Blu-raysup` 22 | `Blu-raysup` for `.sup` files 23 | `VobSub` for `.sub` files 24 | 25 | Install from [here](https://github.com/Tithen-Firion/UserScripts/raw/master/Netflix_-_subtitle_downloader.user.js), [OpenUserJS](https://openuserjs.org/scripts/Tithen-Firion/Netflix_-_subtitle_downloader) or [Greasyfork](https://greasyfork.org/pl/scripts/26654-netflix-subtitle-downloader). 26 | 27 | # Libraries 28 | 29 | ## xhrHijacker 30 | 31 | Allows to hijack XHR whether you're using `@grant` in UserScripts or not. You can change method, url, add headers, abort, use loaded data. You can't change loaded data though. 32 | 33 | Example usage: 34 | 35 | ```javascript 36 | // ==UserScript== 37 | // ... 38 | // @require https://cdn.rawgit.com/Tithen-Firion/UserScripts/7bd6406c0d264d60428cfea16248ecfb4753e5e3/libraries/xhrHijacker.js?version=1.0 39 | // ==/UserScript== 40 | 41 | xhrHijacker(function(xhr, id, origin, args) { 42 | // id is unique string, use it to recognise your xhr between ready states 43 | // origin can be: open|send|readystatechange|load 44 | // args are used only with origin set to open or send 45 | if(origin == "open") { 46 | // happens before real open 47 | args[0] = "GET"; 48 | } else if(origin == "send") { 49 | // happens before real send 50 | xhr.setRequestHeader("X-Foo", "Bar"); 51 | } else if(origin == "readystatechange") { 52 | //you can abort XHR after it is sent 53 | if(xhr.readyState == 2) 54 | xhr.abort(); 55 | } else if(origin == "load") { 56 | console.log(xhr.getAllResponseHeaders()); 57 | console.log(xhr.responseType); 58 | console.log(xhr.response); 59 | console.log(xhr.status); 60 | } 61 | }); 62 | ``` 63 | 64 | # Links for testing 65 | 66 | Links to free stuff so I don't have to search for them over and over again: 67 | 68 | ## Free TV series 69 | https://www.amazon.de/gp/video/detail/B0CNDD43YH Tom Clancy's Jack Ryan 70 | https://www.amazon.de/gp/video/detail/B09PQM5S8T Bosch: Legacy 71 | https://www.amazon.de/gp/video/detail/B0D1GTMSP3 7 vs. Wild 72 | 73 | ## Free movies (there's a whole category) 74 | https://www.amazon.de/gp/video/detail/B01HC649YM NO SUBTITLES: Android Cop 75 | https://www.amazon.de/gp/video/detail/B09SB1522V AUTO GERMAN SUBS: Dark Crimes 76 | https://www.amazon.de/gp/video/detail/B0CBD8B6LL Agent Cody Banks 77 | 78 | -------------------------------------------------------------------------------- /libraries/xhrHijacker.js: -------------------------------------------------------------------------------- 1 | /* 1.0 2 | * By Tithen-Firion 3 | * License: MIT 4 | */ 5 | 6 | var xhrHijacker = xhrHijacker || function(process) { 7 | if(typeof process != "function") { 8 | process = function(){ console.log(arguments); }; 9 | } 10 | function postMyMessage(from_, detail, arg1, arg2, arg3) { 11 | if(typeof arg1 == "string") 12 | detail = { 13 | xhr: detail, 14 | origin: arg1, 15 | id: arg2, 16 | args: arg3 17 | }; 18 | window.dispatchEvent(new CustomEvent("xhrHijacker_message_from_" + from_, {detail: detail})); 19 | } 20 | function processMessage(e) { 21 | var d = e.detail; 22 | process(d.xhr, d.id, d.origin, d.args); 23 | postMyMessage("userscript", d); 24 | } 25 | window.addEventListener("xhrHijacker_message_from_injected", processMessage, false); 26 | function injection() { 27 | var xhrs = {}; 28 | var real = { 29 | open: XMLHttpRequest.prototype.open, 30 | send: XMLHttpRequest.prototype.send 31 | } 32 | function addRandomProperty(object, data, prefix) { 33 | if(typeof prefix != "string") 34 | prefix = ""; 35 | var x; 36 | do { 37 | x = prefix + Math.random(); 38 | } while(object.hasOwnProperty(x)); 39 | object[x] = data; 40 | return x; 41 | } 42 | function searchForPropertyName(object, data) { 43 | for(var e in object) { 44 | if(object.hasOwnProperty(e) && object[e] == data) 45 | return e; 46 | } 47 | } 48 | function processMessage(e) { 49 | var d = e.detail; 50 | var args; 51 | if(typeof d.args === "object") { 52 | // args = Array.prototype.slice.call(d.args, 0); // doesn't work 53 | args = []; 54 | for(var i = d.args.length-1; i >= 0; --i) 55 | args[i] = d.args[i]; 56 | } else 57 | args = d.args; 58 | if(d.origin == "open" || d.origin == "send" ) { 59 | real[d.origin].apply(d.xhr, args); 60 | } else if(d.origin == "load") { 61 | delete xhrs[d.id]; 62 | } 63 | } 64 | window.addEventListener("xhrHijacker_message_from_userscript", processMessage, false); 65 | XMLHttpRequest.prototype.open = function() { 66 | var id = addRandomProperty(xhrs, this); 67 | this.addEventListener("load", function() { 68 | postMyMessage("injected", this, "load", id); 69 | }, false); 70 | this.addEventListener("readystatechange", function() { 71 | postMyMessage("injected", this, "readystatechange", id); 72 | }, false); 73 | postMyMessage("injected", this, "open", id, arguments); 74 | }; 75 | XMLHttpRequest.prototype.send = function() { 76 | var id = searchForPropertyName(xhrs, this); 77 | postMyMessage("injected", this, "send", id, arguments); 78 | }; 79 | } 80 | var grantUsed = false; 81 | if(typeof unsafeWindow !== 'undefined' && window !== unsafeWindow) { 82 | var x; 83 | do { 84 | x = Math.random(); 85 | } while(window.hasOwnProperty(x) || unsafeWindow.hasOwnProperty(x)); 86 | if(!unsafeWindow[x]) 87 | grantUsed = true; 88 | delete window[x]; 89 | } 90 | console.time("xhrHijacker - injecting code"); 91 | if(grantUsed) { 92 | console.info("xhrHijacker - inject"); 93 | window.setTimeout(postMyMessage.toString() + "(" + injection.toString() + ")()", 0); 94 | } else { 95 | console.info("xhrHijacker - execute"); 96 | injection(); 97 | } 98 | console.timeEnd("xhrHijacker - injecting code"); 99 | }; 100 | -------------------------------------------------------------------------------- /iTunes_-_subtitle_downloader.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name iTunes - subtitle downloader 3 | // @description Allows you to download subtitles from iTunes 4 | // @license MIT 5 | // @version 1.3.9 6 | // @namespace tithen-firion.github.io 7 | // @include https://itunes.apple.com/*/movie/* 8 | // @include https://tv.apple.com/*/movie/* 9 | // @include https://tv.apple.com/*/episode/* 10 | // @grant none 11 | // @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5 12 | // @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29 13 | // @require https://cdn.jsdelivr.net/npm/m3u8-parser@4.6.0/dist/m3u8-parser.min.js 14 | // ==/UserScript== 15 | 16 | let langs = localStorage.getItem('ISD_lang-setting') || ''; 17 | 18 | function setLangToDownload() { 19 | const result = prompt('Languages to download, comma separated. Leave empty to download all subtitles.\nExample: en,de,fr', langs); 20 | if(result !== null) { 21 | langs = result; 22 | if(langs === '') 23 | localStorage.removeItem('ISD_lang-setting'); 24 | else 25 | localStorage.setItem('ISD_lang-setting', langs); 26 | } 27 | } 28 | 29 | // taken from: https://github.com/rxaviers/async-pool/blob/1e7f18aca0bd724fe15d992d98122e1bb83b41a4/lib/es7.js 30 | async function asyncPool(poolLimit, array, iteratorFn) { 31 | const ret = []; 32 | const executing = []; 33 | for (const item of array) { 34 | const p = Promise.resolve().then(() => iteratorFn(item, array)); 35 | ret.push(p); 36 | 37 | if (poolLimit <= array.length) { 38 | const e = p.then(() => executing.splice(executing.indexOf(e), 1)); 39 | executing.push(e); 40 | if (executing.length >= poolLimit) { 41 | await Promise.race(executing); 42 | } 43 | } 44 | } 45 | return Promise.all(ret); 46 | } 47 | 48 | class ProgressBar { 49 | constructor(max) { 50 | this.current = 0; 51 | this.max = max; 52 | 53 | let container = document.querySelector('#userscript_progress_bars'); 54 | if(container === null) { 55 | container = document.createElement('div'); 56 | container.id = 'userscript_progress_bars' 57 | document.body.appendChild(container); 58 | container.style.position = 'fixed'; 59 | container.style.top = 0; 60 | container.style.left = 0; 61 | container.style.width = '100%'; 62 | container.style.background = 'red'; 63 | container.style.zIndex = '99999999'; 64 | } 65 | 66 | this.progressElement = document.createElement('div'); 67 | this.progressElement.style.width = '100%'; 68 | this.progressElement.style.height = '20px'; 69 | this.progressElement.style.background = 'transparent'; 70 | 71 | container.appendChild(this.progressElement); 72 | } 73 | 74 | increment() { 75 | this.current += 1; 76 | if(this.current <= this.max) { 77 | let p = this.current / this.max * 100; 78 | this.progressElement.style.background = `linear-gradient(to right, green ${p}%, transparent ${p}%)`; 79 | } 80 | } 81 | 82 | destroy() { 83 | this.progressElement.remove(); 84 | } 85 | } 86 | 87 | async function getText(url) { 88 | const response = await fetch(url); 89 | if(!response.ok) { 90 | console.log(response); 91 | throw new Error('Something went wrong, server returned status code ' + response.status); 92 | } 93 | return response.text(); 94 | } 95 | 96 | async function getM3U8(url) { 97 | const parser = new m3u8Parser.Parser(); 98 | parser.push(await getText(url)); 99 | parser.end(); 100 | return parser.manifest; 101 | } 102 | 103 | async function getSubtitleSegment(url, done) { 104 | const text = await getText(url); 105 | done(); 106 | return text; 107 | } 108 | 109 | function filterLangs(subInfo) { 110 | if(langs === '') 111 | return subInfo; 112 | else { 113 | const regularExpression = new RegExp( 114 | '^(' + langs 115 | .replace(/\[/g, '\\[') 116 | .replace(/\]/g, '\\]') 117 | .replace(/\-/g, '\\-') 118 | .replace(/\s/g, '') 119 | .replace(/,/g, '|') 120 | + ')' 121 | ); 122 | const filteredLangs = []; 123 | for(const entry of subInfo) { 124 | if(entry.language.match(regularExpression)) 125 | filteredLangs.push(entry); 126 | } 127 | return filteredLangs; 128 | } 129 | } 130 | 131 | async function _download(name, url) { 132 | name = name.replace(/[:*?"<>|\\\/]+/g, '_'); 133 | 134 | const mainProgressBar = new ProgressBar(1); 135 | const SUBTITLES = (await getM3U8(url)).mediaGroups.SUBTITLES; 136 | const keys = Object.keys(SUBTITLES); 137 | 138 | if(keys.length === 0) { 139 | alert('No subtitles found!'); 140 | mainProgressBar.destroy(); 141 | return; 142 | } 143 | 144 | let selectedKey = null; 145 | for(const regexp of ['_ak$', '-ak-', '_ap$', '-ap-', , '_ap1$', '-ap1-', , '_ap3$', '-ap3-']) { 146 | for(const key of keys) { 147 | if(key.match(regexp) !== null) { 148 | selectedKey = key; 149 | break; 150 | } 151 | } 152 | if(selectedKey !== null) 153 | break; 154 | } 155 | 156 | if(selectedKey === null) { 157 | selectedKey = keys[0]; 158 | alert('Warnign, unknown subtitle type: ' + selectedKey + '\n\nReport that on script\'s page.'); 159 | } 160 | 161 | const subGroup = SUBTITLES[selectedKey]; 162 | 163 | let subInfo = Object.values(subGroup); 164 | subInfo = filterLangs(subInfo); 165 | mainProgressBar.max = subInfo.length; 166 | 167 | const zip = new JSZip(); 168 | 169 | for(const entry of subInfo) { 170 | let lang = entry.language; 171 | if(entry.forced) lang += '[forced]'; 172 | if(typeof entry.characteristics !== 'undefined') lang += '[cc]'; 173 | const langURL = new URL(entry.uri, url).href; 174 | const segments = (await getM3U8(langURL)).segments; 175 | 176 | const subProgressBar = new ProgressBar(segments.length); 177 | const partial = segmentUrl => getSubtitleSegment(segmentUrl, subProgressBar.increment.bind(subProgressBar)); 178 | 179 | const segmentURLs = []; 180 | for(const segment of segments) { 181 | segmentURLs.push(new URL(segment.uri, langURL).href); 182 | } 183 | 184 | const subtitleSegments = await asyncPool(20, segmentURLs, partial); 185 | let subtitleContent = subtitleSegments.join('\n\n'); 186 | // this gets rid of all WEBVTT lines except for the first one 187 | subtitleContent = subtitleContent.replace(/\nWEBVTT\n.*?\n\n/g, '\n'); 188 | subtitleContent = subtitleContent.replace(/\n{3,}/g, '\n\n'); 189 | 190 | // add RTL Unicode character to Arabic subs to all lines except for: 191 | // - lines that already have it (\u202B or \u200F) 192 | // - first two lines of the file (WEBVTT and X-TIMESTAMP) 193 | // - timestamps (may match the actual subtitle lines but it's unlikely) 194 | // - empty lines 195 | if(lang.startsWith('ar')) 196 | subtitleContent = subtitleContent.replace(/^(?!\u202B|\u200F|WEBVTT|X-TIMESTAMP|\d{2}:\d{2}:\d{2}\.\d{3} \-\-> \d{2}:\d{2}:\d{2}\.\d{3}|\n)/gm, '\u202B'); 197 | 198 | zip.file(`${name} WEBRip.iTunes.${lang}.vtt`, subtitleContent); 199 | 200 | subProgressBar.destroy(); 201 | mainProgressBar.increment(); 202 | } 203 | 204 | const content = await zip.generateAsync({type:"blob"}); 205 | mainProgressBar.destroy(); 206 | saveAs(content, `${name}.zip`); 207 | } 208 | 209 | async function download(name, url) { 210 | try { 211 | await _download(name, url); 212 | } 213 | catch(error) { 214 | console.error(error); 215 | alert('Uncaught error!\nLine: ' + error.lineNumber + '\n' + error); 216 | } 217 | } 218 | 219 | function findUrl(included) { 220 | for(const item of included) { 221 | try { 222 | return item.attributes.assets[0].hlsUrl; 223 | } 224 | catch(ignore){} 225 | } 226 | return null; 227 | } 228 | 229 | function findUrl2(playables) { 230 | for(const playable of Object.values(playables)) { 231 | let url; 232 | try { 233 | url = playable.itunesMediaApiData.offers[0].hlsUrl; 234 | } 235 | catch(ignore) { 236 | try { 237 | url = playable.assets.hlsUrl; 238 | } 239 | catch(ignore) { 240 | continue; 241 | } 242 | } 243 | 244 | return [ 245 | playable.title, 246 | url 247 | ]; 248 | } 249 | return [null, null]; 250 | } 251 | 252 | const parsers = { 253 | 'tv.apple.com': data => { 254 | for(const value of Object.values(data)) { 255 | try{ 256 | const content = value.content; 257 | let playables = null; 258 | let title = null; 259 | let title2 = null; 260 | let url = null; 261 | if(content.type === 'Movie') { 262 | playables = content.playables || value.playables; 263 | } 264 | else if(content.type === 'Episode') { 265 | playables = value.playables; 266 | const season = content.seasonNumber.toString().padStart(2, '0'); 267 | const episode = content.episodeNumber.toString().padStart(2, '0'); 268 | title = `${content.showTitle} S${season}E${episode}`; 269 | } 270 | else { 271 | throw "???"; 272 | } 273 | 274 | [title2, url] = findUrl2(playables); 275 | return [ 276 | title || title2, 277 | url 278 | ]; 279 | } 280 | catch(ignore){} 281 | } 282 | return [null, null]; 283 | }, 284 | 'itunes.apple.com': data => { 285 | data = Object.values(data)[0]; 286 | let name = data.data.attributes.name; 287 | const year = (data.data.attributes.releaseDate || '').substr(0, 4); 288 | name = name.replace(new RegExp('\\s*\\(' + year + '\\)\\s*$'), ''); 289 | name += ` (${year})`; 290 | return [ 291 | name, 292 | findUrl(data.included) 293 | ]; 294 | } 295 | } 296 | 297 | async function parseData(text) { 298 | const data = JSON.parse(text); 299 | const [name, m3u8Url] = parsers[document.location.hostname](data); 300 | if(m3u8Url === null) { 301 | alert("Subtitles URL not found. Make sure you're logged in!"); 302 | return; 303 | } 304 | 305 | const container = document.createElement('div'); 306 | container.style.position = 'absolute'; 307 | container.style.zIndex = '99999998'; 308 | container.style.top = '45px'; 309 | container.style.left = '5px'; 310 | container.style.textAlign = 'center'; 311 | 312 | const button = document.createElement('a'); 313 | button.classList.add('we-button'); 314 | button.classList.add('we-button--compact'); 315 | button.classList.add('commerce-button'); 316 | button.style.padding = '3px 8px'; 317 | button.style.display = 'block'; 318 | button.style.marginBottom = '10px'; 319 | button.href = '#'; 320 | 321 | const langButton = button.cloneNode(); 322 | langButton.innerHTML = 'Languages'; 323 | langButton.addEventListener('click', setLangToDownload); 324 | container.append(langButton); 325 | 326 | button.innerHTML = 'Download subtitles'; 327 | button.addEventListener('click', e => { 328 | download(name, m3u8Url); 329 | }); 330 | container.append(button); 331 | document.body.prepend(container); 332 | } 333 | 334 | (async () => { 335 | let element = document.querySelector('#shoebox-ember-data-store, #shoebox-uts-api, #shoebox-uts-api-cache'); 336 | if(element === null) { 337 | const parser = new DOMParser(); 338 | const doc = parser.parseFromString(await getText(window.location.href), 'text/html'); 339 | element = doc.querySelector('#shoebox-ember-data-store, #shoebox-uts-api, #shoebox-uts-api-cache'); 340 | } 341 | if(element !== null) { 342 | try { 343 | await parseData(element.textContent); 344 | } 345 | catch(error) { 346 | console.error(error); 347 | alert('Uncaught error!\nLine: ' + error.lineNumber + '\n' + error); 348 | } 349 | } 350 | else { 351 | alert('Movie info not found!') 352 | } 353 | })(); 354 | -------------------------------------------------------------------------------- /Amazon_Video_-_subtitle_downloader.user.js: -------------------------------------------------------------------------------- 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 |
    70 |
  1. Amazon subtitle downloader
  2. 71 |
  3. Add episode title to filename:
  4. 72 |
  5. Scroll to the bottom to load more episodes
  6. 73 |
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 = ""; 176 | suffix = ""; 177 | } 178 | if(bold) { 179 | prefix += ""; 180 | suffix = "" + 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
    "; 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); 757 | -------------------------------------------------------------------------------- /Netflix_-_subtitle_downloader.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Netflix - subtitle downloader 3 | // @description Allows you to download subtitles from Netflix 4 | // @license MIT 5 | // @version 4.2.8 6 | // @namespace tithen-firion.github.io 7 | // @include https://www.netflix.com/* 8 | // @grant unsafeWindow 9 | // @require https://cdn.jsdelivr.net/npm/jszip@3.7.1/dist/jszip.min.js 10 | // @require https://cdn.jsdelivr.net/npm/file-saver-es@2.0.5/dist/FileSaver.min.js 11 | // ==/UserScript== 12 | 13 | class ProgressBar { 14 | constructor(max) { 15 | this.current = 0; 16 | this.max = max; 17 | 18 | let container = document.querySelector('#userscript_progress_bars'); 19 | if(container === null) { 20 | container = document.createElement('div'); 21 | container.id = 'userscript_progress_bars' 22 | document.body.appendChild(container) 23 | container.style 24 | container.style.position = 'fixed'; 25 | container.style.top = 0; 26 | container.style.left = 0; 27 | container.style.width = '100%'; 28 | container.style.background = 'red'; 29 | container.style.zIndex = '99999999'; 30 | } 31 | 32 | this.progressElement = document.createElement('div'); 33 | this.progressElement.innerHTML = 'Click to stop'; 34 | this.progressElement.style.cursor = 'pointer'; 35 | this.progressElement.style.fontSize = '16px'; 36 | this.progressElement.style.textAlign = 'center'; 37 | this.progressElement.style.width = '100%'; 38 | this.progressElement.style.height = '20px'; 39 | this.progressElement.style.background = 'transparent'; 40 | this.stop = new Promise(resolve => { 41 | this.progressElement.addEventListener('click', () => {resolve(STOP_THE_DOWNLOAD)}); 42 | }); 43 | 44 | container.appendChild(this.progressElement); 45 | } 46 | 47 | increment() { 48 | this.current += 1; 49 | if(this.current <= this.max) { 50 | let p = this.current / this.max * 100; 51 | this.progressElement.style.background = `linear-gradient(to right, green ${p}%, transparent ${p}%)`; 52 | } 53 | } 54 | 55 | destroy() { 56 | this.progressElement.remove(); 57 | } 58 | } 59 | 60 | const STOP_THE_DOWNLOAD = 'NETFLIX_SUBTITLE_DOWNLOADER_STOP_THE_DOWNLOAD'; 61 | 62 | const WEBVTT = 'webvtt-lssdh-ios8'; 63 | const DFXP = 'dfxp-ls-sdh'; 64 | const SIMPLE = 'simplesdh'; 65 | const IMSC1_1 = 'imsc1.1'; 66 | const ALL_FORMATS = [IMSC1_1, DFXP, WEBVTT, SIMPLE]; 67 | const ALL_FORMATS_prefer_vtt = [WEBVTT, IMSC1_1, DFXP, SIMPLE]; 68 | 69 | const FORMAT_NAMES = {}; 70 | FORMAT_NAMES[WEBVTT] = 'WebVTT'; 71 | FORMAT_NAMES[DFXP] = 'IMSC1.1/DFXP/XML'; 72 | 73 | const EXTENSIONS = {}; 74 | EXTENSIONS[WEBVTT] = 'vtt'; 75 | EXTENSIONS[DFXP] = 'dfxp'; 76 | EXTENSIONS[SIMPLE] = 'xml'; 77 | EXTENSIONS[IMSC1_1] = 'xml'; 78 | 79 | const DOWNLOAD_MENU = ` 80 |
      81 |
    1. Netflix subtitle downloader
    2. 82 |
    3. Download subs for this episodemovie
    4. 83 |
    5. Download subs from this ep till last available
    6. 84 |
    7. Download subs for this season
    8. 85 |
    9. Download subs for all seasons
    10. 86 |
    11. Add episode title to filename:
    12. 87 |
    13. Force Netflix to show all languages:
    14. 88 |
    15. Preferred locale:
    16. 89 |
    17. Languages to download:
    18. 90 |
    19. Subtitle format: prefer
    20. 91 |
    21. Batch delay:
    22. 92 |
    93 | `; 94 | 95 | const SCRIPT_CSS = ` 96 | #subtitle-downloader-menu { 97 | position: absolute; 98 | display: none; 99 | width: 300px; 100 | top: 0; 101 | left: calc( 50% - 150px ); 102 | } 103 | #subtitle-downloader-menu ol { 104 | list-style: none; 105 | position: relative; 106 | width: 300px; 107 | background: #333; 108 | color: #fff; 109 | padding: 0; 110 | margin: auto; 111 | font-size: 12px; 112 | z-index: 99999998; 113 | } 114 | body:hover #subtitle-downloader-menu { display: block; } 115 | #subtitle-downloader-menu li { padding: 10px; } 116 | #subtitle-downloader-menu li.header { font-weight: bold; } 117 | #subtitle-downloader-menu li:not(.header):hover { background: #666; } 118 | #subtitle-downloader-menu li:not(.header) { 119 | display: none; 120 | cursor: pointer; 121 | } 122 | #subtitle-downloader-menu:hover li { display: block; } 123 | 124 | #subtitle-downloader-menu:not(.series) .series{ display: none; } 125 | #subtitle-downloader-menu.series .not-series{ display: none; } 126 | `; 127 | 128 | const SUB_TYPES = { 129 | 'subtitles': '', 130 | 'closedcaptions': '[cc]' 131 | }; 132 | 133 | let idOverrides = {}; 134 | let subCache = {}; 135 | let titleCache = {}; 136 | 137 | let batch = null; 138 | try { 139 | batch = JSON.parse(sessionStorage.getItem('NSD_batch')); 140 | } 141 | catch(ignore) {} 142 | 143 | let batchAll = null; 144 | let batchSeason = null; 145 | let batchToEnd = null; 146 | 147 | let epTitleInFilename = localStorage.getItem('NSD_ep-title-in-filename') === 'true'; 148 | let forceSubs = localStorage.getItem('NSD_force-all-lang') !== 'false'; 149 | let prefLocale = localStorage.getItem('NSD_pref-locale') || ''; 150 | let langs = localStorage.getItem('NSD_lang-setting') || ''; 151 | let subFormat = localStorage.getItem('NSD_sub-format') || WEBVTT; 152 | let batchDelay = parseFloat(localStorage.getItem('NSD_batch-delay') || '0'); 153 | 154 | const setEpTitleInFilename = () => { 155 | document.querySelector('#subtitle-downloader-menu .ep-title-in-filename > span').innerHTML = (epTitleInFilename ? 'on' : 'off'); 156 | }; 157 | const setForceText = () => { 158 | document.querySelector('#subtitle-downloader-menu .force-all-lang > span').innerHTML = (forceSubs ? 'on' : 'off'); 159 | }; 160 | const setLocaleText = () => { 161 | document.querySelector('#subtitle-downloader-menu .pref-locale > span').innerHTML = (prefLocale === '' ? 'disabled' : prefLocale); 162 | }; 163 | const setLangsText = () => { 164 | document.querySelector('#subtitle-downloader-menu .lang-setting > span').innerHTML = (langs === '' ? 'all' : langs); 165 | }; 166 | const setFormatText = () => { 167 | document.querySelector('#subtitle-downloader-menu .sub-format > span').innerHTML = FORMAT_NAMES[subFormat]; 168 | }; 169 | const setBatchDelayText = () => { 170 | document.querySelector('#subtitle-downloader-menu .batch-delay > span').innerHTML = batchDelay; 171 | }; 172 | 173 | const setBatch = b => { 174 | if(b === null) 175 | sessionStorage.removeItem('NSD_batch'); 176 | else 177 | sessionStorage.setItem('NSD_batch', JSON.stringify(b)); 178 | }; 179 | 180 | const toggleEpTitleInFilename = () => { 181 | epTitleInFilename = !epTitleInFilename; 182 | if(epTitleInFilename) 183 | localStorage.setItem('NSD_ep-title-in-filename', epTitleInFilename); 184 | else 185 | localStorage.removeItem('NSD_ep-title-in-filename'); 186 | setEpTitleInFilename(); 187 | }; 188 | const toggleForceLang = () => { 189 | forceSubs = !forceSubs; 190 | if(forceSubs) 191 | localStorage.removeItem('NSD_force-all-lang'); 192 | else 193 | localStorage.setItem('NSD_force-all-lang', forceSubs); 194 | document.location.reload(); 195 | }; 196 | const setPreferredLocale = () => { 197 | const result = prompt('Netflix limited "force all subtitles" usage. Now you have to set a preferred locale to show subtitles for that language.\nPossible values (you can enter only one at a time!):\nar, cs, da, de, el, en, es, es-ES, fi, fr, he, hi, hr, hu, id, it, ja, ko, ms, nb, nl, pl, pt, pt-BR, ro, ru, sv, ta, te, th, tr, uk, vi, zh', prefLocale); 198 | if(result !== null) { 199 | prefLocale = result; 200 | if(prefLocale === '') 201 | localStorage.removeItem('NSD_pref-locale'); 202 | else 203 | localStorage.setItem('NSD_pref-locale', prefLocale); 204 | document.location.reload(); 205 | } 206 | }; 207 | const setLangToDownload = () => { 208 | const result = prompt('Languages to download, comma separated. Leave empty to download all subtitles.\nExample: en,de,fr', langs); 209 | if(result !== null) { 210 | langs = result; 211 | if(langs === '') 212 | localStorage.removeItem('NSD_lang-setting'); 213 | else 214 | localStorage.setItem('NSD_lang-setting', langs); 215 | setLangsText(); 216 | } 217 | }; 218 | const setSubFormat = () => { 219 | if(subFormat === WEBVTT) { 220 | localStorage.setItem('NSD_sub-format', DFXP); 221 | subFormat = DFXP; 222 | } 223 | else { 224 | localStorage.removeItem('NSD_sub-format'); 225 | subFormat = WEBVTT; 226 | } 227 | setFormatText(); 228 | }; 229 | const setBatchDelay = () => { 230 | let result = prompt('Delay (in seconds) between switching pages when downloading subs in batch:', batchDelay); 231 | if(result !== null) { 232 | result = parseFloat(result.replace(',', '.')); 233 | if(result < 0 || !Number.isFinite(result)) 234 | result = 0; 235 | batchDelay = result; 236 | if(batchDelay == 0) 237 | localStorage.removeItem('NSD_batch-delay'); 238 | else 239 | localStorage.setItem('NSD_batch-delay', batchDelay); 240 | setBatchDelayText(); 241 | } 242 | }; 243 | 244 | const asyncSleep = (seconds, value) => new Promise(resolve => { 245 | window.setTimeout(resolve, seconds * 1000, value); 246 | }); 247 | 248 | const popRandomElement = arr => { 249 | return arr.splice(arr.length * Math.random() << 0, 1)[0]; 250 | }; 251 | 252 | const processSubInfo = async result => { 253 | const tracks = result.timedtexttracks; 254 | const subs = {}; 255 | let reportError = true; 256 | for(const track of tracks) { 257 | if(track.isNoneTrack) 258 | continue; 259 | 260 | let type = SUB_TYPES[track.rawTrackType]; 261 | if(typeof type === 'undefined') 262 | type = `[${track.rawTrackType}]`; 263 | const variant = (typeof track.trackVariant === 'undefined' ? '' : `-${track.trackVariant}`); 264 | const lang = track.language + type + variant + (track.isForcedNarrative ? '-forced' : ''); 265 | 266 | const formats = {}; 267 | for(let format of ALL_FORMATS) { 268 | const downloadables = track.ttDownloadables[format]; 269 | if(typeof downloadables !== 'undefined') { 270 | let urls; 271 | if(typeof downloadables.downloadUrls !== 'undefined') 272 | urls = Object.values(downloadables.downloadUrls); 273 | else if(typeof downloadables.urls !== 'undefined') 274 | urls = downloadables.urls.map(({url}) => url); 275 | else { 276 | console.log('processSubInfo:', lang, Object.keys(downloadables)); 277 | if(reportError) { 278 | reportError = false; 279 | alert("Can't find subtitle URL, check the console for more information!"); 280 | } 281 | continue; 282 | } 283 | formats[format] = [urls, EXTENSIONS[format]]; 284 | } 285 | } 286 | 287 | if(Object.keys(formats).length > 0) { 288 | for(let i = 0; ; ++i) { 289 | const langKey = lang + (i == 0 ? "" : `-${i}`); 290 | if(typeof subs[langKey] === "undefined") { 291 | subs[langKey] = formats; 292 | break; 293 | } 294 | } 295 | } 296 | } 297 | subCache[result.movieId] = subs; 298 | }; 299 | 300 | const checkSubsCache = async menu => { 301 | while(getSubsFromCache(true) === null) { 302 | await asyncSleep(0.1); 303 | } 304 | 305 | // show menu if on watch page 306 | menu.style.display = (document.location.pathname.split('/')[1] === 'watch' ? '' : 'none'); 307 | 308 | if(batch !== null && batch.length > 0) { 309 | downloadBatch(true); 310 | } 311 | }; 312 | 313 | const processMetadata = data => { 314 | // add menu when it's not there 315 | let menu = document.querySelector('#subtitle-downloader-menu'); 316 | if(menu === null) { 317 | menu = document.createElement('div'); 318 | menu.id = 'subtitle-downloader-menu'; 319 | menu.innerHTML = DOWNLOAD_MENU; 320 | document.body.appendChild(menu); 321 | menu.querySelector('.download').addEventListener('click', downloadThis); 322 | menu.querySelector('.download-to-end').addEventListener('click', downloadToEnd); 323 | menu.querySelector('.download-season').addEventListener('click', downloadSeason); 324 | menu.querySelector('.download-all').addEventListener('click', downloadAll); 325 | menu.querySelector('.ep-title-in-filename').addEventListener('click', toggleEpTitleInFilename); 326 | menu.querySelector('.force-all-lang').addEventListener('click', toggleForceLang); 327 | menu.querySelector('.pref-locale').addEventListener('click', setPreferredLocale); 328 | menu.querySelector('.lang-setting').addEventListener('click', setLangToDownload); 329 | menu.querySelector('.sub-format').addEventListener('click', setSubFormat); 330 | menu.querySelector('.batch-delay').addEventListener('click', setBatchDelay); 331 | setEpTitleInFilename(); 332 | setForceText(); 333 | setLocaleText(); 334 | setLangsText(); 335 | setFormatText(); 336 | } 337 | // hide menu, at this point sub info is still missing 338 | menu.style.display = 'none'; 339 | menu.classList.remove('series'); 340 | 341 | const result = data.video; 342 | const {type, title} = result; 343 | if(type === 'show') { 344 | batchAll = []; 345 | batchSeason = []; 346 | batchToEnd = []; 347 | const allEpisodes = []; 348 | let currentSeason = 0; 349 | menu.classList.add('series'); 350 | for(const season of result.seasons) { 351 | for(const episode of season.episodes) { 352 | if(episode.id === result.currentEpisode) 353 | currentSeason = season.seq; 354 | allEpisodes.push([season.seq, episode.seq, episode.id]); 355 | titleCache[episode.id] = { 356 | type, title, 357 | season: season.seq, 358 | episode: episode.seq, 359 | subtitle: episode.title, 360 | hiddenNumber: episode.hiddenEpisodeNumbers 361 | }; 362 | } 363 | } 364 | 365 | allEpisodes.sort((a, b) => a[0] - b[0] || a[1] - b[1]); 366 | let toEnd = false; 367 | for(const [season, episode, id] of allEpisodes) { 368 | batchAll.push(id); 369 | if(season === currentSeason) 370 | batchSeason.push(id); 371 | if(id === result.currentEpisode) 372 | toEnd = true; 373 | if(toEnd) 374 | batchToEnd.push(id); 375 | } 376 | } 377 | else if(type === 'movie' || type === 'supplemental') { 378 | titleCache[result.id] = {type, title}; 379 | } 380 | else { 381 | console.debug('[Netflix Subtitle Downloader] unknown video type:', type, result) 382 | return; 383 | } 384 | checkSubsCache(menu); 385 | }; 386 | 387 | const getVideoId = () => window.location.pathname.split('/').pop(); 388 | 389 | const getXFromCache = (cache, name, silent) => { 390 | const id = getVideoId(); 391 | if(cache.hasOwnProperty(id)) 392 | return cache[id]; 393 | 394 | let newID = undefined; 395 | try { 396 | newID = unsafeWindow.netflix.falcorCache.videos[id].current.value[1]; 397 | } 398 | catch(ignore) {} 399 | if(typeof newID !== 'undefined' && cache.hasOwnProperty(newID)) 400 | return cache[newID]; 401 | 402 | newID = idOverrides[id]; 403 | if(typeof newID !== 'undefined' && cache.hasOwnProperty(newID)) 404 | return cache[newID]; 405 | 406 | if(silent === true) 407 | return null; 408 | 409 | alert("Couldn't find the " + name + ". Wait until the player is loaded. If that doesn't help refresh the page."); 410 | throw ''; 411 | }; 412 | 413 | const getSubsFromCache = silent => getXFromCache(subCache, 'subs', silent); 414 | 415 | const pad = (number, letter) => `${letter}${number.toString().padStart(2, '0')}`; 416 | 417 | const safeTitle = title => title.trim().replace(/[:*?"<>|\\\/]+/g, '_').replace(/ /g, '.'); 418 | 419 | const getTitleFromCache = () => { 420 | const title = getXFromCache(titleCache, 'title'); 421 | const titleParts = [title.title]; 422 | if(title.type === 'show') { 423 | const season = pad(title.season, 'S'); 424 | if(title.hiddenNumber) { 425 | titleParts.push(season); 426 | titleParts.push(title.subtitle); 427 | } 428 | else { 429 | titleParts.push(season + pad(title.episode, 'E')); 430 | if(epTitleInFilename) 431 | titleParts.push(title.subtitle); 432 | } 433 | } 434 | return [safeTitle(titleParts.join('.')), safeTitle(title.title)]; 435 | }; 436 | 437 | const pickFormat = formats => { 438 | const preferred = (subFormat === DFXP ? ALL_FORMATS : ALL_FORMATS_prefer_vtt); 439 | 440 | for(let format of preferred) { 441 | if(typeof formats[format] !== 'undefined') 442 | return formats[format]; 443 | } 444 | }; 445 | 446 | 447 | const _save = async (_zip, title) => { 448 | const content = await _zip.generateAsync({type:'blob'}); 449 | saveAs(content, title + '.zip'); 450 | }; 451 | 452 | const _download = async _zip => { 453 | const subs = getSubsFromCache(); 454 | const [title, seriesTitle] = getTitleFromCache(); 455 | const downloaded = []; 456 | 457 | let filteredLangs; 458 | if(langs === '') 459 | filteredLangs = Object.keys(subs); 460 | else { 461 | const regularExpression = new RegExp( 462 | '^(' + langs 463 | .replace(/\[/g, '\\[') 464 | .replace(/\]/g, '\\]') 465 | .replace(/\-/g, '\\-') 466 | .replace(/\s/g, '') 467 | .replace(/,/g, '|') 468 | + ')' 469 | ); 470 | filteredLangs = []; 471 | for(const lang of Object.keys(subs)) { 472 | if(lang.match(regularExpression)) 473 | filteredLangs.push(lang); 474 | } 475 | } 476 | 477 | const progress = new ProgressBar(filteredLangs.length); 478 | let stop = false; 479 | for(const lang of filteredLangs) { 480 | const [urls, extension] = pickFormat(subs[lang]); 481 | while(urls.length > 0) { 482 | let url = popRandomElement(urls); 483 | const resultPromise = fetch(url, {mode: "cors"}); 484 | let result; 485 | try { 486 | // Promise.any isn't supported in all browsers, use Promise.race instead 487 | result = await Promise.race([resultPromise, progress.stop, asyncSleep(30, STOP_THE_DOWNLOAD)]); 488 | } 489 | catch(e) { 490 | // the only promise that can be rejected is the one from fetch 491 | // if that happens we want to stop the download anyway 492 | result = STOP_THE_DOWNLOAD; 493 | } 494 | if(result === STOP_THE_DOWNLOAD) { 495 | stop = true; 496 | break; 497 | } 498 | progress.increment(); 499 | const data = await result.text(); 500 | if(data.length > 0) { 501 | downloaded.push({lang, data, extension}); 502 | break; 503 | } 504 | } 505 | if(stop) 506 | break; 507 | } 508 | 509 | downloaded.forEach(x => { 510 | const {lang, data, extension} = x; 511 | _zip.file(`${title}.WEBRip.Netflix.${lang}.${extension}`, data); 512 | }); 513 | 514 | if(await Promise.race([progress.stop, {}]) === STOP_THE_DOWNLOAD) 515 | stop = true; 516 | progress.destroy(); 517 | 518 | return [seriesTitle, stop]; 519 | }; 520 | 521 | const downloadThis = async () => { 522 | const _zip = new JSZip(); 523 | const [title, stop] = await _download(_zip); 524 | _save(_zip, title); 525 | }; 526 | 527 | const cleanBatch = async () => { 528 | setBatch(null); 529 | return; 530 | const cache = await caches.open('NSD'); 531 | cache.delete('/subs.zip'); 532 | await caches.delete('NSD'); 533 | } 534 | 535 | const readAsBinaryString = blob => new Promise(resolve => { 536 | const reader = new FileReader(); 537 | reader.onload = function(event) { 538 | resolve(event.target.result); 539 | }; 540 | reader.readAsBinaryString(blob); 541 | }); 542 | 543 | const downloadBatch = async auto => { 544 | const cache = await caches.open('NSD'); 545 | let zip, title, stop; 546 | if(auto === true) { 547 | try { 548 | const response = await cache.match('/subs.zip'); 549 | const blob = await response.blob(); 550 | zip = await JSZip.loadAsync(await readAsBinaryString(blob)); 551 | } 552 | catch(error) { 553 | console.error(error); 554 | alert('An error occured when loading the zip file with subs from the cache. More info in the browser console.'); 555 | await cleanBatch(); 556 | return; 557 | } 558 | } 559 | else 560 | zip = new JSZip(); 561 | 562 | try { 563 | [title, stop] = await _download(zip); 564 | } 565 | catch(error) { 566 | title = 'unknown'; 567 | stop = true; 568 | } 569 | 570 | const id = parseInt(getVideoId()); 571 | batch = batch.filter(x => x !== id); 572 | 573 | if(stop || batch.length == 0) { 574 | await _save(zip, title); 575 | await cleanBatch(); 576 | } 577 | else { 578 | setBatch(batch); 579 | cache.put('/subs.zip', new Response(await zip.generateAsync({type:'blob'}))); 580 | await asyncSleep(batchDelay); 581 | window.location = window.location.origin + '/watch/' + batch[0]; 582 | } 583 | }; 584 | 585 | const downloadAll = () => { 586 | batch = batchAll; 587 | downloadBatch(); 588 | }; 589 | 590 | const downloadSeason = () => { 591 | batch = batchSeason; 592 | downloadBatch(); 593 | }; 594 | 595 | const downloadToEnd = () => { 596 | batch = batchToEnd; 597 | downloadBatch(); 598 | }; 599 | 600 | const processMessage = e => { 601 | const {type, data} = e.detail; 602 | if(type === 'subs') 603 | processSubInfo(data); 604 | else if(type === 'id_override') 605 | idOverrides[data[0]] = data[1]; 606 | else if(type === 'metadata') 607 | processMetadata(data); 608 | } 609 | 610 | const injection = (ALL_FORMATS) => { 611 | const MANIFEST_PATTERN = new RegExp('manifest|licensedManifest'); 612 | const forceSubs = localStorage.getItem('NSD_force-all-lang') !== 'false'; 613 | const prefLocale = localStorage.getItem('NSD_pref-locale') || ''; 614 | 615 | // hide the menu when we go back to the browse list 616 | window.addEventListener('popstate', () => { 617 | const display = (document.location.pathname.split('/')[1] === 'watch' ? '' : 'none'); 618 | const menu = document.querySelector('#subtitle-downloader-menu'); 619 | menu.style.display = display; 620 | }); 621 | 622 | // hijack JSON.parse and JSON.stringify functions 623 | ((parse, stringify, open, realFetch) => { 624 | JSON.parse = function (text) { 625 | const data = parse(text); 626 | 627 | if (data && data.result && data.result.timedtexttracks && data.result.movieId) { 628 | window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'subs', data: data.result}})); 629 | } 630 | return data; 631 | }; 632 | 633 | JSON.stringify = function (data) { 634 | /*{ 635 | let text = stringify(data); 636 | if (text.includes('dfxp-ls-sdh')) 637 | console.log(text, data); 638 | }*/ 639 | 640 | if (data && typeof data.url === 'string' && data.url.search(MANIFEST_PATTERN) > -1) { 641 | for (let v of Object.values(data)) { 642 | try { 643 | if (v.profiles) { 644 | for(const profile_name of ALL_FORMATS) { 645 | if(!v.profiles.includes(profile_name)) { 646 | v.profiles.unshift(profile_name); 647 | } 648 | } 649 | } 650 | if (v.showAllSubDubTracks != null && forceSubs) 651 | v.showAllSubDubTracks = true; 652 | if (prefLocale !== '') 653 | v.preferredTextLocale = prefLocale; 654 | } 655 | catch (e) { 656 | if (e instanceof TypeError) 657 | continue; 658 | else 659 | throw e; 660 | } 661 | } 662 | } 663 | if(data && typeof data.movieId === 'number') { 664 | try { 665 | let videoId = data.params.sessionParams.uiplaycontext.video_id; 666 | if(typeof videoId === 'number' && videoId !== data.movieId) 667 | window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'id_override', data: [videoId, data.movieId]}})); 668 | } 669 | catch(ignore) {} 670 | } 671 | return stringify(data); 672 | }; 673 | 674 | XMLHttpRequest.prototype.open = function() { 675 | if(arguments[1] && arguments[1].includes('/metadata?')) 676 | this.addEventListener('load', async () => { 677 | let data = this.response; 678 | if(data instanceof Blob) 679 | data = JSON.parse(await data.text()); 680 | else if(typeof data === "string") 681 | data = JSON.parse(data); 682 | window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'metadata', data: data}})); 683 | }, false); 684 | open.apply(this, arguments); 685 | }; 686 | 687 | window.fetch = async (...args) => { 688 | const response = realFetch(...args); 689 | if(args[0] && args[0].includes('/metadata?')) { 690 | const copied = (await response).clone(); 691 | const data = await copied.json(); 692 | window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'metadata', data: data}})); 693 | } 694 | return response; 695 | }; 696 | })(JSON.parse, JSON.stringify, XMLHttpRequest.prototype.open, window.fetch); 697 | } 698 | 699 | window.addEventListener('netflix_sub_downloader_data', processMessage, false); 700 | 701 | // inject script 702 | const sc = document.createElement('script'); 703 | sc.innerHTML = '(' + injection.toString() + ')(' + JSON.stringify(ALL_FORMATS) + ')'; 704 | document.head.appendChild(sc); 705 | document.head.removeChild(sc); 706 | 707 | // add CSS style 708 | const s = document.createElement('style'); 709 | s.innerHTML = SCRIPT_CSS; 710 | document.head.appendChild(s); 711 | 712 | const observer = new MutationObserver(function(mutations) { 713 | mutations.forEach(function(mutation) { 714 | mutation.addedNodes.forEach(function(node) { 715 | // add scrollbar - Netflix doesn't expect you to have this manu languages to choose from... 716 | try { 717 | (node.parentNode || node).querySelector('.watch-video--selector-audio-subtitle').parentNode.style.overflowY = 'scroll'; 718 | } 719 | catch(ignore) {} 720 | }); 721 | }); 722 | }); 723 | observer.observe(document.body, { childList: true, subtree: true }); 724 | --------------------------------------------------------------------------------