├── .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 |
71 | - Add episode title to filename:
72 | - Scroll to the bottom to load more episodes
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 |
82 | - Download subs for this episodemovie
83 | - Download subs from this ep till last available
84 | - Download subs for this season
85 | - Download subs for all seasons
86 | - Add episode title to filename:
87 | - Force Netflix to show all languages:
88 | - Preferred locale:
89 | - Languages to download:
90 | - Subtitle format: prefer
91 | - Batch delay:
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 |
--------------------------------------------------------------------------------