Greasy Fork is available in English.

iTunes - subtitle downloader

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/m3u8-parser@4.6.0/dist/m3u8-parser.min.js
// ==/UserScript==

let langs = localStorage.getItem('ISD_lang-setting') || '';

function 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('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!')
  }
})();