// ==UserScript==
// @name Netflix subtitle downloader
// @description Allows you to download subtitles from Netflix
// @namespace tithen-firion
// @include https://www.netflix.com/*
// @version 2.0.1
// @require https://greasyfork.org/scripts/26651-xhrhijacker/code/xhrHijacker.js?version=171120
// @require https://cdn.rawgit.com/sizzlemctwizzle/GM_config/232c002a7421f10b666c6205b9f495367ba9dac2/gm_config.js?v=2016-11-03
// @require https://cdn.rawgit.com/Stuk/jszip/28d10c924285063b17b73b7db1572e1375f4b924/dist/jszip.min.js?v=3.1.4
// @require https://cdn.rawgit.com/eligrey/FileSaver.js/5ed507ef8aa53d8ecfea96d96bc7214cd2476fd2/FileSaver.min.js?v=1.3.3
// @grant none
// ==/UserScript==
// update info
(function() {
var v = localStorage.getItem('subtitle-downloader-version');
if(v != 'v2') {
localStorage.setItem('subtitle-downloader-version', 'v2');
alert('Netflix subtitle downloader has been updated!\n\nMain changes:\n- moved download buttons to subtitle select menu\n- added styling options\n- added experimental download for subs in image format (Hebrew, Japanese, etc.); unzip them and open .xml file in SubtitleEdit; let me know if any other OCR/subtitle program can open them\n\nDon\'t forget to update your opinion on Greasyfork.');
}
})();
var MAIN_TITLE = '.player-status-main-title, .ellipsize-text>h4, .video-title>h4';
var TRACK_MENU = '#player-menu-track-settings, .audio-subtitle-controller';
var NEXT_EPISODE = '.player-next-episode:not(.player-hidden), .button-nfplayerNextEpisode';
var SELECTED_SUBS = '.player-timed-text-tracks > .player-track-selected';
var DOWNLOAD_MENU = `<lh class="list-header">Netflix subtitle downloader</lh>
<li class="list-header">Netflix subtitle downloader</li>
<li class="track options">Options</li>
<li class="track download">Download subs for this episode</li>
<li class="track download-all">Download subs from this ep till last available</li>`;
var CSS = `.player-timed-text-tracks, .track-list-subtitles{ border-right:1px solid #000 }
.player-timed-text-tracks+.player-timed-text-tracks, .track-list-subtitles+.track-list-subtitles{ border-right:0 }
#player-menu-track-settings .subtitle-downloader-menu li.list-header,
.audio-subtitle-controller .subtitle-downloader-menu lh.list-header{ display:none }`;
var OPTIONS_CSS = `#NetflixSubtitleDownloaderConfig_wrapper{ text-align:center }
.config_var{ display:inline-block; padding:10px }
.config_var>*{ vertical-align:middle }`;
var DOWNLOADED_WITH = 'Subtitles downloaded with "Netflix subtitle downloader" UserScript by Tithen-Firion.';
// INIT
(function(){
// default settings
GM_config.init({
'id': 'NetflixSubtitleDownloaderConfig',
'title': 'Script Settings',
'fields': {
'b': {
'label': 'bold (<b>...</b>)',
'type': 'checkbox',
'default': true
},
'i': {
'label': 'italics (<i>...</i>)',
'type': 'checkbox',
'default': true
},
'u': {
'label': 'underline (<u>...</u>)',
'type': 'checkbox',
'default': true
},
'font': {
'label': 'colour (<font color="...">...</font>)',
'type': 'checkbox',
'default': true
},
'position': {
'label': 'position ({\\an8})',
'type': 'checkbox',
'default': false
}
},
'css': OPTIONS_CSS
});
// add CSS style
var s = document.createElement('style');
s.innerHTML = CSS;
document.head.appendChild(s);
// add menu when it's not there
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if(node.nodeName.toUpperCase() == 'DIV') {
let trackMenu = (node.parentNode || node).querySelector(TRACK_MENU);
if(trackMenu != null && trackMenu.querySelector('.subtitle-downloader-menu') == null) {
let ol = document.createElement('ol');
ol.setAttribute('class', 'subtitle-downloader-menu player-timed-text-tracks track-list track-list-subtitles');
ol.innerHTML = DOWNLOAD_MENU;
trackMenu.appendChild(ol);
ol.querySelector('.options').addEventListener('click', GM_config.open.bind(GM_config));
ol.querySelector('.download').addEventListener('click', downloadThis);
ol.querySelector('.download-all').addEventListener('click', downloadAll);
}
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
// XML to SRT
(function(self) {
var TRANSFORMATION = [ // a(ttribute), v(alue), t(ag)
{ 'a': 'tts:fontWeight', 'v': 'bold', 't': 'b' },
{ 'a': 'tts:fontStyle', 'v': 'italic', 't': 'i' },
{ 'a': 'tts:textDecoration', 'v': 'underline', 't': 'u' },
{ 'a': 'tts:color', 'v': 'white', 't': 'font', 'color': true }
];
var getStyles = function(xmlDoc) {
var styles = {};
var styleElems = xmlDoc.querySelectorAll('styling style');
for(let i = 0; i < styleElems.length; ++i) {
let id = styleElems[i].getAttribute('xml:id');
styles[id] = {start: '', end: ''};
TRANSFORMATION.forEach(function(styling) {
if(GM_config.get(styling.t) && styleElems[i].hasAttribute(styling.a)) {
let value = styleElems[i].getAttribute(styling.a).trim();
let equal = value === styling.v;
let color = 'color' in styling;
if(equal != color) {
styles[id].start += '<' + styling.t + ('color' in styling ? ' color="' + value + '"': '') + '>';
styles[id].end = '</' + styling.t + '>' + styles[id].end;
}
}
});
if(styles[id].start === '')
delete styles[id];
}
return styles;
};
var getRegions = function(xmlDoc) {
var regions = {};
if(GM_config.get('position')) {
let regionElems = xmlDoc.querySelectorAll('layout region');
for(let i = 0; i < regionElems.length; ++i) {
let id = regionElems[i].getAttribute('xml:id');
let value = regionElems[i].getAttribute('tts:displayAlign');
if(value == null) {
let styles = regionElems[i].querySelectorAll('style');
for(let j = 0; j < styles.length; ++j) {
if(styles[j].hasAttribute('tts:displayAlign')) {
value = styles[j].getAttribute('tts:displayAlign');
break;
}
}
}
value = value.trim();
if(value == 'before')
regions[id] = '{\\an8}';
}
}
return regions;
};
var toText = function(node, styles, regions) {
var txt = '';
var children = node.childNodes;
for(let i = 0; i < children.length; ++i) {
if(children[i].nodeType === 3)
txt += children[i].textContent;
else if(children[i].nodeType === 1) {
if(children[i].nodeName.toUpperCase() === 'BR')
txt += '\n';
else
txt += toText(children[i], styles);
}
}
if(node.hasAttribute('style')) {
let s = node.getAttribute('style');
if(s in styles)
txt = styles[s].start + txt + styles[s].end;
}
if(node.hasAttribute('region')) {
let r = node.getAttribute('region');
if(r in regions)
txt = regions[r] + txt;
}
return txt;
};
var getLines = function(xmlDoc, styles, regions) {
var prevStart = -1, subs = [];
var subElems = xmlDoc.querySelectorAll('div p');
for(let i = 0; i < subElems.length; ++i) {
let el = subElems[i];
let start = Math.round(parseInt(el.getAttribute('begin'))/10000);
let end = Math.round(parseInt(el.getAttribute('end'))/10000);
let text = toText(el, styles, regions);
if(start === prevStart)
subs[subs.length-1].text += "\n" + text;
else
subs.push({start: start, end: end, text: text});
prevStart = start;
}
var lastEndTime = subs[subs.length-1].end;
subs.push({start: lastEndTime + 2000, end: lastEndTime + 4000, text: DOWNLOADED_WITH});
return subs;
};
var formatTime = function(time) {
var str = (time%1000).toString().padStart(3, '0');
time = Math.floor(time/1000);
str = (time%60).toString().padStart(2, '0') + ',' + str;
time = Math.floor(time/60);
str = (time%60).toString().padStart(2, '0') + ':' + str;
str = (Math.floor(time/60)).toString().padStart(2, '0') + ':' + str;
return str;
};
var xmlToSrt = function(xmlString) {
try {
let parser = new DOMParser();
var xmlDoc = parser.parseFromString(xmlString, 'text/xml');
}
catch(e) {
console.error(e);
alert('Failed to parse XML subtitle file');
return null;
}
var styles = getStyles(xmlDoc);
var regions = getRegions(xmlDoc);
var lines = getLines(xmlDoc, styles, regions);
var srtLines = [];
lines.forEach(function(line, i) {
srtLines.push(i+1);
srtLines.push(formatTime(line.start) + ' --> ' + formatTime(line.end));
srtLines.push(line.text);
srtLines.push('');
});
return srtLines.join('\n');
};
self.xmlToSrt = xmlToSrt;
})(this);
// get show name or full name with episode number
function getTitle(full) {
full = full || false;
var titleElement = document.querySelector(MAIN_TITLE);
if(titleElement === null)
return null;
var title = titleElement.innerText.replace(/[:*?"<>|\\\/]+/g, '_').replace(/ /g, '.');
if(full) {
title += '.';
var episodeElement = titleElement.nextElementSibling;
if(episodeElement) {
var m = episodeElement.innerText.match(/^[^\d]*?(\d+)[^\d]*?(\d+)?[^\d]*?$/);
if(m && m.length == 3) {
if(typeof m[2] == 'undefined') // example: Stranger Things season 1
title += 'S01E' + m[1].padStart(2, '0') + '.';
else
title += 'S' + m[1].padStart(2, '0') + 'E' + m[2].padStart(2, '0') + '.';
}
}
title += 'WEBRip.Netflix';
var selectedSubs = document.querySelector(SELECTED_SUBS);
if(selectedSubs != null)
title += '.' + selectedSubs.getAttribute('data-id').split(';')[2];
}
return title;
}
function setCurrentSubFile(name, content, count) {
if(typeof currentSubFile == 'undefined' || currentSubFile.count < count) {
currentSubFile = {
name: name,
content: content,
count: count
};
if(batch)
downloadAll();
}
}
// convert XML subs to SRT and set as current subs
function processXmlSubs(responseText, count) {
var title = getTitle(true);
if(title == null)
window.setTimeout(processXmlSubs, 200, responseText, count);
else {
var srt = xmlToSrt(responseText);
if(srt != null)
setCurrentSubFile(title + '.srt', srt, count);
}
}
// download and process subs in image format
function downloadImageSubs(url, count) {
var req = new XMLHttpRequest();
req.open('GET', url);
req.responseType = 'blob';
req.onload = function() {
let title = getTitle(true);
setCurrentSubFile(title + '.zip', req.response, count);
};
req.send(null);
}
// download subs for current episode
function downloadThis() {
if(typeof currentSubFile == 'undefined')
window.setTimeout(downloadThis, 100);
else {
var blob = currentSubFile.content instanceof Blob ?
currentSubFile.content :
new Blob([currentSubFile.content], {type: 'text/plain;charset=utf-8'});
saveAs(blob, currentSubFile.name, true);
}
}
//download subs from this ep till last available
function downloadAll() {
batch = true;
if(typeof currentSubFile == 'undefined')
window.setTimeout(downloadAll, 100);
else {
zip = zip || new JSZip();
zip.file(currentSubFile.name, currentSubFile.content);
var nextEp = document.querySelector(NEXT_EPISODE);
if(nextEp)
nextEp.click();
else
zip.generateAsync({type:"blob"})
.then(function(content) {
saveAs(content, getTitle() + ".zip");
zip = undefined;
batch = false;
});
}
}
var IDs = {}, counter = 0, batch = false, currentImageSubs = '', currentSubFile, zip;
xhrHijacker(function(xhr, id, origin, args) {
if(origin === 'open' && window.location.pathname.startsWith('/watch/')) {
if(args[1].indexOf('/?o=') > -1) {
IDs[id] = counter++;
}
else {
var m = args[1].match(/^(.*?\/range\/)\d+-\d+(\?.*?)(&random=[^&]+)?$/);
if(m !== null && typeof m[3] == 'undefined' && currentImageSubs != m[2]) {
currentImageSubs = m[2];
console.log('Image subs detected:', args[1]);
downloadImageSubs(m[1] + '1-0' + m[2] + '&random=0', counter++);
}
}
}
else if(origin === 'load') {
if(id in IDs && IDs.hasOwnProperty(id)) {
let c = IDs[id];
delete IDs[id];
processXmlSubs(xhr.response, c);
}
}
});