Allows you to download subtitles from iTunes
// ==UserScript== // @name iTunes - subtitle downloader // @description Allows you to download subtitles from iTunes // @license MIT // @version 1.3.9 // @namespace tithen-firion.github.io // @include https://itunes.apple.com/*/movie/* // @include https://tv.apple.com/*/movie/* // @include https://tv.apple.com/*/episode/* // @grant none // @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5 // @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29 // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/m3u8-parser.min.js // ==/UserScript== let langs = localStorage.getItem('ISD_lang-setting') || ''; function setLangToDownload() { const r###lt = prompt('Languages to download, comma separated. Leave empty to download all subtitles.\nExample: en,de,fr', langs); if(r###lt !== null) { langs = r###lt; if(langs === '') localStorage.removeItem('ISD_lang-setting'); else localStorage.setItem('ISD_lang-setting', langs); } } // taken from: https://github.com/rxaviers/async-pool/blob/1e7f18aca0bd724fe15d992d98122e1bb83b41a4/lib/es7.js async function asyncPool(poolLimit, array, iteratorFn) { const ret = []; const executing = []; for (const item of array) { const p = Promise.resolve().then(() => iteratorFn(item, array)); ret.push(p); if (poolLimit <= array.length) { const e = p.then(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); if (executing.length >= poolLimit) { await Promise.race(executing); } } } return Promise.all(ret); } 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.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.style.width = '100%'; this.progressElement.style.height = '20px'; this.progressElement.style.background = 'transparent'; 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(); } } async function getText(url) { const response = await fetch(url); if(!response.ok) { console.log(response); throw new Error('Something went wrong, server returned status code ' + response.status); } return response.text(); } async function getM3U8(url) { const parser = new m3u8Parser.Parser(); parser.push(await getText(url)); parser.end(); return parser.manifest; } async function getSubtitleSegment(url, done) { const text = await getText(url); done(); return text; } function filterLangs(subInfo) { if(langs === '') return subInfo; else { const regularExpression = new RegExp( '^(' + langs .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') .replace(/\-/g, '\\-') .replace(/\s/g, '') .replace(/,/g, '|') + ')' ); const filteredLangs = []; for(const entry of subInfo) { if(entry.language.match(regularExpression)) filteredLangs.push(entry); } return filteredLangs; } } async function _download(name, url) { name = name.replace(/[:*?"<>|\\\/]+/g, '_'); const mainProgressBar = new ProgressBar(1); const SUBTITLES = (await getM3U8(url)).mediaGroups.SUBTITLES; const keys = Object.keys(SUBTITLES); if(keys.length === 0) { alert('No subtitles found!'); mainProgressBar.destroy(); return; } let selectedKey = null; for(const regexp of ['_ak$', '-ak-', '_ap$', '-ap-', , '_ap1$', '-ap1-', , '_ap3$', '-ap3-']) { for(const key of keys) { if(key.match(regexp) !== null) { selectedKey = key; break; } } if(selectedKey !== null) break; } if(selectedKey === null) { selectedKey = keys[0]; alert('Warnign, unknown subtitle type: ' + selectedKey + '\n\nReport that on script\'s page.'); } const subGroup = SUBTITLES[selectedKey]; let subInfo = Object.values(subGroup); subInfo = filterLangs(subInfo); mainProgressBar.max = subInfo.length; const zip = new JSZip(); for(const entry of subInfo) { let lang = entry.language; if(entry.forced) lang += '[forced]'; if(typeof entry.characteristics !== 'undefined') lang += '[cc]'; const langURL = new URL(entry.uri, url).href; const segments = (await getM3U8(langURL)).segments; const subProgressBar = new ProgressBar(segments.length); const partial = segmentUrl => getSubtitleSegment(segmentUrl, subProgressBar.increment.bind(subProgressBar)); const segmentURLs = []; for(const segment of segments) { segmentURLs.push(new URL(segment.uri, langURL).href); } const subtitleSegments = await asyncPool(20, segmentURLs, partial); let subtitleContent = subtitleSegments.join('\n\n'); // this gets rid of all WEBVTT lines except for the first one subtitleContent = subtitleContent.replace(/\nWEBVTT\n.*?\n\n/g, '\n'); subtitleContent = subtitleContent.replace(/\n{3,}/g, '\n\n'); // add RTL Unicode character to Arabic subs to all lines except for: // - lines that already have it (\u202B or \u200F) // - first two lines of the file (WEBVTT and X-TIMESTAMP) // - timestamps (may match the actual subtitle lines but it's unlikely) // - empty lines if(lang.startsWith('ar')) 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'); zip.file(`${name} WEBRip.iTunes.${lang}.vtt`, subtitleContent); subProgressBar.destroy(); mainProgressBar.increment(); } const content = await zip.generateAsync({type:"blob"}); mainProgressBar.destroy(); saveAs(content, `${name}.zip`); } async function download(name, url) { try { await _download(name, url); } catch(error) { console.error(error); alert('Uncaught error!\nLine: ' + error.lineNumber + '\n' + error); } } function findUrl(included) { for(const item of included) { try { return item.attributes.assets[0].hlsUrl; } catch(ignore){} } return null; } function findUrl2(playables) { for(const playable of Object.values(playables)) { let url; try { url = playable.itunesMediaApiData.offers[0].hlsUrl; } catch(ignore) { try { url = playable.assets.hlsUrl; } catch(ignore) { continue; } } return [ playable.title, url ]; } return [null, null]; } const parsers = { 'tv.apple.com': data => { for(const value of Object.values(data)) { try{ const content = value.content; let playables = null; let title = null; let title2 = null; let url = null; if(content.type === 'Movie') { playables = content.playables || value.playables; } else if(content.type === 'Episode') { playables = value.playables; const season = content.seasonNumber.toString().padStart(2, '0'); const episode = content.episodeNumber.toString().padStart(2, '0'); title = `${content.showTitle} S${season}E${episode}`; } else { throw "???"; } [title2, url] = findUrl2(playables); return [ title || title2, url ]; } catch(ignore){} } return [null, null]; }, 'itunes.apple.com': data => { data = Object.values(data)[0]; let name = data.data.attributes.name; const year = (data.data.attributes.releaseDate || '').substr(0, 4); name = name.replace(new RegExp('\\s*\\(' + year + '\\)\\s*$'), ''); name += ` (${year})`; return [ name, findUrl(data.included) ]; } } async function parseData(text) { const data = JSON.parse(text); const [name, m3u8Url] = parsers[document.location.hostname](data); if(m3u8Url === null) { alert("Subtitles URL not found. Make sure you're logged in!"); return; } const container = document.createElement('div'); container.style.position = 'absolute'; container.style.zIndex = '99999998'; container.style.top = '45px'; container.style.left = '5px'; container.style.textAlign = 'center'; const button = document.createElement('a'); button.classList.add('we-button'); button.classList.add('we-button--compact'); button.classList.add('commerce-button'); button.style.padding = '3px 8px'; button.style.display = 'block'; button.style.marginBottom = '10px'; button.href = '#'; const langButton = button.cloneNode(); langButton.innerHTML = 'Languages'; langButton.addEventListener('click', setLangToDownload); container.append(langButton); button.innerHTML = 'Download subtitles'; button.addEventListener('click', e => { download(name, m3u8Url); }); container.append(button); document.body.prepend(container); } (async () => { let element = document.querySelector('#shoebox-ember-data-store, #shoebox-uts-api, #shoebox-uts-api-cache'); if(element === null) { const parser = new DOMParser(); const doc = parser.parseFromString(await getText(window.location.href), 'text/html'); element = doc.querySelector('#shoebox-ember-data-store, #shoebox-uts-api, #shoebox-uts-api-cache'); } if(element !== null) { try { await parseData(element.textContent); } catch(error) { console.error(error); alert('Uncaught error!\nLine: ' + error.lineNumber + '\n' + error); } } else { alert('Movie info not found!') } })();