iTunes - subtitle downloader

Allows you to download subtitles from iTunes

当前为 2021-09-12 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name iTunes - subtitle downloader
  3. // @description Allows you to download subtitles from iTunes
  4. // @license MIT
  5. // @version 1.3.6
  6. // @namespace tithen-firion.github.io
  7. // @include https://itunes.apple.com/*/movie/*
  8. // @include https://tv.apple.com/*/movie/*
  9. // @include https://tv.apple.com/*/episode/*
  10. // @grant none
  11. // @require https://cdn.jsdelivr.net/gh/Stuk/jszip@579beb1d45c8d586d8be4411d5b2e48dea018c06/dist/jszip.min.js?version=3.1.5
  12. // @require https://cdn.jsdelivr.net/gh/eligrey/FileSaver.js@283f438c31776b622670be002caf1986c40ce90c/dist/FileSaver.min.js?version=2018-12-29
  13. // @require https://cdn.jsdelivr.net/npm/m3u8-parser@4.6.0/dist/m3u8-parser.min.js
  14. // ==/UserScript==
  15.  
  16. let langs = localStorage.getItem('ISD_lang-setting') || '';
  17.  
  18. function setLangToDownload() {
  19. const result = prompt('Languages to download, comma separated. Leave empty to download all subtitles.\nExample: en,de,fr', langs);
  20. if(result !== null) {
  21. langs = result;
  22. if(langs === '')
  23. localStorage.removeItem('ISD_lang-setting');
  24. else
  25. localStorage.setItem('ISD_lang-setting', langs);
  26. }
  27. }
  28.  
  29. // taken from: https://github.com/rxaviers/async-pool/blob/1e7f18aca0bd724fe15d992d98122e1bb83b41a4/lib/es7.js
  30. async function asyncPool(poolLimit, array, iteratorFn) {
  31. const ret = [];
  32. const executing = [];
  33. for (const item of array) {
  34. const p = Promise.resolve().then(() => iteratorFn(item, array));
  35. ret.push(p);
  36.  
  37. if (poolLimit <= array.length) {
  38. const e = p.then(() => executing.splice(executing.indexOf(e), 1));
  39. executing.push(e);
  40. if (executing.length >= poolLimit) {
  41. await Promise.race(executing);
  42. }
  43. }
  44. }
  45. return Promise.all(ret);
  46. }
  47.  
  48. class ProgressBar {
  49. constructor(max) {
  50. this.current = 0;
  51. this.max = max;
  52.  
  53. let container = document.querySelector('#userscript_progress_bars');
  54. if(container === null) {
  55. container = document.createElement('div');
  56. container.id = 'userscript_progress_bars'
  57. document.body.appendChild(container);
  58. container.style.position = 'fixed';
  59. container.style.top = 0;
  60. container.style.left = 0;
  61. container.style.width = '100%';
  62. container.style.background = 'red';
  63. container.style.zIndex = '99999999';
  64. }
  65.  
  66. this.progressElement = document.createElement('div');
  67. this.progressElement.style.width = '100%';
  68. this.progressElement.style.height = '20px';
  69. this.progressElement.style.background = 'transparent';
  70.  
  71. container.appendChild(this.progressElement);
  72. }
  73.  
  74. increment() {
  75. this.current += 1;
  76. if(this.current <= this.max) {
  77. let p = this.current / this.max * 100;
  78. this.progressElement.style.background = `linear-gradient(to right, green ${p}%, transparent ${p}%)`;
  79. }
  80. }
  81.  
  82. destroy() {
  83. this.progressElement.remove();
  84. }
  85. }
  86.  
  87. async function getText(url) {
  88. const response = await fetch(url);
  89. if(!response.ok) {
  90. console.log(response);
  91. throw new Error('Something went wrong, server returned status code ' + response.status);
  92. }
  93. return response.text();
  94. }
  95.  
  96. async function getM3U8(url) {
  97. const parser = new m3u8Parser.Parser();
  98. parser.push(await getText(url));
  99. parser.end();
  100. return parser.manifest;
  101. }
  102.  
  103. async function getSubtitleSegment(url, done) {
  104. const text = await getText(url);
  105. done();
  106. return text;
  107. }
  108.  
  109. function filterLangs(subInfo) {
  110. if(langs === '')
  111. return subInfo;
  112. else {
  113. const regularExpression = new RegExp(
  114. '^(' + langs
  115. .replace(/\[/g, '\\[')
  116. .replace(/\]/g, '\\]')
  117. .replace(/\-/g, '\\-')
  118. .replace(/\s/g, '')
  119. .replace(/,/g, '|')
  120. + ')'
  121. );
  122. const filteredLangs = [];
  123. for(const entry of subInfo) {
  124. if(entry.language.match(regularExpression))
  125. filteredLangs.push(entry);
  126. }
  127. return filteredLangs;
  128. }
  129. }
  130.  
  131. async function _download(name, url) {
  132. name = name.replace(/[:*?"<>|\\\/]+/g, '_');
  133.  
  134. const mainProgressBar = new ProgressBar(1);
  135. const SUBTITLES = (await getM3U8(url)).mediaGroups.SUBTITLES;
  136. const keys = Object.keys(SUBTITLES);
  137.  
  138. if(keys.length === 0) {
  139. alert('No subtitles found!');
  140. mainProgressBar.destroy();
  141. return;
  142. }
  143.  
  144. let selectedKey = null;
  145. for(const regexp of ['_ak$', '-ak-', '_ap$', '-ap-', , '_ap1$', '-ap1-', , '_ap3$', '-ap3-']) {
  146. for(const key of keys) {
  147. if(key.match(regexp) !== null) {
  148. selectedKey = key;
  149. break;
  150. }
  151. }
  152. if(selectedKey !== null)
  153. break;
  154. }
  155.  
  156. if(selectedKey === null) {
  157. selectedKey = keys[0];
  158. alert('Warnign, unknown subtitle type: ' + selectedKey + '\n\nReport that on script\'s page.');
  159. }
  160.  
  161. const subGroup = SUBTITLES[selectedKey];
  162.  
  163. let subInfo = Object.values(subGroup);
  164. subInfo = filterLangs(subInfo);
  165. mainProgressBar.max = subInfo.length;
  166.  
  167. const zip = new JSZip();
  168.  
  169. for(const entry of subInfo) {
  170. let lang = entry.language;
  171. if(entry.forced) lang += '[forced]';
  172. if(typeof entry.characteristics !== 'undefined') lang += '[cc]';
  173. const langURL = new URL(entry.uri, url).href;
  174. const segments = (await getM3U8(langURL)).segments;
  175.  
  176. const subProgressBar = new ProgressBar(segments.length);
  177. const partial = segmentUrl => getSubtitleSegment(segmentUrl, subProgressBar.increment.bind(subProgressBar));
  178.  
  179. const segmentURLs = [];
  180. for(const segment of segments) {
  181. segmentURLs.push(new URL(segment.uri, langURL).href);
  182. }
  183.  
  184. const subtitleSegments = await asyncPool(20, segmentURLs, partial);
  185. let subtitleContent = subtitleSegments.join('\n\n');
  186. // this gets rid of all WEBVTT lines except for the first one
  187. subtitleContent = subtitleContent.replace(/\nWEBVTT\n.*?\n\n/g, '\n');
  188. subtitleContent = subtitleContent.replace(/\n{3,}/g, '\n\n');
  189.  
  190. // add RTL Unicode character to Arabic subs to all lines except for:
  191. // - lines that already have it (\u202B or \u200F)
  192. // - first two lines of the file (WEBVTT and X-TIMESTAMP)
  193. // - timestamps (may match the actual subtitle lines but it's unlikely)
  194. // - empty lines
  195. if(lang.startsWith('ar'))
  196. 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');
  197.  
  198. zip.file(`${name} WEBRip.iTunes.${lang}.vtt`, subtitleContent);
  199.  
  200. subProgressBar.destroy();
  201. mainProgressBar.increment();
  202. }
  203.  
  204. const content = await zip.generateAsync({type:"blob"});
  205. mainProgressBar.destroy();
  206. saveAs(content, `${name}.zip`);
  207. }
  208.  
  209. async function download(name, url) {
  210. try {
  211. await _download(name, url);
  212. }
  213. catch(error) {
  214. console.error(error);
  215. alert('Uncaught error!\nLine: ' + error.lineNumber + '\n' + error);
  216. }
  217. }
  218.  
  219. function findUrl(included) {
  220. for(const item of included) {
  221. try {
  222. return item.attributes.assets[0].hlsUrl;
  223. }
  224. catch(ignore){}
  225. }
  226. return null;
  227. }
  228.  
  229. const parsers = {
  230. 'tv.apple.com': data => {
  231. for(const value of Object.values(data)) {
  232. try{
  233. const data2 = JSON.parse(value).d.data;
  234. const content = data2.content;
  235. if(content.type === 'Movie') {
  236. const playables = content.playables || data2.playables;
  237. const playable = playables[Object.keys(playables)[0]];
  238. let url;
  239. try {
  240. url = playable.itunesMediaApiData.offers[0].hlsUrl;
  241. }
  242. catch(ignore) {
  243. url = playable.assets.hlsUrl;
  244. }
  245. return [
  246. playable.title,
  247. url
  248. ];
  249. }
  250. else if(content.type === 'Episode') {
  251. const season = content.seasonNumber.toString().padStart(2, '0');
  252. const episode = content.episodeNumber.toString().padStart(2, '0');
  253. return [
  254. `${content.showTitle} S${season}E${episode}`,
  255. Object.values(data2.playables)[0].assets.hlsUrl
  256. ];
  257. }
  258. }
  259. catch(ignore){}
  260. }
  261. return [null, null];
  262. },
  263. 'itunes.apple.com': data => {
  264. data = Object.values(data)[0];
  265. let name = data.data.attributes.name;
  266. const year = (data.data.attributes.releaseDate || '').substr(0, 4);
  267. name = name.replace(new RegExp('\\s*\\(' + year + '\\)\\s*$'), '');
  268. name += ` (${year})`;
  269. return [
  270. name,
  271. findUrl(data.included)
  272. ];
  273. }
  274. }
  275.  
  276. async function parseData(text) {
  277. const data = JSON.parse(text);
  278. const [name, m3u8Url] = parsers[document.location.hostname](data);
  279. if(m3u8Url === null) {
  280. alert("Subtitles URL not found. Make sure you're logged in!");
  281. return;
  282. }
  283.  
  284. const container = document.createElement('div');
  285. container.style.position = 'absolute';
  286. container.style.zIndex = '99999998';
  287. container.style.top = '45px';
  288. container.style.left = '5px';
  289. container.style.textAlign = 'center';
  290.  
  291. const button = document.createElement('a');
  292. button.classList.add('we-button');
  293. button.classList.add('we-button--compact');
  294. button.classList.add('commerce-button');
  295. button.style.padding = '3px 8px';
  296. button.style.display = 'block';
  297. button.style.marginBottom = '10px';
  298. button.href = '#';
  299.  
  300. const langButton = button.cloneNode();
  301. langButton.innerHTML = 'Languages';
  302. langButton.addEventListener('click', setLangToDownload);
  303. container.append(langButton);
  304.  
  305. button.innerHTML = 'Download subtitles';
  306. button.addEventListener('click', e => {
  307. download(name, m3u8Url);
  308. });
  309. container.append(button);
  310. document.body.prepend(container);
  311. }
  312.  
  313. (async () => {
  314. let element = document.querySelector('#shoebox-ember-data-store, #shoebox-uts-api');
  315. if(element === null) {
  316. const parser = new DOMParser();
  317. const doc = parser.parseFromString(await getText(window.location.href), 'text/html');
  318. element = doc.querySelector('#shoebox-ember-data-store, #shoebox-uts-api');
  319. }
  320. if(element !== null) {
  321. try {
  322. await parseData(element.textContent);
  323. }
  324. catch(error) {
  325. console.error(error);
  326. alert('Uncaught error!\nLine: ' + error.lineNumber + '\n' + error);
  327. }
  328. }
  329. else {
  330. alert('Movie info not found!')
  331. }
  332. })();