Netflix - subtitle downloader

Allows you to download subtitles from Netflix

2021-04-19 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

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