NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Netflix - subtitle downloader // @description Allows you to download subtitles from Netflix // @license MIT // @version 4.2.6 // @namespace tithen-firion.github.io // @include https://www.netflix.com/* // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jszip@3.7.1/dist/jszip.min.js // @require https://cdn.jsdelivr.net/npm/file-saver-es@2.0.5/dist/FileSaver.min.js // ==/UserScript== class ProgressBar { constructor(max) { this.current = 0; this.max = max; let container = document.querySelector('#userscript_progress_bars'); if(container === null) { container = document.createElement('div'); container.id = 'userscript_progress_bars' document.body.appendChild(container) container.style container.style.position = 'fixed'; container.style.top = 0; container.style.left = 0; container.style.width = '100%'; container.style.background = 'red'; container.style.zIndex = '99999999'; } this.progressElement = document.createElement('div'); this.progressElement.innerHTML = 'Click to stop'; this.progressElement.style.cursor = 'pointer'; this.progressElement.style.fontSize = '16px'; this.progressElement.style.textAlign = 'center'; this.progressElement.style.width = '100%'; this.progressElement.style.height = '20px'; this.progressElement.style.background = 'transparent'; this.stop = new Promise(resolve => { this.progressElement.addEventListener('click', () => {resolve(STOP_THE_DOWNLOAD)}); }); container.appendChild(this.progressElement); } increment() { this.current += 1; if(this.current <= this.max) { let p = this.current / this.max * 100; this.progressElement.style.background = `linear-gradient(to right, green ${p}%, transparent ${p}%)`; } } destroy() { this.progressElement.remove(); } } const STOP_THE_DOWNLOAD = 'NETFLIX_SUBTITLE_DOWNLOADER_STOP_THE_DOWNLOAD'; const WEBVTT = 'webvtt-lssdh-ios8'; const DFXP = 'dfxp-ls-sdh'; const SIMPLE = 'simplesdh'; const ALL_FORMATS = [WEBVTT, DFXP, SIMPLE]; const FORMAT_NAMES = {}; FORMAT_NAMES[WEBVTT] = 'WebVTT'; FORMAT_NAMES[DFXP] = 'DFXP/XML'; const EXTENSIONS = {}; EXTENSIONS[WEBVTT] = 'vtt'; EXTENSIONS[DFXP] = 'dfxp'; EXTENSIONS[SIMPLE] = 'xml'; const DOWNLOAD_MENU = ` <ol> <li class="header">Netflix subtitle downloader</li> <li class="download">Download subs for this <span class="series">episode</span><span class="not-series">movie</span></li> <li class="download-to-end series">Download subs from this ep till last available</li> <li class="download-season series">Download subs for this season</li> <li class="download-all series">Download subs for all seasons</li> <li class="ep-title-in-filename">Add episode title to filename: <span></span></li> <li class="force-all-lang">Force Netflix to show all languages: <span></span></li> <li class="pref-locale">Preferred locale: <span></span></li> <li class="lang-setting">Languages to download: <span></span></li> <li class="sub-format">Subtitle format: prefer <span></span></li> <li class="batch-delay">Batch delay: <span></span></li> </ol> `; const SCRIPT_CSS = ` #subtitle-downloader-menu { position: absolute; display: none; width: 300px; top: 0; left: calc( 50% - 150px ); } #subtitle-downloader-menu ol { list-style: none; position: relative; width: 300px; background: #333; color: #fff; padding: 0; margin: auto; font-size: 12px; z-index: 99999998; } body:hover #subtitle-downloader-menu { display: block; } #subtitle-downloader-menu li { padding: 10px; } #subtitle-downloader-menu li.header { font-weight: bold; } #subtitle-downloader-menu li:not(.header):hover { background: #666; } #subtitle-downloader-menu li:not(.header) { display: none; cursor: pointer; } #subtitle-downloader-menu:hover li { display: block; } #subtitle-downloader-menu:not(.series) .series{ display: none; } #subtitle-downloader-menu.series .not-series{ display: none; } `; const SUB_TYPES = { 'subtitles': '', 'closedcaptions': '[cc]' }; let idOverrides = {}; let subCache = {}; let titleCache = {}; let batch = null; try { batch = JSON.parse(sessionStorage.getItem('NSD_batch')); } catch(ignore) {} let batchAll = null; let batchSeason = null; let batchToEnd = null; let epTitleInFilename = localStorage.getItem('NSD_ep-title-in-filename') === 'true'; let forceSubs = localStorage.getItem('NSD_force-all-lang') !== 'false'; let prefLocale = localStorage.getItem('NSD_pref-locale') || ''; let langs = localStorage.getItem('NSD_lang-setting') || ''; let subFormat = localStorage.getItem('NSD_sub-format') || WEBVTT; let batchDelay = parseFloat(localStorage.getItem('NSD_batch-delay') || '0'); const setEpTitleInFilename = () => { document.querySelector('#subtitle-downloader-menu .ep-title-in-filename > span').innerHTML = (epTitleInFilename ? 'on' : 'off'); }; const setForceText = () => { document.querySelector('#subtitle-downloader-menu .force-all-lang > span').innerHTML = (forceSubs ? 'on' : 'off'); }; const setLocaleText = () => { document.querySelector('#subtitle-downloader-menu .pref-locale > span').innerHTML = (prefLocale === '' ? 'disabled' : prefLocale); }; const setLangsText = () => { document.querySelector('#subtitle-downloader-menu .lang-setting > span').innerHTML = (langs === '' ? 'all' : langs); }; const setFormatText = () => { document.querySelector('#subtitle-downloader-menu .sub-format > span').innerHTML = FORMAT_NAMES[subFormat]; }; const setBatchDelayText = () => { document.querySelector('#subtitle-downloader-menu .batch-delay > span').innerHTML = batchDelay; }; const setBatch = b => { if(b === null) sessionStorage.removeItem('NSD_batch'); else sessionStorage.setItem('NSD_batch', JSON.stringify(b)); }; const toggleEpTitleInFilename = () => { epTitleInFilename = !epTitleInFilename; if(epTitleInFilename) localStorage.setItem('NSD_ep-title-in-filename', epTitleInFilename); else localStorage.removeItem('NSD_ep-title-in-filename'); setEpTitleInFilename(); }; const toggleForceLang = () => { forceSubs = !forceSubs; if(forceSubs) localStorage.removeItem('NSD_force-all-lang'); else localStorage.setItem('NSD_force-all-lang', forceSubs); document.location.reload(); }; const setPreferredLocale = () => { 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); if(result !== null) { prefLocale = result; if(prefLocale === '') localStorage.removeItem('NSD_pref-locale'); else localStorage.setItem('NSD_pref-locale', prefLocale); document.location.reload(); } }; const setLangToDownload = () => { const result = prompt('Languages to download, comma separated. Leave empty to download all subtitles.\nExample: en,de,fr', langs); if(result !== null) { langs = result; if(langs === '') localStorage.removeItem('NSD_lang-setting'); else localStorage.setItem('NSD_lang-setting', langs); setLangsText(); } }; const setSubFormat = () => { if(subFormat === WEBVTT) { localStorage.setItem('NSD_sub-format', DFXP); subFormat = DFXP; } else { localStorage.removeItem('NSD_sub-format'); subFormat = WEBVTT; } setFormatText(); }; const setBatchDelay = () => { let result = prompt('Delay (in seconds) between switching pages when downloading subs in batch:', batchDelay); if(result !== null) { result = parseFloat(result.replace(',', '.')); if(result < 0 || !Number.isFinite(result)) result = 0; batchDelay = result; if(batchDelay == 0) localStorage.removeItem('NSD_batch-delay'); else localStorage.setItem('NSD_batch-delay', batchDelay); setBatchDelayText(); } }; const asyncSleep = (seconds, value) => new Promise(resolve => { window.setTimeout(resolve, seconds * 1000, value); }); const popRandomElement = arr => { return arr.splice(arr.length * Math.random() << 0, 1)[0]; }; const processSubInfo = async result => { const tracks = result.timedtexttracks; const subs = {}; let reportError = true; for(const track of tracks) { if(track.isNoneTrack) continue; let type = SUB_TYPES[track.rawTrackType]; if(typeof type === 'undefined') type = `[${track.rawTrackType}]`; const variant = (typeof track.trackVariant === 'undefined' ? '' : `-${track.trackVariant}`); const lang = track.language + type + variant + (track.isForcedNarrative ? '-forced' : ''); const formats = {}; for(let format of ALL_FORMATS) { const downloadables = track.ttDownloadables[format]; if(typeof downloadables !== 'undefined') { let urls; if(typeof downloadables.downloadUrls !== 'undefined') urls = Object.values(downloadables.downloadUrls); else if(typeof downloadables.urls !== 'undefined') urls = downloadables.urls.map(({url}) => url); else { console.log('processSubInfo:', lang, Object.keys(downloadables)); if(reportError) { reportError = false; alert("Can't find subtitle URL, check the console for more information!"); } continue; } formats[format] = [urls, EXTENSIONS[format]]; } } if(Object.keys(formats).length > 0) { for(let i = 0; ; ++i) { const langKey = lang + (i == 0 ? "" : `-${i}`); if(typeof subs[langKey] === "undefined") { subs[langKey] = formats; break; } } } } subCache[result.movieId] = subs; }; const checkSubsCache = async menu => { while(getSubsFromCache(true) === null) { await asyncSleep(0.1); } // show menu if on watch page menu.style.display = (document.location.pathname.split('/')[1] === 'watch' ? '' : 'none'); if(batch !== null && batch.length > 0) { downloadBatch(true); } }; const processMetadata = data => { // add menu when it's not there let menu = document.querySelector('#subtitle-downloader-menu'); if(menu === null) { menu = document.createElement('div'); menu.id = 'subtitle-downloader-menu'; menu.innerHTML = DOWNLOAD_MENU; document.body.appendChild(menu); menu.querySelector('.download').addEventListener('click', downloadThis); menu.querySelector('.download-to-end').addEventListener('click', downloadToEnd); menu.querySelector('.download-season').addEventListener('click', downloadSeason); menu.querySelector('.download-all').addEventListener('click', downloadAll); menu.querySelector('.ep-title-in-filename').addEventListener('click', toggleEpTitleInFilename); menu.querySelector('.force-all-lang').addEventListener('click', toggleForceLang); menu.querySelector('.pref-locale').addEventListener('click', setPreferredLocale); menu.querySelector('.lang-setting').addEventListener('click', setLangToDownload); menu.querySelector('.sub-format').addEventListener('click', setSubFormat); menu.querySelector('.batch-delay').addEventListener('click', setBatchDelay); setEpTitleInFilename(); setForceText(); setLocaleText(); setLangsText(); setFormatText(); } // hide menu, at this point sub info is still missing menu.style.display = 'none'; menu.classList.remove('series'); const result = data.video; const {type, title} = result; if(type === 'show') { batchAll = []; batchSeason = []; batchToEnd = []; const allEpisodes = []; let currentSeason = 0; menu.classList.add('series'); for(const season of result.seasons) { for(const episode of season.episodes) { if(episode.id === result.currentEpisode) currentSeason = season.seq; allEpisodes.push([season.seq, episode.seq, episode.id]); titleCache[episode.id] = { type, title, season: season.seq, episode: episode.seq, subtitle: episode.title, hiddenNumber: episode.hiddenEpisodeNumbers }; } } allEpisodes.sort((a, b) => a[0] - b[0] || a[1] - b[1]); let toEnd = false; for(const [season, episode, id] of allEpisodes) { batchAll.push(id); if(season === currentSeason) batchSeason.push(id); if(id === result.currentEpisode) toEnd = true; if(toEnd) batchToEnd.push(id); } } else if(type === 'movie' || type === 'supplemental') { titleCache[result.id] = {type, title}; } else { console.debug('[Netflix Subtitle Downloader] unknown video type:', type, result) return; } checkSubsCache(menu); }; const getVideoId = () => window.location.pathname.split('/').pop(); const getXFromCache = (cache, name, silent) => { const id = getVideoId(); if(cache.hasOwnProperty(id)) return cache[id]; let newID = undefined; try { newID = unsafeWindow.netflix.falcorCache.videos[id].current.value[1]; } catch(ignore) {} if(typeof newID !== 'undefined' && cache.hasOwnProperty(newID)) return cache[newID]; newID = idOverrides[id]; if(typeof newID !== 'undefined' && cache.hasOwnProperty(newID)) return cache[newID]; if(silent === true) return null; alert("Couldn't find the " + name + ". Wait until the player is loaded. If that doesn't help refresh the page."); throw ''; }; const getSubsFromCache = silent => getXFromCache(subCache, 'subs', silent); const pad = (number, letter) => `${letter}${number.toString().padStart(2, '0')}`; const safeTitle = title => title.trim().replace(/[:*?"<>|\\\/]+/g, '_').replace(/ /g, '.'); const getTitleFromCache = () => { const title = getXFromCache(titleCache, 'title'); const titleParts = [title.title]; if(title.type === 'show') { const season = pad(title.season, 'S'); if(title.hiddenNumber) { titleParts.push(season); titleParts.push(title.subtitle); } else { titleParts.push(season + pad(title.episode, 'E')); if(epTitleInFilename) titleParts.push(title.subtitle); } } return [safeTitle(titleParts.join('.')), safeTitle(title.title)]; }; const pickFormat = formats => { const preferred = ALL_FORMATS.slice(); if(subFormat === DFXP) preferred.push(preferred.shift()); for(let format of preferred) { if(typeof formats[format] !== 'undefined') return formats[format]; } }; const _save = async (_zip, title) => { const content = await _zip.generateAsync({type:'blob'}); saveAs(content, title + '.zip'); }; const _download = async _zip => { const subs = getSubsFromCache(); const [title, seriesTitle] = getTitleFromCache(); const downloaded = []; let filteredLangs; if(langs === '') filteredLangs = Object.keys(subs); else { const regularExpression = new RegExp( '^(' + langs .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') .replace(/\-/g, '\\-') .replace(/\s/g, '') .replace(/,/g, '|') + ')' ); filteredLangs = []; for(const lang of Object.keys(subs)) { if(lang.match(regularExpression)) filteredLangs.push(lang); } } const progress = new ProgressBar(filteredLangs.length); let stop = false; for(const lang of filteredLangs) { const [urls, extension] = pickFormat(subs[lang]); while(urls.length > 0) { let url = popRandomElement(urls); const resultPromise = fetch(url, {mode: "cors"}); let result; try { // Promise.any isn't supported in all browsers, use Promise.race instead result = await Promise.race([resultPromise, progress.stop, asyncSleep(30, STOP_THE_DOWNLOAD)]); } catch(e) { // the only promise that can be rejected is the one from fetch // if that happens we want to stop the download anyway result = STOP_THE_DOWNLOAD; } if(result === STOP_THE_DOWNLOAD) { stop = true; break; } progress.increment(); const data = await result.text(); if(data.length > 0) { downloaded.push({lang, data, extension}); break; } } if(stop) break; } downloaded.forEach(x => { const {lang, data, extension} = x; _zip.file(`${title}.WEBRip.Netflix.${lang}.${extension}`, data); }); if(await Promise.race([progress.stop, {}]) === STOP_THE_DOWNLOAD) stop = true; progress.destroy(); return [seriesTitle, stop]; }; const downloadThis = async () => { const _zip = new JSZip(); const [title, stop] = await _download(_zip); _save(_zip, title); }; const cleanBatch = async () => { setBatch(null); return; const cache = await caches.open('NSD'); cache.delete('/subs.zip'); await caches.delete('NSD'); } const readAsBinaryString = blob => new Promise(resolve => { const reader = new FileReader(); reader.onload = function(event) { resolve(event.target.result); }; reader.readAsBinaryString(blob); }); const downloadBatch = async auto => { const cache = await caches.open('NSD'); let zip, title, stop; if(auto === true) { try { const response = await cache.match('/subs.zip'); const blob = await response.blob(); zip = await JSZip.loadAsync(await readAsBinaryString(blob)); } catch(error) { console.error(error); alert('An error occured when loading the zip file with subs from the cache. More info in the browser console.'); await cleanBatch(); return; } } else zip = new JSZip(); try { [title, stop] = await _download(zip); } catch(error) { title = 'unknown'; stop = true; } const id = parseInt(getVideoId()); batch = batch.filter(x => x !== id); if(stop || batch.length == 0) { await _save(zip, title); await cleanBatch(); } else { setBatch(batch); cache.put('/subs.zip', new Response(await zip.generateAsync({type:'blob'}))); await asyncSleep(batchDelay); window.location = window.location.origin + '/watch/' + batch[0]; } }; const downloadAll = () => { batch = batchAll; downloadBatch(); }; const downloadSeason = () => { batch = batchSeason; downloadBatch(); }; const downloadToEnd = () => { batch = batchToEnd; downloadBatch(); }; const processMessage = e => { const {type, data} = e.detail; if(type === 'subs') processSubInfo(data); else if(type === 'id_override') idOverrides[data[0]] = data[1]; else if(type === 'metadata') processMetadata(data); } const injection = () => { const WEBVTT = 'webvtt-lssdh-ios8'; const MANIFEST_PATTERN = new RegExp('manifest|licensedManifest'); const forceSubs = localStorage.getItem('NSD_force-all-lang') !== 'false'; const prefLocale = localStorage.getItem('NSD_pref-locale') || ''; // hide the menu when we go back to the browse list window.addEventListener('popstate', () => { const display = (document.location.pathname.split('/')[1] === 'watch' ? '' : 'none'); const menu = document.querySelector('#subtitle-downloader-menu'); menu.style.display = display; }); // hijack JSON.parse and JSON.stringify functions ((parse, stringify, open) => { JSON.parse = function (text) { const data = parse(text); if (data && data.result && data.result.timedtexttracks && data.result.movieId) { window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'subs', data: data.result}})); } return data; }; JSON.stringify = function (data) { /*{ let text = stringify(data); if (text.includes('dfxp-ls-sdh')) console.log(text, data); }*/ if (data && typeof data.url === 'string' && data.url.search(MANIFEST_PATTERN) > -1) { for (let v of Object.values(data)) { try { if (v.profiles) v.profiles.unshift(WEBVTT); if (v.showAllSubDubTracks != null && forceSubs) v.showAllSubDubTracks = true; if (prefLocale !== '') v.preferredTextLocale = prefLocale; } catch (e) { if (e instanceof TypeError) continue; else throw e; } } } if(data && typeof data.movieId === 'number') { try { let videoId = data.params.sessionParams.uiplaycontext.video_id; if(typeof videoId === 'number' && videoId !== data.movieId) window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'id_override', data: [videoId, data.movieId]}})); } catch(ignore) {} } return stringify(data); }; XMLHttpRequest.prototype.open = function() { if(arguments[1] && arguments[1].includes('/metadata?')) this.addEventListener('load', async () => { let data = this.response; if(data instanceof Blob) data = JSON.parse(await data.text()); else if(typeof data === "string") data = JSON.parse(data); window.dispatchEvent(new CustomEvent('netflix_sub_downloader_data', {detail: {type: 'metadata', data: data}})); }, false); open.apply(this, arguments); }; })(JSON.parse, JSON.stringify, XMLHttpRequest.prototype.open); } window.addEventListener('netflix_sub_downloader_data', processMessage, false); // inject script const sc = document.createElement('script'); sc.innerHTML = '(' + injection.toString() + ')()'; document.head.appendChild(sc); document.head.removeChild(sc); // add CSS style const s = document.createElement('style'); s.innerHTML = SCRIPT_CSS; document.head.appendChild(s); const observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { // add scrollbar - Netflix doesn't expect you to have this manu languages to choose from... try { (node.parentNode || node).querySelector('.watch-video--selector-audio-subtitle').parentNode.style.overflowY = 'scroll'; } catch(ignore) {} }); }); }); observer.observe(document.body, { childList: true, subtree: true });