Netflix - subtitle downloader

Allows you to download subtitles from Netflix

As of 2021-09-10. See the latest version.

  1. // ==UserScript==
  2. // @name Netflix - subtitle downloader
  3. // @description Allows you to download subtitles from Netflix
  4. // @license MIT
  5. // @version 4.0.1
  6. // @namespace tithen-firion.github.io
  7. // @include https://www.netflix.com/*
  8. // @grant unsafeWindow
  9. // @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5
  10. // @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29
  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 ALL_FORMATS = [WEBVTT, DFXP, SIMPLE];
  66.  
  67. const FORMAT_NAMES = {};
  68. FORMAT_NAMES[WEBVTT] = 'WebVTT';
  69. FORMAT_NAMES[DFXP] = 'DFXP/XML';
  70.  
  71. const EXTENSIONS = {};
  72. EXTENSIONS[WEBVTT] = 'vtt';
  73. EXTENSIONS[DFXP] = 'dfxp';
  74. EXTENSIONS[SIMPLE] = 'xml';
  75.  
  76. const DOWNLOAD_MENU = `<li class="header">Netflix subtitle downloader</li>
  77. <li class="download">Download subs for this episode</li>
  78. <!--<li class="download-all">Download subs from this ep till last available</li>-->
  79. <li class="ep-title-in-filename">Add episode title to filename: <span></span></li>
  80. <li class="force-all-lang">Force Netflix to show all languages: <span></span></li>
  81. <li class="lang-setting">Languages to download: <span></span></li>
  82. <li class="sub-format">Subtitle format: prefer <span></span></li>`;
  83.  
  84. const SCRIPT_CSS = `
  85. .subtitle-downloader-menu {
  86. list-style: none;
  87. position: relative;
  88. display: none;
  89. width: 300px;
  90. background: #333;
  91. color: #fff;
  92. padding: 0;
  93. margin: auto;
  94. font-size: 12px;
  95. }
  96. body:hover .subtitle-downloader-menu { display: block; }
  97. .subtitle-downloader-menu li { padding: 10px; }
  98. .subtitle-downloader-menu li.header { font-weight: bold; }
  99. .subtitle-downloader-menu li:not(.header):hover { background: #666; }
  100. .subtitle-downloader-menu li:not(.header) {
  101. display: none;
  102. cursor: pointer;
  103. }
  104. .subtitle-downloader-menu:hover li { display: block; }
  105. `;
  106.  
  107. const SUB_TYPES = {
  108. 'subtitles': '',
  109. 'closedcaptions': '[cc]'
  110. };
  111.  
  112. let idOverrides = {};
  113. let zip;
  114. let subCache = {};
  115. let titleCache = {};
  116. let batch = false;
  117.  
  118. let epTitleInFilename = localStorage.getItem('NSD_ep-title-in-filename') === 'true';
  119. let forceSubs = localStorage.getItem('NSD_force-all-lang') !== 'false';
  120. let langs = localStorage.getItem('NSD_lang-setting') || '';
  121. let subFormat = localStorage.getItem('NSD_sub-format') || WEBVTT;
  122.  
  123. const setEpTitleInFilename = () => {
  124. document.querySelector('.subtitle-downloader-menu > .ep-title-in-filename > span').innerHTML = (epTitleInFilename ? 'on' : 'off');
  125. };
  126. const setForceText = () => {
  127. document.querySelector('.subtitle-downloader-menu > .force-all-lang > span').innerHTML = (forceSubs ? 'on' : 'off');
  128. };
  129. const setLangsText = () => {
  130. document.querySelector('.subtitle-downloader-menu > .lang-setting > span').innerHTML = (langs === '' ? 'all' : langs);
  131. };
  132. const setFormatText = () => {
  133. document.querySelector('.subtitle-downloader-menu > .sub-format > span').innerHTML = FORMAT_NAMES[subFormat];
  134. };
  135.  
  136. const toggleEpTitleInFilename = () => {
  137. epTitleInFilename = !epTitleInFilename;
  138. if(epTitleInFilename)
  139. localStorage.setItem('NSD_ep-title-in-filename', epTitleInFilename);
  140. else
  141. localStorage.removeItem('NSD_ep-title-in-filename');
  142. setEpTitleInFilename();
  143. };
  144. const toggleForceLang = () => {
  145. forceSubs = !forceSubs;
  146. if(forceSubs)
  147. localStorage.removeItem('NSD_force-all-lang');
  148. else
  149. localStorage.setItem('NSD_force-all-lang', forceSubs);
  150. document.location.reload();
  151. };
  152. const setLangToDownload = () => {
  153. const result = prompt('Languages to download, comma separated. Leave empty to download all subtitles.\nExample: en,de,fr', langs);
  154. if(result !== null) {
  155. langs = result;
  156. if(langs === '')
  157. localStorage.removeItem('NSD_lang-setting');
  158. else
  159. localStorage.setItem('NSD_lang-setting', langs);
  160. setLangsText();
  161. }
  162. };
  163. const setSubFormat = () => {
  164. if(subFormat === WEBVTT) {
  165. localStorage.setItem('NSD_sub-format', DFXP);
  166. subFormat = DFXP;
  167. }
  168. else {
  169. localStorage.removeItem('NSD_sub-format');
  170. subFormat = WEBVTT;
  171. }
  172. setFormatText();
  173. };
  174.  
  175. const asyncSleep = (seconds, value) => new Promise(resolve => {
  176. window.setTimeout(resolve, seconds * 1000, value);
  177. });
  178.  
  179. const popRandomElement = arr => {
  180. return arr.splice(arr.length * Math.random() << 0, 1)[0];
  181. };
  182.  
  183. const processSubInfo = async result => {
  184. const tracks = result.timedtexttracks;
  185. const subs = {};
  186. for(const track of tracks) {
  187. if(track.isNoneTrack)
  188. continue;
  189.  
  190. let type = SUB_TYPES[track.rawTrackType];
  191. if(typeof type === 'undefined')
  192. type = `[${track.rawTrackType}]`;
  193. const lang = track.language + type + (track.isForcedNarrative ? '-forced' : '');
  194.  
  195. const formats = {};
  196. for(let format of ALL_FORMATS) {
  197. if(typeof track.ttDownloadables[format] !== 'undefined')
  198. formats[format] = [Object.values(track.ttDownloadables[format].downloadUrls), EXTENSIONS[format]];
  199. }
  200.  
  201. if(Object.keys(formats).length > 0)
  202. subs[lang] = formats;
  203. }
  204. subCache[result.movieId] = subs;
  205.  
  206. // add menu when it's not there
  207. if(document.querySelector('.subtitle-downloader-menu') === null) {
  208. let ol = document.createElement('ol');
  209. ol.setAttribute('class', 'subtitle-downloader-menu player-timed-text-tracks track-list track-list-subtitles');
  210. ol.innerHTML = DOWNLOAD_MENU;
  211. document.body.appendChild(ol);
  212. ol.querySelector('.download').addEventListener('click', downloadThis);
  213. //ol.querySelector('.download-all').addEventListener('click', downloadAll);
  214. ol.querySelector('.ep-title-in-filename').addEventListener('click', toggleEpTitleInFilename);
  215. ol.querySelector('.force-all-lang').addEventListener('click', toggleForceLang);
  216. ol.querySelector('.lang-setting').addEventListener('click', setLangToDownload);
  217. ol.querySelector('.sub-format').addEventListener('click', setSubFormat);
  218. setEpTitleInFilename();
  219. setForceText();
  220. setLangsText();
  221. setFormatText();
  222. }
  223.  
  224. if(batch) {
  225. downloadAll();
  226. }
  227. };
  228.  
  229. const processMetadata = data => {
  230. const result = data.video;
  231. const {type, title} = result;
  232. if(type === 'show') {
  233. for(const season of result.seasons) {
  234. for(const episode of season.episodes) {
  235. titleCache[episode.id] = {
  236. type, title,
  237. season: season.seq,
  238. episode: episode.seq,
  239. subtitle: episode.title,
  240. hiddenNumber: episode.hiddenEpisodeNumbers
  241. };
  242. }
  243. }
  244. }
  245. else if(type === 'movie' || type === 'supplemental') {
  246. titleCache[result.id] = {type, title};
  247. }
  248. else {
  249. console.debug('[Netflix Subtitle Downloader] unknown video type:', type, result)
  250. }
  251. };
  252.  
  253. const getXFromCache = (cache, name) => {
  254. const id = window.location.pathname.split('/').pop();
  255. if(cache.hasOwnProperty(id))
  256. return cache[id];
  257.  
  258. let newID = undefined;
  259. try {
  260. newID = unsafeWindow.netflix.falcorCache.videos[id].current.value[1];
  261. }
  262. catch(ignore) {}
  263. if(typeof newID !== 'undefined' && cache.hasOwnProperty(newID))
  264. return cache[newID];
  265.  
  266. newID = idOverrides[id];
  267. if(typeof newID !== 'undefined' && cache.hasOwnProperty(newID))
  268. return cache[newID];
  269.  
  270. alert("Couldn't find the " + name + ". Wait until the player is loaded. If that doesn't help refresh the page.");
  271. throw '';
  272. };
  273.  
  274. const getSubsFromCache = () => getXFromCache(subCache, 'subs');
  275.  
  276. const pad = (number, letter) => `${letter}${number.toString().padStart(2, '0')}`;
  277.  
  278. const getTitleFromCache = () => {
  279. const title = getXFromCache(titleCache, 'title');
  280. const titleParts = [title.title];
  281. if(title.type === 'show') {
  282. const season = pad(title.season, 'S');
  283. if(title.hiddenNumber) {
  284. titleParts.push(season);
  285. titleParts.push(title.subtitle);
  286. }
  287. else {
  288. titleParts.push(season + pad(title.episode, 'E'));
  289. if(epTitleInFilename)
  290. titleParts.push(title.subtitle);
  291. }
  292. }
  293. return titleParts.join('.').trim().replace(/[:*?"<>|\\\/]+/g, '_').replace(/ /g, '.');
  294. };
  295.  
  296. const pickFormat = formats => {
  297. const preferred = ALL_FORMATS.slice();
  298. if(subFormat === DFXP)
  299. preferred.push(preferred.shift());
  300.  
  301. for(let format of preferred) {
  302. if(typeof formats[format] !== 'undefined')
  303. return formats[format];
  304. }
  305. };
  306.  
  307.  
  308. const _save = async (_zip, title) => {
  309. const content = await _zip.generateAsync({type:'blob'});
  310. saveAs(content, title + '.zip');
  311. };
  312.  
  313. const _download = async _zip => {
  314. const subs = getSubsFromCache();
  315. const title = getTitleFromCache();
  316. const downloaded = [];
  317.  
  318. let filteredLangs;
  319. if(langs === '')
  320. filteredLangs = Object.keys(subs);
  321. else {
  322. const regularExpression = new RegExp(
  323. '^(' + langs
  324. .replace(/\[/g, '\\[')
  325. .replace(/\]/g, '\\]')
  326. .replace(/\-/g, '\\-')
  327. .replace(/\s/g, '')
  328. .replace(/,/g, '|')
  329. + ')'
  330. );
  331. filteredLangs = [];
  332. for(const lang of Object.keys(subs)) {
  333. if(lang.match(regularExpression))
  334. filteredLangs.push(lang);
  335. }
  336. }
  337.  
  338. const progress = new ProgressBar(filteredLangs.length);
  339. let stop = false;
  340. for(const lang of filteredLangs) {
  341. const [urls, extension] = pickFormat(subs[lang]);
  342. while(urls.length > 0) {
  343. let url = popRandomElement(urls);
  344. const resultPromise = fetch(url, {mode: "cors"});
  345. let result;
  346. try {
  347. // Promise.any isn't supported in all browsers, use Promise.race instead
  348. result = await Promise.race([resultPromise, progress.stop, asyncSleep(30, STOP_THE_DOWNLOAD)]);
  349. }
  350. catch(e) {
  351. // the only promise that can be rejected is the one from fetch
  352. // if that happens we want to stop the download anyway
  353. result = STOP_THE_DOWNLOAD;
  354. }
  355. if(result === STOP_THE_DOWNLOAD) {
  356. stop = true;
  357. break;
  358. }
  359. progress.increment();
  360. const data = await result.text();
  361. if(data.length > 0) {
  362. downloaded.push({lang, data, extension});
  363. break;
  364. }
  365. }
  366. if(stop)
  367. break;
  368. }
  369.  
  370. downloaded.forEach(x => {
  371. const {lang, data, extension} = x;
  372. _zip.file(`${title}.WEBRip.Netflix.${lang}.${extension}`, data);
  373. });
  374.  
  375. if(await Promise.race([progress.stop, {}]) === STOP_THE_DOWNLOAD)
  376. stop = true;
  377. progress.destroy();
  378.  
  379. return [title, stop];
  380. };
  381.  
  382. const downloadThis = async () => {
  383. const _zip = new JSZip();
  384. const [title, stop] = await _download(_zip);
  385. _save(_zip, title);
  386. };
  387.  
  388. /*const downloadAll = async () => {
  389. zip = zip || new JSZip();
  390. batch = true;
  391. const [title, stop] = await _download(zip);
  392. const nextEp = document.querySelector(NEXT_EPISODE);
  393. if(!stop && nextEp)
  394. nextEp.click();
  395. else {
  396. await _save(zip, title);
  397. zip = undefined;
  398. batch = false;
  399. }
  400. };*/
  401.  
  402. const processMessage = e => {
  403. const {type, data} = e.detail;
  404. if(type === 'subs')
  405. processSubInfo(data);
  406. else if(type === 'id_override')
  407. idOverrides[data[0]] = data[1];
  408. else if(type === 'metadata')
  409. processMetadata(data);
  410. }
  411.  
  412. const injection = () => {
  413. const WEBVTT = 'webvtt-lssdh-ios8';
  414. const MANIFEST_PATTERN = new RegExp('manifest|licensedManifest');
  415. const forceSubs = localStorage.getItem('NSD_force-all-lang') !== 'false';
  416.  
  417. // hijack JSON.parse and JSON.stringify functions
  418. ((parse, stringify, open) => {
  419. JSON.parse = function (text) {
  420. const data = parse(text);
  421.  
  422. if (data && data.result && data.result.timedtexttracks && data.result.movieId) {
  423. window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'subs', data: data.result}}));
  424. }
  425. return data;
  426. };
  427.  
  428. JSON.stringify = function (data) {
  429. /*{
  430. let text = stringify(data);
  431. if (text.includes('dfxp-ls-sdh'))
  432. console.log(text, data);
  433. }*/
  434.  
  435. if (data && typeof data.url === 'string' && data.url.search(MANIFEST_PATTERN) > -1) {
  436. for (let v of Object.values(data)) {
  437. try {
  438. if (v.profiles)
  439. v.profiles.unshift(WEBVTT);
  440. if (v.showAllSubDubTracks != null && forceSubs)
  441. v.showAllSubDubTracks = true;
  442. }
  443. catch (e) {
  444. if (e instanceof TypeError)
  445. continue;
  446. else
  447. throw e;
  448. }
  449. }
  450. }
  451. if(data && typeof data.movieId === 'number') {
  452. try {
  453. let videoId = data.params.sessionParams.uiplaycontext.video_id;
  454. if(typeof videoId === 'number' && videoId !== data.movieId)
  455. window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'id_override', data: [videoId, data.movieId]}}));
  456. }
  457. catch(ignore) {}
  458. }
  459. return stringify(data);
  460. };
  461.  
  462. XMLHttpRequest.prototype.open = function() {
  463. if(arguments[1] && arguments[1].includes('/metadata?'))
  464. this.addEventListener('load', () => {
  465. window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'metadata', data: this.response}}));
  466. }, false);
  467. open.apply(this, arguments);
  468. };
  469. })(JSON.parse, JSON.stringify, XMLHttpRequest.prototype.open);
  470. }
  471.  
  472. window.addEventListener('netflix_sub_downloader_data', processMessage, false);
  473.  
  474. // inject script
  475. const sc = document.createElement('script');
  476. sc.innerHTML = '(' + injection.toString() + ')()';
  477. document.head.appendChild(sc);
  478. document.head.removeChild(sc);
  479.  
  480. // add CSS style
  481. const s = document.createElement('style');
  482. s.innerHTML = SCRIPT_CSS;
  483. document.head.appendChild(s);
  484.  
  485. const observer = new MutationObserver(function(mutations) {
  486. mutations.forEach(function(mutation) {
  487. mutation.addedNodes.forEach(function(node) {
  488. // add scrollbar - Netflix doesn't expect you to have this manu languages to choose from...
  489. try {
  490. (node.parentNode || node).querySelector('.watch-video--selector-audio-subtitle').parentNode.style.overflowY = 'scroll';
  491. }
  492. catch(ignore) {}
  493. });
  494. });
  495. });
  496. observer.observe(document.body, { childList: true, subtree: true });