iTunes - subtitle downloader

Allows you to download subtitles from iTunes

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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