Greasy Fork镜像 还支持 简体中文。

AniLINK - Episode Link Extractor

Stream or download your favorite anime series effortlessly with AniLINK! Unlock the power to play any anime series directly in your preferred video player or download entire seasons in a single click using popular download managers like IDM. AniLINK generates direct download links for all episodes, conveniently sorted by quality. Elevate your anime-watching experience now!

  1. // ==UserScript==
  2. // @name AniLINK - Episode Link Extractor
  3. // @namespace https://gf.qytechs.cn/en/users/781076-jery-js
  4. // @version 6.12.0
  5. // @description Stream or download your favorite anime series effortlessly with AniLINK! Unlock the power to play any anime series directly in your preferred video player or download entire seasons in a single click using popular download managers like IDM. AniLINK generates direct download links for all episodes, conveniently sorted by quality. Elevate your anime-watching experience now!
  6. // @icon https://www.google.com/s2/favicons?domain=animepahe.ru
  7. // @author Jery
  8. // @license MIT
  9. // @match https://anitaku.*/*
  10. // @match https://anitaku.bz/*
  11. // @match https://gogoanime.*/*
  12. // @match https://gogoanime3.cc/*
  13. // @match https://gogoanime3.*/*
  14. // @match https://animepahe.*/play/*
  15. // @match https://animepahe.*/anime/*
  16. // @match https://animepahe.ru/play/*
  17. // @match https://animepahe.com/play/*
  18. // @match https://animepahe.org/play/*
  19. // @match https://yugenanime.*/anime/*/*/watch/
  20. // @match https://yugenanime.tv/anime/*/*/watch/
  21. // @match https://yugenanime.sx/anime/*/*/watch/
  22. // @match https://hianime.*/watch/*
  23. // @match https://hianime.to/watch/*
  24. // @match https://hianime.nz/watch/*
  25. // @match https://hianime.sz/watch/*
  26. // @match https://otaku-streamers.com/info/*/*
  27. // @match https://beta.otaku-streamers.com/watch/*/*
  28. // @match https://beta.otaku-streamers.com/title/*/*
  29. // @match https://animeheaven.me/anime.php?*
  30. // @match https://animez.org/*/*
  31. // @match https://animeyy.com/*/*
  32. // @match https://*.miruro.to/watch?id=*
  33. // @match https://*.miruro.tv/watch?id=*
  34. // @match https://*.miruro.online/watch?id=*
  35. // @match https://anizone.to/anime/*
  36. // @match https://anixl.to/title/*
  37. // @match https://sudatchi.com/watch/*/*
  38. // @match https://animekai.to/watch/*
  39. // @match https://hianime.*/watch/*
  40. // @match https://hianime.to/watch/*
  41. // @match https://hianime.nz/watch/*
  42. // @match https://hianimeZ.*/watch/*
  43. // @match https://aninow.tv/w/*
  44. // @grant GM_registerMenuCommand
  45. // @grant GM_xmlhttpRequest
  46. // @grant GM.xmlHttpRequest
  47. // @require https://cdn.jsdelivr.net/npm/@trim21/gm-fetch@0.2.1
  48. // @grant GM_addStyle
  49. // @grant GM_getValue
  50. // ==/UserScript==
  51.  
  52. /**
  53. * Represents an anime episode with metadata and streaming links.
  54. */
  55. class Episode {
  56. /**
  57. * @param {string} number - The episode number.
  58. * @param {string} animeTitle - The title of the anime.
  59. * @param {Object.<string, {stream: string, type: 'm3u8'|'mp4', tracks: Array<{file: string, kind: 'caption'|'audio', label: string}>}>} links - An object containing streaming links and tracks for each source.
  60. * @param {string} thumbnail - The URL of the episode's thumbnail image.
  61. * @param {string} [epTitle] - The title of the episode (optional).
  62. */
  63. constructor(number, animeTitle, links, thumbnail, epTitle) {
  64. this.number = String(number); // The episode number
  65. this.animeTitle = animeTitle; // The title of the anime.
  66. this.epTitle = epTitle; // The title of the episode (this can be the specific ep title or blank).
  67. this.links = this._processLinks(links); // An object containing streaming links and tracks for each source: {"source1":{stream:"url", type:"m3u8|mp4", tracks:[{file:"url", kind:"caption|audio", label:"name"}]}}}
  68. this.thumbnail = thumbnail; // The URL of the episode's thumbnail image (if unavailable, then just any image is fine. Thumbnail property isnt really used in the script yet).
  69. this.filename = `${this.animeTitle} - ${this.number.padStart(3, '0')}${this.epTitle ? ` - ${this.epTitle}` : ''}.${Object.values(this.links)[0]?.type || 'm3u8'}`; // The formatted name of the episode, combining anime name, number and title and extension.
  70. this.title = this.epTitle ?? this.animeTitle;
  71. }
  72. // Processes the links to ensure they are absolute URLs.
  73. _processLinks(links) {
  74. for (const linkObj of Object.values(links)) {
  75. linkObj.stream &&= new URL(linkObj.stream, location.origin).href;
  76. linkObj.tracks?.forEach?.(track => track.file &&= new URL(track.file, location.origin).href);
  77. }
  78. return links;
  79. }
  80. }
  81.  
  82. /**
  83. * @typedef {Object} Websites[]
  84. * @property {string} name - The name of the website (required).
  85. * @property {string[]} url - An array of URL patterns that identify the website (required).
  86. * @property {string} thumbnail - A CSS selector to identify the episode thumbnail on the website (required).
  87. * @property {Function} addStartButton - A function to add the "Generate Download Links" button to the website (required).
  88. * @property {AsyncGeneratorFunction} extractEpisodes - An async generator function to extract episode information from the website (required).
  89. * @property {string} epLinks - A CSS selector to identify the episode links on the website (optional).
  90. * @property {string} epTitle - A CSS selector to identify the episode title on the website (optional).
  91. * @property {string} linkElems - A CSS selector to identify the download link elements on the website (optional).
  92. * @property {string} [animeTitle] - A CSS selector to identify the anime title on the website (optional).
  93. * @property {string} [epNum] - A CSS selector to identify the episode number on the website (optional).
  94. * @property {Function} [_getVideoLinks] - A function to extract video links from the website (optional).
  95. * @property {string} [styles] - Custom CSS styles to be applied to the website (optional).
  96. *
  97. * @description An array of website configurations for extracting episode links.
  98. *
  99. * @note To add a new website, follow these steps:
  100. * 1. Create a new object with the following properties:
  101. * - `name`: The name of the website.
  102. * - `url`: An array of URL patterns that identify the website.
  103. * - `thumbnail`: A CSS selector to identify the episode thumbnail on the website.
  104. * - `addStartButton`: A function to add the "Generate Download Links" button to the website.
  105. * - `extractEpisodes`: An async generator function to extract episode information from the website.
  106. * 2. Optionally, add the following properties if needed (they arent used by the script, but they will come in handy when the animesite changes its layout):
  107. * - `animeTitle`: A CSS selector to identify the anime title on the website.
  108. * - `epLinks`: A CSS selector to identify the episode links on the website.
  109. * - `epTitle`: A CSS selector to identify the episode title on the website.
  110. * - `linkElems`: A CSS selector to identify the download link elements on the website.
  111. * - `epNum`: A CSS selector to identify the episode number on the website.
  112. * - `_getVideoLinks`: A function to extract video links from the website.
  113. * - `styles`: Custom CSS styles to be applied to the website.
  114. * 3. Implement the `addStartButton` function to add the "Generate Download Links" button to the website.
  115. * - This function should create a element and append it to the appropriate location on the website.
  116. * - The button should have an ID of "AniLINK_startBtn".
  117. * 4. Implement the `extractEpisodes` function to extract episode information from the website.
  118. * - This function should be an async generator function that yields Episode objects (To ensure fast processing, using chunks is recommended).
  119. * - Use the `fetchPage` function to fetch the HTML content of each episode page.
  120. * - Parse the HTML content to extract the episode title, number, links, and thumbnail.
  121. * - Create an `Episode` object for each episode and yield it using the `yieldEpisodesFromPromises` function.
  122. * 5. Optionally, implement the `_getVideoLinks` function to extract video links from the website.
  123. * - This function should return a promise that resolves to an object containing video links.
  124. * - Use this function if the video links require additional processing or API calls.
  125. * - Tip: use GM_xmlhttpRequest to make cross-origin requests if needed (I've used proxy.sh so far which I plan to change in the future since GM_XHR seems more reliable).
  126. */
  127. const websites = [
  128. {
  129. name: 'GoGoAnime',
  130. url: ['anitaku.to/', 'gogoanime3.co/', 'gogoanime3', 'anitaku', 'gogoanime'],
  131. epLinks: '#episode_related > li > a',
  132. epTitle: '.title_name > h2',
  133. linkElems: '.cf-download > a',
  134. thumbnail: '.headnav_left > a > img',
  135. addStartButton: function () {
  136. const button = Object.assign(document.createElement('a'), {
  137. id: "AniLINK_startBtn",
  138. style: "cursor: pointer; background-color: #145132;",
  139. innerHTML: document.querySelector("div.user_auth a[href='/login.html']")
  140. ? `<b style="color:#FFC119;">AniLINK:</b> Please <a href="/login.html"><u>log in</u></a> to download`
  141. : '<i class="icongec-dowload"></i> Generate Download Links'
  142. });
  143. const target = location.href.includes('/category/') ? '#episode_page' : '.cf-download';
  144. document.querySelector(target)?.appendChild(button);
  145. return button;
  146. },
  147. extractEpisodes: async function* (status) {
  148. const throttleLimit = 12; // Number of episodes to extract in parallel
  149. const allEpLinks = Array.from(document.querySelectorAll(this.epLinks));
  150. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  151. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  152. const chunk = epLinks.slice(i, i + throttleLimit);
  153. const episodePromises = chunk.map(async epLink => {
  154. try {
  155. const page = await fetchPage(epLink.href);
  156.  
  157. const [, epTitle, epNumber] = page.querySelector(this.epTitle).textContent.match(/(.+?) Episode (\d+(?:\.\d+)?)/);
  158. const thumbnail = page.querySelector(this.thumbnail).src;
  159. status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, '0')}...`;
  160. const links = [...page.querySelectorAll(this.linkElems)].reduce((obj, elem) => ({ ...obj, [elem.textContent.trim()]: { stream: elem.href, type: 'mp4' } }), {});
  161. status.textContent = `Extracted ${epTitle} - ${epNumber.padStart(3, '0')}`;
  162.  
  163. return new Episode(epNumber, epTitle, links, thumbnail); // Return Episode object
  164. } catch (e) { showToast(e); return null; }
  165. }); // Handle errors and return null
  166.  
  167. yield* yieldEpisodesFromPromises(episodePromises); // Use helper function
  168. }
  169. }
  170. },
  171. {
  172. name: 'YugenAnime',
  173. url: ['yugenanime.tv', 'yugenanime.sx'],
  174. epLinks: '.ep-card > a.ep-thumbnail',
  175. animeTitle: '.ani-info-ep .link h1',
  176. epTitle: 'div.col.col-w-65 > div.box > h1',
  177. thumbnail: 'a.ep-thumbnail img',
  178. addStartButton: function () {
  179. return document.querySelector(".content .navigation").appendChild(Object.assign(document.createElement('a'), { id: "AniLINK_startBtn", className: "link p-15", textContent: "Generate Download Links" }));
  180. },
  181. extractEpisodes: async function* (status) {
  182. status.textContent = 'Getting list of episodes...';
  183. const allEpLinks = Array.from(document.querySelectorAll(this.epLinks));
  184. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  185.  
  186. const throttleLimit = 6; // Number of episodes to extract in parallel
  187.  
  188. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  189. const chunk = epLinks.slice(i, i + throttleLimit);
  190. const episodePromises = chunk.map(async (epLink, index) => {
  191. try {
  192. status.textContent = `Loading ${epLink.pathname}`;
  193. const page = await fetchPage(epLink.href);
  194.  
  195. const animeTitle = page.querySelector(this.animeTitle).textContent;
  196. const epNumber = epLink.href.match(/(\d+)\/?$/)[1];
  197. const epTitle = page.querySelector(this.epTitle).textContent.match(/^${epNumber} : (.+)$/) || animeTitle;
  198. const thumbnail = document.querySelectorAll(this.thumbnail)[index].src;
  199. status.textContent = `Extracting ${`${epNumber.padStart(3, '0')} - ${animeTitle}` + (epTitle != animeTitle ? `- ${epTitle}` : '')}...`;
  200. const rawLinks = await this._getVideoLinks(page, status, epTitle);
  201. const links = Object.entries(rawLinks).reduce((acc, [quality, url]) => ({ ...acc, [quality]: { stream: url, type: 'm3u8' } }), {});
  202.  
  203. return new Episode(epNumber, epTitle, links, thumbnail);
  204. } catch (e) { showToast(e); return null; }
  205. });
  206. yield* yieldEpisodesFromPromises(episodePromises);
  207. }
  208. },
  209. _getVideoLinks: async function (page, status, episodeTitle) {
  210. const embedLinkId = page.body.innerHTML.match(new RegExp(`src="//${page.domain}/e/(.*?)/"`))[1];
  211. const embedApiResponse = await fetch(`https://${page.domain}/api/embed/`, { method: 'POST', headers: { "X-Requested-With": "XMLHttpRequest" }, body: new URLSearchParams({ id: embedLinkId, ac: "0" }) });
  212. const json = await embedApiResponse.json();
  213. const m3u8GeneralLink = json.hls[0];
  214. status.textContent = `Parsing ${episodeTitle}...`;
  215. // Fetch the m3u8 file content
  216. const m3u8Response = await fetch(m3u8GeneralLink);
  217. const m3u8Text = await m3u8Response.text();
  218. // Parse the m3u8 file to extract different qualities
  219. const qualityMatches = m3u8Text.matchAll(/#EXT-X-STREAM-INF:.*RESOLUTION=\d+x\d+.*NAME="(\d+p)"\n(.*\.m3u8)/g);
  220. const links = {};
  221. for (const match of qualityMatches) {
  222. const [_, quality, m3u8File] = match;
  223. links[quality] = `${m3u8GeneralLink.slice(0, m3u8GeneralLink.lastIndexOf('/') + 1)}${m3u8File}`;
  224. }
  225. return links;
  226. }
  227. },
  228. {
  229. name: 'AnimePahe',
  230. url: ['animepahe.ru', 'animepahe.com', 'animepahe.org'],
  231. epLinks: (location.pathname.startsWith('/anime/')) ? 'a.play' : '.dropup.episode-menu a.dropdown-item',
  232. epTitle: '.theatre-info > h1',
  233. linkElems: '#resolutionMenu > button',
  234. thumbnail: '.theatre-info > a > img',
  235. addStartButton: function () {
  236. GM_addStyle(`.theatre-settings .col-sm-3 { max-width: 20%; }`);
  237. (document.location.pathname.startsWith('/anime/'))
  238. ? document.querySelector(".col-6.bar").innerHTML += `<div class="btn-group btn-group-toggle"><label id="AniLINK_startBtn" class="btn btn-dark btn-sm">Generate Download Links</label></div>`
  239. : document.querySelector("div.theatre-settings > div.row").innerHTML += `<div class="col-12 col-sm-3"><div class="dropup"><a class="btn btn-secondary btn-block" id="AniLINK_startBtn">Generate Download Links</a></div></div>`;
  240. return document.getElementById("AniLINK_startBtn");
  241. },
  242. extractEpisodes: async function* (status) {
  243. const allEpLinks = Array.from(document.querySelectorAll(this.epLinks));
  244. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  245. // Resolve the ep numbering offset (sometimes, a 2nd cour can have ep.num=13 while its s2e1)
  246. const firstEp = () => document.querySelector(this.epLinks).textContent.match(/.*\s(\d+)/)[1];
  247. let firstEpNum = firstEp();
  248. if (document.querySelector('.btn.active')?.innerText == 'desc') {
  249. document.querySelector('.episode-bar .btn').click();
  250. await new Promise(r => { const c = () => firstEp() !== firstEpNum ? r() : setTimeout(c, 500); c(); });
  251. firstEpNum = firstEp();
  252. }
  253.  
  254. const throttleLimit = 36; // Setting high throttle limit actually improves performance
  255. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  256. const chunk = epLinks.slice(i, i + throttleLimit);
  257. const episodePromises = chunk.map(async epLink => {
  258. try {
  259. const page = await fetchPage(epLink.href);
  260.  
  261. if (page.querySelector(this.epTitle) == null) return;
  262. const [, animeTitle, epNum] = page.querySelector(this.epTitle).outerText.split(/Watch (.+) - (\d+(?:\.\d+)?) Online$/);
  263. const epNumber = (epNum - firstEpNum + 1).toString();
  264. const thumbnail = page.querySelector(this.thumbnail).src;
  265. status.textContent = `Extracting ${animeTitle} - ${epNumber.padStart(3, "0")}...`;
  266.  
  267. async function getVideoUrl(kwikUrl) {
  268. const response = await fetch(kwikUrl, { headers: { "Referer": "https://animepahe.com" } });
  269. const data = await response.text();
  270. return eval(/(eval)(\(f.*?)(\n<\/script>)/s.exec(data)[2].replace("eval", "")).match(/https.*?m3u8/)[0];
  271. }
  272. let links = {};
  273. for (const elm of [...page.querySelectorAll(this.linkElems)]) {
  274. links[elm.textContent] = { stream: await getVideoUrl(elm.getAttribute('data-src')), type: 'm3u8' };
  275. status.textContent = `Parsed ${`${epNumber.padStart(3, '0')} - ${animeTitle}`}`;
  276. }
  277. return new Episode(epNumber, animeTitle, links, thumbnail);
  278. } catch (e) { showToast(e); return null; }
  279. });
  280. yield* yieldEpisodesFromPromises(episodePromises);
  281. }
  282. },
  283. styles: `div#AniLINK_LinksContainer { font-size: 10px; } #Quality > b > div > ul {font-size: 16px;}`
  284. },
  285. {
  286. name: 'Beta-Otaku-Streamers',
  287. url: ['beta.otaku-streamers.com'],
  288. epLinks: (document.location.pathname.startsWith('/title/')) ? '.item-title a' : '.video-container .clearfix > a',
  289. epTitle: '.title > a',
  290. epNum: '.watch_curep',
  291. thumbnail: 'video',
  292. addStartButton: function () {
  293. (document.location.pathname.startsWith('/title/')
  294. ? document.querySelector(".album-top-box") : document.querySelector('.video-container .title-box'))
  295. .innerHTML += `<a id="AniLINK_startBtn" class="btn btn-outline rounded-btn">Generate Download Links</a>`;
  296. return document.getElementById("AniLINK_startBtn");
  297. },
  298. extractEpisodes: async function* (status) {
  299. const allEpLinks = Array.from(document.querySelectorAll(this.epLinks));
  300. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  301. const throttleLimit = 12;
  302.  
  303. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  304. const chunk = epLinks.slice(i, i + throttleLimit);
  305. const episodePromises = chunk.map(async epLink => {
  306. try {
  307. const page = await fetchPage(epLink.href);
  308. const epTitle = page.querySelector(this.epTitle).textContent.trim();
  309. const epNumber = page.querySelector(this.epNum).textContent.replace("Episode ", '');
  310. const thumbnail = page.querySelector(this.thumbnail).poster;
  311.  
  312. status.textContent = `Extracting ${epTitle} - ${epNumber}...`;
  313. const links = { 'Video Links': { stream: page.querySelector('video > source').src, type: 'mp4' } };
  314.  
  315. return new Episode(epNumber, epTitle, links, thumbnail);
  316. } catch (e) { showToast(e); return null; }
  317. });
  318. yield* yieldEpisodesFromPromises(episodePromises);
  319. }
  320. }
  321. },
  322. {
  323. name: 'Otaku-Streamers',
  324. url: ['otaku-streamers.com'],
  325. epLinks: 'table > tbody > tr > td:nth-child(2) > a',
  326. epTitle: '#strw_player > table > tbody > tr:nth-child(1) > td > span:nth-child(1) > a',
  327. epNum: '#video_episode',
  328. thumbnail: 'otaku-streamers.com/images/os.jpg',
  329. addStartButton: function () {
  330. const button = document.createElement('a');
  331. button.id = "AniLINK_startBtn";
  332. button.style.cssText = `cursor: pointer; background-color: #145132; float: right;`;
  333. button.innerHTML = 'Generate Download Links';
  334. document.querySelector('table > tbody > tr:nth-child(2) > td > div > table > tbody > tr > td > h2').appendChild(button);
  335. return button;
  336. },
  337. extractEpisodes: async function* (status) {
  338. const allEpLinks = Array.from(document.querySelectorAll(this.epLinks));
  339. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  340. const throttleLimit = 12; // Number of episodes to extract in parallel
  341.  
  342. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  343. const chunk = epLinks.slice(i, i + throttleLimit);
  344. const episodePromises = chunk.map(async epLink => {
  345. try {
  346. const page = await fetchPage(epLink.href);
  347. const epTitle = page.querySelector(this.epTitle).textContent;
  348. const epNumber = page.querySelector(this.epNum).textContent.replace("Episode ", '')
  349.  
  350. status.textContent = `Extracting ${epTitle} - ${epNumber}...`;
  351. const links = { 'mp4': { stream: page.querySelector('video > source').src, type: 'mp4' } };
  352.  
  353. return new Episode(epNumber, epTitle, links, this.thumbnail); // Return Episode object
  354. } catch (e) { showToast(e); return null; }
  355. }); // Handle errors and return null
  356.  
  357. yield* yieldEpisodesFromPromises(episodePromises); // Use helper function
  358. }
  359. }
  360. },
  361. {
  362. name: 'AnimeHeaven',
  363. url: ['animeheaven.me'],
  364. epLinks: 'a.ac3',
  365. epTitle: 'a.c2.ac2',
  366. epNumber: '.boxitem.bc2.c1.mar0',
  367. thumbnail: 'img.posterimg',
  368. addStartButton: function () {
  369. const button = document.createElement('a');
  370. button.id = "AniLINK_startBtn";
  371. button.style.cssText = `cursor: pointer; border: 2px solid red; padding: 4px;`;
  372. button.innerHTML = 'Generate Download Links';
  373. document.querySelector("div.linetitle2.c2").parentNode.insertBefore(button, document.querySelector("div.linetitle2.c2"));
  374. return button;
  375. },
  376. extractEpisodes: async function* (status) {
  377. const allEpLinks = Array.from(document.querySelectorAll(this.epLinks));
  378. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  379. const throttleLimit = 12; // Number of episodes to extract in parallel
  380.  
  381. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  382. const chunk = epLinks.slice(i, i + throttleLimit);
  383. const episodePromises = chunk.map(async epLink => {
  384. try {
  385. const page = await fetchPage(epLink.href);
  386. const epTitle = page.querySelector(this.epTitle).textContent;
  387. const epNumber = page.querySelector(this.epNumber).textContent.replace("Episode ", '');
  388. const thumbnail = document.querySelector(this.thumbnail).src;
  389.  
  390. status.textContent = `Extracting ${epTitle} - ${epNumber}...`;
  391. const links = [...page.querySelectorAll('#vid > source')].reduce((acc, source) => ({ ...acc, [source.src.match(/\/\/(\w+)\./)[1]]: { stream: source.src, type: 'mp4' } }), {});
  392.  
  393. return new Episode(epNumber, epTitle, links, thumbnail); // Return Episode object
  394. } catch (e) { showToast(e); return null; }
  395. }); // Handle errors and return null
  396.  
  397. yield* yieldEpisodesFromPromises(episodePromises); // Use helper function
  398. }
  399. }
  400. },
  401. {
  402. name: 'AnimeZ',
  403. url: ['animez.org', 'animeyy.com'],
  404. epLinks: 'li.wp-manga-chapter a',
  405. epTitle: '#title-detail-manga',
  406. epNum: '.wp-manga-chapter.active',
  407. thumbnail: '.Image > figure > img',
  408. addStartButton: function () {
  409. (document.querySelector(".MovieTabNav.ControlPlayer") || document.querySelector(".mb-3:has(#keyword_chapter)"))
  410. .innerHTML += `<div class="Lnk AAIco-link" id="AniLINK_startBtn">Extract Episode Links</div>`;
  411. return document.getElementById("AniLINK_startBtn");
  412. },
  413. extractEpisodes: async function* (status) {
  414. /// work in progress- stopped when animes.org started redirecting to some random manhwa site
  415. status.textContent = 'Fetching Episodes List...';
  416. const mangaId = (window.location.pathname.match(/-(\d+)(?:\/|$)/) || [])[1] || document.querySelector('[data-manga-id]')?.getAttribute('data-manga-id');
  417. if (!mangaId) return showToast('Could not determine manga_id for episode list.');
  418. const nav = [...document.querySelectorAll('#nav_list_chapter_id_detail li > :not(a.next)')];
  419. const maxPage = Math.max(1, ...Array.from(nav).map(a => +(a.getAttribute('onclick')?.match(/load_list_chapter\((\d+)\)/)?.[1] || 0)).filter(Boolean));
  420. // Parse all episode links from all pages in parallel
  421. status.textContent = `Loading all ${maxPage} episode pages...`;
  422. let allEpLinks = [];
  423. try {
  424. await Promise.all(Array.from({ length: maxPage }, (_, i) => fetch(`/?act=ajax&code=load_list_chapter&manga_id=${mangaId}&page_num=${i + 1}&chap_id=0&keyword=`).then(r => r.text()).then(t => {
  425. let html = JSON.parse(t).list_chap;
  426. const doc = document.implementation.createHTMLDocument('eps');
  427. doc.body.innerHTML = html;
  428. allEpLinks.push(...doc.querySelectorAll(this.epLinks));
  429. })));
  430. } catch (e) { showToast('Failed to load Episodes List: ' + e); return null; }
  431. // Remove duplicates
  432. allEpLinks = allEpLinks.filter((el, idx, self) => self.findIndex(e => e.href === el.href && e.textContent.trim() === el.textContent.trim()) === idx);
  433. const epLinks = await applyEpisodeRangeFilter(allEpLinks);
  434. const throttleLimit = 12;
  435. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  436. const chunk = epLinks.slice(i, i + throttleLimit);
  437. const episodePromises = chunk.map(async epLink => {
  438. try {
  439. const page = await fetchPage(epLink.href);
  440. const epTitle = page.querySelector(this.epTitle).textContent;
  441. const isDub = page.querySelector(this.epNum).textContent.includes('-Dub');
  442. const epNumber = page.querySelector(this.epNum).textContent.replace(/-Dub/, '').trim();
  443. const thumbnail = document.querySelector(this.thumbnail).src;
  444.  
  445. status.textContent = `Extracting ${epTitle} - ${epNumber}...`;
  446. const links = { [isDub ? "Dub" : "Sub"]: { stream: page.querySelector('iframe').src.replace('/embed/', '/anime/'), type: 'm3u8' } };
  447.  
  448. return new Episode(epNumber, epTitle, links, thumbnail); // Return Episode object
  449. } catch (e) { showToast(e); return null; }
  450. });
  451. yield* yieldEpisodesFromPromises(episodePromises);
  452. }
  453. }
  454. },
  455. {
  456. name: 'Miruro',
  457. url: ['miruro.to', 'miruro.tv', 'miruro.online'],
  458. animeTitle: '.anime-title > a',
  459. thumbnail: 'a[href^="/info?id="] > img',
  460. baseApiUrl: `${location.origin}/api`,
  461. addStartButton: function (id) {
  462. const intervalId = setInterval(() => {
  463. const target = document.querySelector('.title-actions-container');
  464. if (target) {
  465. clearInterval(intervalId);
  466. const btn = document.createElement('button');
  467. btn.id = id;
  468. btn.style.cssText = `${target.lastChild.style.cssText} display: flex; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: auto;`;
  469. btn.className = target.lastChild.className;
  470. btn.innerHTML = `
  471. <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="3 3 18 18"><path fill="currentColor" d="M5 21q-.825 0-1.413-.588T3 19V5q0-.825.588-1.413T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.588 1.413T19 21H5Zm0-2h14V5H5v14Zm3-4.5h2.5v-6H8v6Zm5.25 0h2.5v-6h-2.5v6Zm5.25 0h2.5v-6h-2.5v6Z"/></svg>
  472. <div style="display: flex; justify-content: center; align-items: center;">Extract Episode Links</div>
  473. `;
  474. btn.addEventListener('click', extractEpisodes);
  475. target.appendChild(btn);
  476. }
  477. }, 200);
  478. },
  479. extractEpisodes: async function* (status) {
  480. status.textContent = 'Fetching episode list...';
  481. const animeTitle = (document.querySelector('p.title-romaji') || document.querySelector(this.animeTitle)).textContent;
  482. const malId = document.querySelector(`a[href*="/myanimelist.net/anime/"]`)?.href.split('/').pop();
  483. if (!malId) return showToast('MAL ID not found.');
  484.  
  485. const res = await fetch(`${this.baseApiUrl}/episodes?malId=${malId}`).then(r => r.json());
  486. const providers = Object.entries(res).flatMap(([p, s]) => {
  487. if (p === "ANIMEZ") {
  488. // AnimeZ: treat sub and dub as separate providers
  489. const v = Object.values(s)[0], animeId = Object.keys(s)[0], eps = v?.episodeList?.episodes;
  490. if (!eps) return [];
  491. return ["sub", "dub"].map(type =>eps[type]?.length ? { source: `animez-${type}`, animeId, useEpId: true, epList: eps[type] } : null);
  492. } else {
  493. // Default: original logic
  494. let v = Object.values(s)[0], epl = v?.episodeList?.episodes || v?.episodeList;
  495. // Fix the ep numbering offset for animepahe (sometimes S2 starts from ep 13 instead of ep 1)
  496. if (p === "ANIMEPAHE") epl = epl?.map((e) => ({...e, number: (e.number - epl[0]?.number + 1)}));
  497. return epl && { source: p.toLowerCase(), animeId: Object.keys(s)[0], useEpId: !!v?.episodeList?.episodes, epList: epl };
  498. }
  499. }).filter(Boolean);
  500.  
  501. // Get the provider with most episodes to use as base for thumbnails, epTitle, epNumber, etc.
  502. // Preferred provider is Zoro, if available, since it has the best title format
  503. let baseProvider = providers.find(p => p.source === 'zoro') || providers.find(p => p.epList.length == Math.max(...providers.map(p => p.epList.length)));
  504. baseProvider = { ...baseProvider, epList: await applyEpisodeRangeFilter(baseProvider.epList) };
  505.  
  506. if (!baseProvider) return showToast('No episodes found.');
  507.  
  508. for (const baseEp of baseProvider.epList) {
  509. const num = String(baseEp.number).padStart(3, '0');
  510. let epTitle = baseEp.jptitle || baseEp.title, thumbnail = baseEp.snapshot; // will try to update with other providers if this is blank
  511.  
  512. status.textContent = `Fetching Ep ${num}...`;
  513. let links = {};
  514. await Promise.all(providers.map(async ({ source, animeId, useEpId, epList }) => {
  515. const ep = epList.find(ep => ep.number == baseEp.number);
  516. epTitle = epTitle || ep.title; // update title if blank
  517. const epId = !useEpId ? `${animeId}/ep-${ep.number}` : ep.id;
  518. try {
  519. const apiProvider = source.startsWith('animez-') ? 'animez' : source;
  520. const sres = await fetchWithRetry(`${this.baseApiUrl}/sources?episodeId=${epId}&provider=${apiProvider}`);
  521. const sresJson = await sres.json();
  522. links[this._getLocalSourceName(source)] = { stream: sresJson.streams[0].url, type: "m3u8", tracks: sresJson.tracks || [] };
  523. } catch (e) { showToast(`Failed to fetch ep-${ep.number} from ${source}: ${e}`); return null; }
  524. }));
  525.  
  526. if (!epTitle || /^Episode \d+/.test(epTitle)) epTitle = undefined; // remove epTitle if episode title is blank or just "Episode X"
  527. yield new Episode(num, animeTitle, links, thumbnail || document.querySelector(this.thumbnail).src, epTitle);
  528. }
  529. },
  530. _getLocalSourceName: function (source) {
  531. const sourceNames = { 'animepahe': 'kiwi', 'animekai': 'arc', 'animez-sub': 'jet-sub', 'animez-dub': 'jet-dub', 'zoro': 'zoro' };
  532. return sourceNames[source] || source.charAt(0).toUpperCase() + source.slice(1);
  533. },
  534. },
  535. {
  536. name: 'AniZone',
  537. url: ['anizone.to/'],
  538. animeTitle: 'nav > span',
  539. epTitle: 'div.space-y-2 > div.text-center',
  540. epNumber: 'a[x-ref="activeEps"] > div > div',
  541. thumbnail: 'media-poster',
  542. epLinks: () => [...new Set(Array.from(document.querySelectorAll('a[href^="https://anizone.to/anime/"]')).map(a => a.href))],
  543. addStartButton: function () {
  544. const target = document.querySelector('button > span.truncate')?.parentElement || document.querySelector('.grow + div select');
  545. const button = Object.assign(document.createElement('button'), {
  546. id: "AniLINK_startBtn",
  547. className: target.className,
  548. style: "display: flex; justify-content: center; align-items: center; width: 100%;",
  549. innerHTML: `<svg xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;" height="1em" viewBox="3 3 18 18"><path fill="currentColor" d="M5 21q-.825 0-1.413-.588T3 19V5q0-.825.588-1.413T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.588 1.413T19 21H5Zm0-2h14V5H5v14Zm3-4.5h2.5v-6H8v6Zm5.25 0h2.5v-6h-2.5v6Zm5.25 0h2.5v-6h-2.5v6Z"/></svg><span class="truncate">Extract Episode Links</span>`
  550. });
  551. target.parentElement.appendChild(button);
  552. return button;
  553. },
  554. extractEpisodes: async function* (status) {
  555. const epLinks = await applyEpisodeRangeFilter(this.epLinks());
  556. const throttleLimit = 12; // Limit concurrent requests
  557. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  558. const chunk = epLinks.slice(i, i + throttleLimit);
  559. const episodePromises = chunk.map(async epLink => {
  560. try {
  561. const page = await fetchPage(epLink);
  562. const animeTitle = page.querySelector(this.animeTitle)?.textContent.trim();
  563. const epNum = page.querySelector(this.epNumber)?.textContent.trim();
  564. const epTitle = page.querySelector(this.epTitle)?.textContent.trim();
  565. const thumbnail = page.querySelector(this.thumbnail)?.src;
  566.  
  567. status.textContent = `Extracting ${epNum} - ${epTitle}...`;
  568. const links = { [page.querySelector('button > span.truncate').textContent]: { stream: page.querySelector("media-player").getAttribute("src"), type: "m3u8", tracks: [...page.querySelectorAll("media-provider>track")].map(t => ({ file: t.src, kind: t.kind, label: t.label })) } };
  569.  
  570. return new Episode(epNum, animeTitle, links, thumbnail, epTitle);
  571. } catch (e) { showToast(e); return null; }
  572. });
  573. yield* yieldEpisodesFromPromises(episodePromises);
  574. }
  575. }
  576. },
  577. {
  578. name: 'AniXL',
  579. url: ['anixl.to/'],
  580. animeTitle: () => document.querySelector('a.link[href^="/title/"]').textContent,
  581. epLinks: () => [...document.querySelectorAll('div[q\\:key^="F0_"] a')].map(e=>e.href),
  582. addStartButton: () => document.querySelector('div.join')?.prepend( Object.assign(document.createElement('button'), {id: "AniLINK_startBtn", className: "btn btn-xs", textContent: "Generate Download Links"}) ),
  583. extractEpisodes: async function* (status) {
  584. const epLinks = await applyEpisodeRangeFilter(this.epLinks());
  585. const throttleLimit = 12; // Limit concurrent requests
  586. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  587. const chunk = epLinks.slice(i, i + throttleLimit);
  588. const episodePromises = chunk.map(async epLink => {
  589. return await fetchPage(epLink).then(page => {
  590. const [, epNum, epTitle] = page.querySelector('a[q\\:id="1s"]').textContent.match(/Ep (\d+) : (.*)/d)
  591. status.textContent = `Extracting ${epNum} - ${epTitle}...`;
  592. const links = page.querySelector('script[type="qwik/json"]').textContent.match(/"[ds]ub","https:\/\/[^"]+\/media\/[^"]+\/[^"]+\.m3u8"/g)?.map(s => s.split(',').map(JSON.parse)).reduce((acc, [type, url]) => ({ ...acc, [type]: { stream: url, type: 'm3u8', tracks: [] } }), {});
  593. return new Episode(epNum, this.animeTitle(), links, null, epTitle);
  594. }).catch(e => { showToast(e); return null; });
  595. });
  596. yield* yieldEpisodesFromPromises(episodePromises);
  597. }
  598. }
  599. },
  600. {
  601. name: 'Sudatchi',
  602. url: ['sudatchi.com/'],
  603. epLinks: () => [...document.querySelectorAll('.text-sm.rounded-lg')].map(e => `${location.href}/../${e.textContent}`),
  604. extractEpisodes: async function* (status) {
  605. for (let i = 0, l = await applyEpisodeRangeFilter(this.epLinks()); i < l.length; i += 6)
  606. yield* yieldEpisodesFromPromises(l.slice(i, i + 6).map(async link =>
  607. await fetchPage(link).then(p => {
  608. const tracks = JSON.parse([...p.scripts].flatMap(s => s.textContent.match(/\[{.*"}/)).filter(Boolean)[0].replaceAll('\\', '') + ']').map(i => ({ file: i.file.replace('/ipfs/', 'https://sudatchi.com/api/proxy/'), label: i.label, kind: i.kind }));
  609. const links = { 'Sudatchi': { stream: p.querySelector('meta[property="og:video"]').content.replace(/http.*:8888/, location.origin), type: 'm3u8', tracks } };
  610. return new Episode(link.split('/').pop(), p.querySelector('p').textContent, links, p.querySelector('video').poster);
  611. }).catch(e => { showToast(e); return null; })
  612. ));
  613. }
  614. },
  615. {
  616. name: 'HiAnime',
  617. url: ['hianime.to/', 'hianimez.is/', 'hianimez.to/', 'hianime.nz/', 'hianime.bz/', 'hianime.pe/', 'hianime.cx/', 'hianime.gs/'],
  618. extractEpisodes: async function* (status) {
  619. for (let e of await applyEpisodeRangeFilter($('.ss-list > a').get())) {
  620. const [epId, epNum, epTitle] = [$(e).data('id'), $(e).data('number'), $(e).find('.ep-name').text()]; let thumbnail = '';
  621. status.textContent = `Extracting Episode ${epNum}...`;
  622. const links = await $((await $.get(`/ajax/v2/episode/servers?episodeId=${epId}`, r => $(r).responseJSON)).html).find('.server-item').map((_, i) => [[$(i).text().trim(), $(i).data('type')]] ).get().reduce(async (linkAcc, [server, type]) => {try {
  623. await (new Promise(r => setTimeout(r, 1000))); // Throttle requests to avoid rate limiting
  624. let data = await GM_fetch(`https://hianime.pstream.org/api/v2/hianime/episode/sources?animeEpisodeId=${location.pathname.split('/').pop()}?ep=${epId}&server=${server.toLowerCase()}&category=${type}`, { 'headers': { 'Origin': 'https://pstream.org' } }).then(r => r.json()).then(j => j.data);
  625. thumbnail = data.tracks.find(t => t.lang === 'thumbnails')?.url || '';
  626. return {...await linkAcc, [`${server}-${type}`]: { stream: data.sources[0].url, type: 'm3u8', tracks: data.tracks.filter(t=>t.lang!="thumbnails").map(t=> ({file: t.url, label: t.lang, kind: 'caption'})) }};
  627. } catch (e) { showToast(`Failed to fetch Ep ${epNum} from ${server}-${type} (you're probably being rate limited)`); return linkAcc; }}, Promise.resolve({}));
  628. yield new Episode(epNum, ($('.film-name > a').first().text()), links, thumbnail, epTitle);
  629. };
  630. }
  631. },
  632. {
  633. name: 'AniNow',
  634. url: ['aninow.tv/'],
  635. extractEpisodes: async function* (status) {
  636. for (let i = 0, l = await applyEpisodeRangeFilter([...document.querySelectorAll('a.episode-item')]); i < l.length; i+=6)
  637. yield* yieldEpisodesFromPromises(l.slice(i, i + 6).map(async a => {
  638. status.textContent = `Extracting Episode ${a.innerText.padStart(3, '0')}...`;
  639. const data = await fetchPage(a.href).then(p => JSON.parse(p.querySelector("#media-sources-data").dataset.mediaSources)).then(d => d.filter(l => !!l.url));
  640. const links = data.reduce((acc, m) => (acc[`${m.providerdisplayname}-${m.language}-${m.quality}`] = {
  641. stream: m.url.startsWith('videos/') ? `/storage/C:/Users/GraceAshby/OneDrive/aninow/media/${m.url}` : m.url,
  642. type: m.url.endsWith('mp4') ? 'mp4' : 'm3u8',
  643. tracks: m.subtitles.map(s => ({ file: s.filename, label: s.displayname, kind: 'caption' }))
  644. }, acc), {});
  645. return new Episode(a.innerText, document.querySelector('h1').innerText, links, document.querySelector('a>img').src);
  646. }));
  647. }
  648. },
  649.  
  650. // AnimeKai is not fully implemented yet... its a work in progress...
  651. {
  652. name: 'AnimeKai',
  653. url: ['animekai.to/watch/'],
  654. animeTitle: '.title',
  655. thumbnail: 'img',
  656. addStartButton: function () {
  657. const button = Object.assign(document.createElement('button'), {
  658. id: "AniLINK_startBtn",
  659. className: "btn btn-primary", // Use existing site styles
  660. textContent: "Generate Download Links",
  661. style: "margin-left: 10px;"
  662. });
  663. // Add button next to the episode list controls or similar area
  664. const target = document.querySelector('.episode-section');
  665. if (target) {
  666. target.appendChild(button);
  667. } else {
  668. // Fallback location if the primary target isn't found
  669. document.querySelector('.eplist-nav')?.appendChild(button);
  670. }
  671. return button;
  672. },
  673. // --- Helper functions adapted from provided code ---
  674. _reverseIt: (n) => n.split('').reverse().join(''),
  675. _base64UrlEncode: (str) => btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
  676. _base64UrlDecode: (n) => { n = n.padEnd(n.length + ((4 - (n.length % 4)) % 4), '=').replace(/-/g, '+').replace(/_/g, '/'); return atob(n); },
  677. _substitute: (input, keys, values) => { const map = Object.fromEntries(keys.split('').map((key, i) => [key, values[i] || ''])); return input.split('').map(char => map[char] || char).join(''); },
  678. _transform: (n, t) => { const v = Array.from({ length: 256 }, (_, i) => i); let c = 0, f = ''; for (let w = 0; w < 256; w++) { c = (c + v[w] + n.charCodeAt(w % n.length)) % 256;[v[w], v[c]] = [v[c], v[w]]; } for (let a = (c = 0), w = 0; a < t.length; a++) { w = (w + 1) % 256; c = (c + v[w]) % 256;[v[w], v[c]] = [v[c], v[w]]; f += String.fromCharCode(t.charCodeAt(a) ^ v[(v[w] + v[c]) % 256]); } return f; },
  679. _GenerateToken: function (n) { n = encodeURIComponent(n); return this._base64UrlEncode(this._substitute(this._base64UrlEncode(this._transform('sXmH96C4vhRrgi8', this._reverseIt(this._reverseIt(this._base64UrlEncode(this._transform('kOCJnByYmfI', this._substitute(this._substitute(this._reverseIt(this._base64UrlEncode(this._transform('0DU8ksIVlFcia2', n))), '1wctXeHqb2', '1tecHq2Xbw'), '48KbrZx1ml', 'Km8Zb4lxr1'))))))), 'hTn79AMjduR5', 'djn5uT7AMR9h')); },
  680. _DecodeIframeData: function (n) { n = `${n}`; n = this._transform('0DU8ksIVlFcia2', this._base64UrlDecode(this._reverseIt(this._substitute(this._substitute(this._transform('kOCJnByYmfI', this._base64UrlDecode(this._reverseIt(this._reverseIt(this._transform('sXmH96C4vhRrgi8', this._base64UrlDecode(this._substitute(this._base64UrlDecode(n), 'djn5uT7AMR9h', 'hTn79AMjduR5'))))))), 'Km8Zb4lxr1', '48KbrZx1ml'), '1tecHq2Xbw', '1wctXeHqb2')))); return decodeURIComponent(n); },
  681. _Decode: function (n) { n = this._substitute(this._reverseIt(this._transform('3U8XtHJfgam02k', this._base64UrlDecode(this._transform('PgiY5eIZWn', this._base64UrlDecode(this._substitute(this._reverseIt(this._substitute(this._transform('QKbVomcBHysCW9', this._base64UrlDecode(this._reverseIt(this._base64UrlDecode(n)))), '0GsO8otUi21aY', 'Go1UiY82st0Oa')), 'rXjnhU3SsbEd', 'rXEsS3nbjhUd')))))), '7DtY4mHcMA2yIL', 'IM7Am4D2yYHctL'); return decodeURIComponent(n); },
  682. // --- Main extraction logic ---
  683. extractEpisodes: async function* (status) {
  684. status.textContent = 'Starting AnimeKai extraction...';
  685. const animeTitle = document.querySelector(this.animeTitle)?.textContent || 'Unknown Anime';
  686. const thumbnail = document.querySelector(this.thumbnail)?.src || '';
  687. const ani_id = document.querySelector('.rate-box#anime-rating')?.getAttribute('data-id');
  688.  
  689. if (!ani_id) {
  690. showToast("Could not find anime ID.");
  691. return;
  692. }
  693.  
  694. const headers = {
  695. 'X-Requested-With': 'XMLHttpRequest',
  696. 'Referer': window.location.href,
  697. 'Accept': 'application/json, text/javascript, */*; q=0.01', // Ensure correct accept header
  698. };
  699.  
  700. try {
  701. status.textContent = 'Fetching episode list...';
  702. const episodeListUrl = `${location.origin}/ajax/episodes/list?ani_id=${ani_id}&_=${this._GenerateToken(ani_id)}`;
  703. console.log(`Fetching episode list from: ${episodeListUrl}`);
  704. const epListResponse = await fetch(episodeListUrl, { headers });
  705. if (!epListResponse.ok) throw new Error(`Failed to fetch episode list: ${epListResponse.status}`);
  706. const epListJson = await epListResponse.json();
  707. console.log(`Episode list response:`, epListJson);
  708. const epListDoc = (new DOMParser()).parseFromString(epListJson.result, 'text/html');
  709. const episodeElements = Array.from(epListDoc.querySelectorAll('div.eplist > ul > li > a'));
  710.  
  711. const throttleLimit = 5; // Limit concurrent requests to avoid rate limiting
  712.  
  713. for (let i = 0; i < episodeElements.length; i += throttleLimit) {
  714. const chunk = episodeElements.slice(i, i + throttleLimit);
  715. const episodePromises = chunk.map(async epElement => {
  716. const epNumber = epElement.getAttribute('num');
  717. const epToken = epElement.getAttribute('token');
  718. const epTitleText = epElement.querySelector('span')?.textContent || `Episode ${epNumber}`;
  719.  
  720. if (!epNumber || !epToken) {
  721. showToast(`Skipping episode: Missing number or token.`);
  722. return null;
  723. }
  724.  
  725. try {
  726. status.textContent = `Fetching servers for Ep ${epNumber}...`;
  727. const serversUrl = `${location.origin}/ajax/links/list?token=${epToken}&_=${this._GenerateToken(epToken)}`;
  728. const serversResponse = await fetch(serversUrl, { headers });
  729. if (!serversResponse.ok) throw new Error(`Failed to fetch servers for Ep ${epNumber}: ${serversResponse.status}`);
  730. const serversJson = await serversResponse.json();
  731. const serversDoc = (new DOMParser()).parseFromString(serversJson.result, 'text/html');
  732. console.log(JSON.stringify(serversDoc));
  733.  
  734. const serverElements = serversDoc.querySelectorAll('.server-items .server');
  735.  
  736. console.log(JSON.stringify(serverElements));
  737. if (serverElements.length === 0) {
  738. showToast(`No servers found for Ep ${epNumber}.`);
  739. return null;
  740. }
  741.  
  742. status.textContent = `Processing ${serverElements.length} servers for Ep ${epNumber}...`;
  743.  
  744. for (const serverElement of serverElements) {
  745. const serverId = serverElement.getAttribute('data-lid');
  746. const serverName = serverElement.textContent || `Server_${serverId?.slice(0, 4)}`; // Fallback name
  747.  
  748. if (!serverId) {
  749. console.warn(`Skipping server: Missing ID.`);
  750. continue;
  751. }
  752.  
  753. try {
  754. // Fetch view link
  755. status.textContent = `Fetching video link for Ep ${epNumber}...`;
  756. const viewUrl = `${location.origin}/ajax/links/view?id=${serverId}&_=${this._GenerateToken(serverId)}`;
  757. const viewResponse = await fetch(viewUrl, { headers });
  758. if (!viewResponse.ok) throw new Error(`Failed to fetch view link for Ep ${epNumber}: ${viewResponse.status}`);
  759. const viewJson = await viewResponse.json();
  760. console.log(`View link response:`, viewJson);
  761.  
  762.  
  763. const decodedIframeData = JSON.parse(this._DecodeIframeData(viewJson.result));
  764. console.log(`Decoded iframe data:`, decodedIframeData);
  765.  
  766. const megaUpEmbedUrl = decodedIframeData.url;
  767.  
  768. if (!megaUpEmbedUrl) {
  769. showToast(`Could not decode embed URL for Ep ${epNumber}.`);
  770. return null;
  771. }
  772.  
  773. // Fetch MegaUp media page to get encrypted sources
  774. const mediaUrl = megaUpEmbedUrl.replace(/\/(e|e2)\//, '/media/');
  775. status.textContent = `Fetching media data for Ep ${epNumber}...`;
  776. const mediaResponse = await GM_fetch(mediaUrl, { headers: { 'Referer': location.origin } });
  777. if (!mediaResponse.ok) throw new Error(`Failed to fetch media data for Ep ${epNumber}: ${mediaResponse.status}`);
  778. const mediaJson = await mediaResponse.json();
  779. console.log(`Media data response:`, mediaJson);
  780.  
  781.  
  782. if (!mediaJson.result) {
  783. showToast(`No result found in media data for Ep ${epNumber}.`);
  784. return null;
  785. }
  786.  
  787. status.textContent = `Decoding sources for Ep ${epNumber}...`;
  788. const decryptedSources = JSON.parse(this._Decode(mediaJson.result).replace(/\\/g, ''));
  789.  
  790. const links = {};
  791. decryptedSources.sources.forEach(source => {
  792. // Try to determine quality from URL or label if available
  793. const qualityMatch = source.file.match(/(\d{3,4})[pP]/);
  794. const quality = qualityMatch ? qualityMatch[1] + 'p' : 'Default';
  795. links[quality] = { stream: source.file, type: 'm3u8' };
  796. });
  797.  
  798. status.textContent = `Extracted Ep ${epNumber}`;
  799. return new Episode(epNumber, animeTitle, links, thumbnail);
  800.  
  801. } catch (epError) {
  802. showToast(`Error processing Ep ${epNumber}: ${epError.message}`);
  803. console.error(`Error processing Ep ${epNumber}:`, epError);
  804. return null;
  805. }
  806.  
  807. }
  808. } catch (serverError) {
  809. showToast(`Error fetching servers for Ep ${epNumber}: ${serverError.message}`);
  810. console.error(`Error fetching servers for Ep ${epNumber}:`, serverError);
  811. return null;
  812. }
  813. });
  814.  
  815. yield* yieldEpisodesFromPromises(episodePromises);
  816. }
  817. } catch (error) {
  818. showToast(`Failed AnimeKai extraction: ${error.message}`);
  819. console.error("AnimeKai extraction error:", error);
  820. status.textContent = `Error: ${error.message}`;
  821. }
  822. }
  823. }
  824. ];
  825.  
  826. /**
  827. * Fetches the HTML content of a given URL and parses it into a DOM object.
  828. *
  829. * @param {string} url - The URL of the page to fetch.
  830. * @returns {Promise<Document>} A promise that resolves to a DOM Document object.
  831. * @throws {Error} If the fetch operation fails.
  832. */
  833. async function fetchPage(url) {
  834. const response = await fetch(url);
  835. if (response.ok) {
  836. const page = (new DOMParser()).parseFromString(await response.text(), 'text/html');
  837. return page;
  838. } else {
  839. showToast(`Failed to fetch HTML for ${url} : ${response.status}`);
  840. throw new Error(`Failed to fetch HTML for ${url} : ${response.status}`);
  841. }
  842. }
  843.  
  844. /**
  845. * Fetches a URL with retry logic for handling rate limits or temporary errors.
  846. *
  847. * @returns {Promise<Response>} A promise that resolves to the response object.
  848. */
  849. async function fetchWithRetry(url, options = {}, retries = 3, sleep = 1000) {
  850. const response = await fetch(url, options);
  851. if (!response.ok) {
  852. if (response.status === 503 && retries > 0) { // 503 is a common status when rate limited
  853. console.log(`Retrying ${url}, ${retries} retries remaining`);
  854. await new Promise(resolve => setTimeout(resolve, sleep)); // Wait 1 second before retrying
  855. return fetchWithRetry(url, options, retries - 1, sleep); // Pass options and sleep to the next call
  856. }
  857. throw new Error(`${response.status} - ${response.statusText}`);
  858. }
  859. return response;
  860. }
  861.  
  862. /**
  863. * Asynchronously processes an array of episode promises and yields each resolved episode.
  864. *
  865. * @param {Array<Promise>} episodePromises - An array of promises, each resolving to an episode.
  866. * @returns {AsyncGenerator} An async generator yielding each resolved episode.
  867. */
  868. async function* yieldEpisodesFromPromises(episodePromises) {
  869. for (const episodePromise of episodePromises) {
  870. const episode = await episodePromise;
  871. if (episode) {
  872. yield episode;
  873. }
  874. }
  875. }
  876.  
  877. /**
  878. * encodes a string to base64url format thats safe for URLs
  879. */
  880. const safeBtoa = str => btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  881.  
  882. /**
  883. * Analyzes the given media url to return duration, size, and resolution of the media.
  884. * @param {string} mediaUrl - The URL of the media to analyze.
  885. * @return {Promise<{duration: string, size: string, resolution: string}>} A promise that resolves to an object
  886. * containing duration (in hh:mm:ss), size of the media (in MB), and resolution (e.g., 1920x1080).
  887. * @TODO: Not Yet Implemented
  888. */
  889. async function analyzeMedia(mediaUrl) {
  890. if (_analyzedMediaCache.has(mediaUrl)) return _analyzedMediaCache.get(mediaUrl);
  891.  
  892. let metadata = { duration: 'N/A', resolution: 'N/A', size: 'N/A' };
  893. try {
  894. if (mediaUrl.endsWith('.mp4')) {
  895. const r = await GM_fetch(mediaUrl, { method: 'HEAD' });
  896. if (r.ok) {
  897. const sz = parseFloat(r.headers.get('Content-Length')) || 0;
  898. metadata.size = `${(sz / 1048576).toFixed(2)} MB`;
  899. }
  900. } else if (mediaUrl.endsWith('.m3u8')) {
  901. const r = await GM_fetch(mediaUrl);
  902. if (r.ok) {
  903. const t = await r.text();
  904. const res = t.match(/RESOLUTION=(\d+x\d+)/i);
  905. if (res) metadata.resolution = res[1];
  906. let d = 0;
  907. for (const m of t.matchAll(/#EXTINF:([\d.]+)/g)) d += parseFloat(m[1]);
  908. if (d > 0) {
  909. const h = Math.floor(d / 3600), m = Math.floor((d % 3600) / 60), s = Math.floor(d % 60);
  910. metadata.duration = [h, m, s].map(v => String(v).padStart(2, '0')).join(':');
  911. }
  912. }
  913. }
  914. if (metadata.duration === 'N/A' || metadata.resolution === 'N/A') {
  915. await new Promise(res => {
  916. const v = document.createElement('video');
  917. v.src = mediaUrl; v.preload = 'metadata'; v.muted = true;
  918. v.onloadedmetadata = () => {
  919. if (v.duration && metadata.duration === 'N/A') {
  920. const h = Math.floor(v.duration / 3600), m = Math.floor((v.duration % 3600) / 60), s = Math.floor(v.duration % 60);
  921. metadata.duration = [h, m, s].map(x => String(x).padStart(2, '0')).join(':');
  922. }
  923. if (v.videoWidth && v.videoHeight && metadata.resolution === 'N/A')
  924. metadata.resolution = `${v.videoWidth}x${v.videoHeight}`;
  925. res();
  926. };
  927. v.onerror = () => res();
  928. setTimeout(res, 2000);
  929. });
  930. }
  931. } catch (e) { }
  932. _analyzedMediaCache.set(mediaUrl, metadata);
  933. return metadata;
  934. }
  935. const _analyzedMediaCache = new Map(); // Cache to store analyzed media results for the above function
  936.  
  937.  
  938. // initialize
  939. console.log('Initializing AniLINK...');
  940. const site = websites.find(site => site.url.some(url => window.location.href.includes(url)));
  941.  
  942. // register menu command to start script
  943. GM_registerMenuCommand('Extract Episodes', extractEpisodes);
  944.  
  945. // attach start button to page
  946. try {
  947. const startBtnId = "AniLINK_startBtn";
  948. (site.addStartButton(startBtnId) || document.getElementById(startBtnId))?.addEventListener('click', extractEpisodes);
  949. } catch (e) {
  950. console.warn('Could not add start button to site. This might be due to the function not being implemented for this site.');
  951. }
  952.  
  953. // append site specific css styles
  954. document.body.style.cssText += (site.styles || '');
  955.  
  956. /***************************************************************
  957. * This function creates an overlay on the page and displays a list of episodes extracted from a website
  958. * The function is triggered by a user command registered with `GM_registerMenuCommand`.
  959. * The episode list is generated by calling the `extractEpisodes` method of a website object that matches the current URL.
  960. ***************************************************************/
  961. async function extractEpisodes() {
  962. // Restore last overlay if it exists
  963. if (document.getElementById("AniLINK_Overlay")) {
  964. document.getElementById("AniLINK_Overlay").style.display = "flex";
  965. return;
  966. }
  967. // Flag to control extraction process
  968. let isExtracting = true;
  969.  
  970. // --- Materialize CSS Initialization ---
  971. GM_addStyle(`
  972. @import url('https://fonts.googleapis.com/icon?family=Material+Icons');
  973.  
  974. #AniLINK_Overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.8); z-index: 1000; display: flex; align-items: center; justify-content: center; }
  975. #AniLINK_LinksContainer { width: 80%; max-height: 85%; background-color: #222; color: #eee; padding: 20px; border-radius: 8px; overflow-y: auto; display: flex; flex-direction: column;} /* Flex container for status and qualities */
  976. .anlink-status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } /* Header for status bar and stop button */
  977. .anlink-status-bar { color: #eee; flex-grow: 1; margin-right: 10px; display: block; } /* Status bar takes space */
  978. .anlink-status-icon { background: transparent; border: none; color: #eee; cursor: pointer; padding-right: 10px; } /* status icon style */
  979. .anlink-status-icon i { font-size: 24px; transition: transform 0.3s ease-in-out; } /* Icon size and transition */
  980. .anlink-status-icon i::before { content: 'check_circle'; } /* Show check icon when not extracting */
  981. .anlink-status-icon i.extracting::before { content: 'auto_mode'; animation: spinning 2s linear infinite; } /* Spinner animation class */
  982. .anlink-status-icon:hover i.extracting::before { content: 'stop_circle'; animation: stop; } /* Show stop icon on hover when extracting */
  983. .anlink-quality-section { margin-top: 20px; margin-bottom: 10px; border-bottom: 1px solid #444; padding-bottom: 5px; }
  984. .anlink-quality-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; } /* Added cursor pointer */
  985. .anlink-quality-header > span { color: #26a69a; font-size: 1.5em; display: flex; align-items: center; flex-grow: 1; } /* Flex and align items for icon and text */
  986. .anlink-quality-header i { margin-right: 8px; transition: transform 0.3s ease-in-out; } /* Transition for icon rotation */
  987. .anlink-quality-header i.rotate { transform: rotate(90deg); } /* Rotate class */
  988. .anlink-episode-list { list-style: none; padding-left: 0; margin-top: 0; overflow: hidden; transition: max-height 0.5s ease-in-out; } /* Transition for max-height */
  989. .anlink-episode-item { margin-bottom: 5px; padding: 8px; border-bottom: 1px solid #333; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Single line and ellipsis for item */
  990. .anlink-episode-item:last-child { border-bottom: none; }
  991. .anlink-episode-item > label > span { user-select: none; cursor: pointer; color: #26a69a; } /* Disable selecting the 'Ep: 1' prefix */
  992. .anlink-episode-item > label > span > img { display: inline; } /* Ensure the mpv icon is in the same line */
  993. .anlink-episode-checkbox { appearance: none; width: 20px; height: 20px; margin-right: 10px; margin-bottom: -5px; border: 1px solid #26a69a; border-radius: 4px; outline: none; cursor: pointer; transition: background-color 0.3s, border-color 0.3s; }
  994. .anlink-episode-checkbox:checked { background-color: #26a69a; border-color: #26a69a; }
  995. .anlink-episode-checkbox:checked::after { content: '✔'; display: block; color: white; font-size: 14px; text-align: center; line-height: 20px; animation: checkTilt 0.3s; }
  996. .anlink-episode-link { color: #ffca28; text-decoration: none; word-break: break-all; overflow: hidden; text-overflow: ellipsis; display: inline; } /* Single line & Ellipsis for long links */
  997. .anlink-episode-link:hover { color: #fff; }
  998. .anlink-header-buttons { display: flex; gap: 10px; }
  999. .anlink-header-buttons button { background-color: #26a69a; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; }
  1000. .anlink-header-buttons button:hover { background-color: #2bbbad; }
  1001.  
  1002. @keyframes spinning { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Spinning animation */
  1003. @keyframes checkTilt { from { transform: rotate(-20deg); } to { transform: rotate(0deg); } } /* Checkmark tilt animation */
  1004. `);
  1005.  
  1006. // Create an overlay to cover the page
  1007. const overlayDiv = document.createElement("div");
  1008. overlayDiv.id = "AniLINK_Overlay";
  1009. document.body.appendChild(overlayDiv);
  1010. overlayDiv.onclick = e => !linksContainer.contains(e.target) &&
  1011. (document.querySelector('.anlink-status-bar')?.textContent.startsWith("Cancelled")
  1012. ? overlayDiv.remove()
  1013. : overlayDiv.style.display = "none");
  1014.  
  1015. // Create a container for links
  1016. const linksContainer = document.createElement('div');
  1017. linksContainer.id = "AniLINK_LinksContainer";
  1018. overlayDiv.appendChild(linksContainer);
  1019.  
  1020. // Status bar header - container for status bar and status icon
  1021. const statusBarHeader = document.createElement('div');
  1022. statusBarHeader.className = 'anlink-status-header';
  1023. linksContainer.appendChild(statusBarHeader);
  1024.  
  1025. // Create dynamic status icon
  1026. const statusIconElement = document.createElement('a');
  1027. statusIconElement.className = 'anlink-status-icon';
  1028. statusIconElement.innerHTML = '<i class="material-icons extracting"></i>';
  1029. statusIconElement.title = 'Stop Extracting';
  1030. statusBarHeader.appendChild(statusIconElement);
  1031.  
  1032. statusIconElement.addEventListener('click', () => {
  1033. isExtracting = false; // Set flag to stop extraction
  1034. statusBar.textContent = "Extraction Stopped.";
  1035. });
  1036.  
  1037. // Create a status bar
  1038. const statusBar = document.createElement('span');
  1039. statusBar.className = "anlink-status-bar";
  1040. statusBar.textContent = "Extracting Links..."
  1041. statusBarHeader.appendChild(statusBar);
  1042.  
  1043. // Create a container for qualities and episodes
  1044. const qualitiesContainer = document.createElement('div');
  1045. qualitiesContainer.id = "AniLINK_QualitiesContainer";
  1046. linksContainer.appendChild(qualitiesContainer);
  1047.  
  1048.  
  1049. // --- Process Episodes using Generator ---
  1050. statusBar.textContent = "Starting...";
  1051. const episodeGenerator = site.extractEpisodes(statusBar);
  1052. const qualityLinkLists = {}; // Stores lists of links for each quality
  1053.  
  1054. for await (const episode of episodeGenerator) {
  1055. if (!isExtracting) { // Check if extraction is stopped
  1056. statusIconElement.querySelector('i').classList.remove('extracting'); // Stop spinner animation
  1057. statusBar.textContent = "Extraction Stopped By User.";
  1058. return; // Exit if extraction is stopped
  1059. }
  1060. if (!episode) continue; // Skip if episode is null (error during extraction)
  1061.  
  1062. // Get all links into format - {[qual1]:[ep1,2,3,4], [qual2]:[ep1,2,3,4], ...}
  1063. for (const quality in episode.links) {
  1064. qualityLinkLists[quality] = qualityLinkLists[quality] || [];
  1065. qualityLinkLists[quality].push(episode);
  1066. }
  1067.  
  1068. // Update UI in real-time - RENDER UI HERE BASED ON qualityLinkLists
  1069. renderQualityLinkLists(qualityLinkLists, qualitiesContainer);
  1070. }
  1071. isExtracting = false; // Extraction completed
  1072. statusIconElement.querySelector('i').classList.remove('extracting');
  1073. statusBar.textContent = "Extraction Complete!";
  1074.  
  1075.  
  1076. // Renders quality link lists inside a given container element
  1077. function renderQualityLinkLists(sortedLinks, container) {
  1078. // Track expanded state for each quality section
  1079. const expandedState = {};
  1080. container.querySelectorAll('.anlink-quality-section').forEach(section => {
  1081. const quality = section.dataset.quality;
  1082. const episodeList = section.querySelector('.anlink-episode-list');
  1083. expandedState[quality] = episodeList && episodeList.style.maxHeight !== '0px';
  1084. });
  1085.  
  1086. for (const quality in sortedLinks) {
  1087. let qualitySection = container.querySelector(`.anlink-quality-section[data-quality="${quality}"]`);
  1088. let episodeListElem;
  1089.  
  1090. const episodes = sortedLinks[quality].sort((a, b) => a.number - b.number);
  1091.  
  1092. if (!qualitySection) {
  1093. // Create new section if it doesn't exist
  1094. qualitySection = document.createElement('div');
  1095. qualitySection.className = 'anlink-quality-section';
  1096. qualitySection.dataset.quality = quality;
  1097.  
  1098. const headerDiv = document.createElement('div'); // Header div for quality-string and buttons - ROW
  1099. headerDiv.className = 'anlink-quality-header';
  1100.  
  1101. // Create a span for the clickable header text and icon
  1102. const qualitySpan = document.createElement('span');
  1103. qualitySpan.innerHTML = `<i style="opacity: 0.5">(${sortedLinks[quality].length})</i> <i class="material-icons">chevron_right</i> ${quality}`;
  1104. qualitySpan.addEventListener('click', toggleQualitySection);
  1105. headerDiv.appendChild(qualitySpan);
  1106.  
  1107.  
  1108. // --- Create Speed Dial Button in the Quality Section ---
  1109. const headerButtons = document.createElement('div');
  1110. headerButtons.className = 'anlink-header-buttons';
  1111. headerButtons.innerHTML = `
  1112. <button type="button" class="anlink-select-links">Select</button>
  1113. <button type="button" class="anlink-copy-links">Copy</button>
  1114. <button type="button" class="anlink-export-links">Export</button>
  1115. <button type="button" class="anlink-play-links">Play with MPV</button>
  1116. `;
  1117. headerDiv.appendChild(headerButtons);
  1118. qualitySection.appendChild(headerDiv);
  1119.  
  1120. // --- Add Empty episodes list elm to the quality section ---
  1121. episodeListElem = document.createElement('ul');
  1122. episodeListElem.className = 'anlink-episode-list';
  1123. episodeListElem.style.maxHeight = '0px';
  1124. qualitySection.appendChild(episodeListElem);
  1125.  
  1126. container.appendChild(qualitySection);
  1127.  
  1128. // Attach handlers
  1129. attachBtnClickListeners(episodes, qualitySection);
  1130. } else {
  1131. // Update header count
  1132. const qualitySpan = qualitySection.querySelector('.anlink-quality-header > span');
  1133. if (qualitySpan) {
  1134. qualitySpan.innerHTML = `<i style="opacity: 0.5">(${sortedLinks[quality].length})</i> <i class="material-icons">chevron_right</i> ${quality}`;
  1135. }
  1136. episodeListElem = qualitySection.querySelector('.anlink-episode-list');
  1137. }
  1138.  
  1139. // Update episode list items
  1140. episodeListElem.innerHTML = '';
  1141. episodes.forEach(ep => {
  1142. const listItem = document.createElement('li');
  1143. listItem.className = 'anlink-episode-item';
  1144. listItem.innerHTML = `
  1145. <label>
  1146. <input type="checkbox" class="anlink-episode-checkbox" />
  1147. <span id="mpv-epnum" title="Play in MPV">Ep ${ep.number.replace(/^0+/, '')}: </span>
  1148. <a href="${ep.links[quality].stream}" class="anlink-episode-link" download="${encodeURI(ep.filename)}" data-epnum="${ep.number}" data-ep=${encodeURI(JSON.stringify({ ...ep, links: undefined }))} >${ep.links[quality].stream}</a>
  1149. </label>
  1150. `;
  1151. const episodeLinkElement = listItem.querySelector('.anlink-episode-link');
  1152. const epnumSpan = listItem.querySelector('#mpv-epnum');
  1153. const link = episodeLinkElement.href;
  1154. const name = decodeURIComponent(episodeLinkElement.download);
  1155.  
  1156. // On hover, show MPV icon & file name
  1157. listItem.addEventListener('mouseenter', () => {
  1158. window.getSelection().isCollapsed && (episodeLinkElement.textContent = name);
  1159. epnumSpan.innerHTML = `<img width="20" height="20" fill="#26a69a" style="vertical-align:middle;" src="https://a.fsdn.com/allura/p/mpv-player-windows/icon?1517058933"> ${ep.number.replace(/^0+/, '')}: `;
  1160. });
  1161. listItem.addEventListener('mouseleave', () => {
  1162. episodeLinkElement.textContent = decodeURIComponent(link);
  1163. epnumSpan.textContent = `Ep ${ep.number.replace(/^0+/, '')}: `;
  1164. });
  1165. epnumSpan.addEventListener('click', e => {
  1166. e.preventDefault();
  1167. location.replace('mpv://play/' + safeBtoa(link) + `/?v_title=${safeBtoa(name)}` + `&cookies=${location.hostname}.txt`);
  1168. showToast('Sent to MPV. If nothing happened, install <a href="https://github.com/akiirui/mpv-handler" target="_blank" style="color:#1976d2;">mpv-handler</a>.');
  1169. });
  1170.  
  1171. episodeListElem.appendChild(listItem);
  1172. });
  1173.  
  1174. // Restore expand state only if section was previously expanded
  1175. if (expandedState[quality]) {
  1176. const icon = qualitySection.querySelector('.material-icons');
  1177. episodeListElem.style.maxHeight = `${episodeListElem.scrollHeight}px`;
  1178. icon.classList.add('rotate');
  1179. }
  1180. }
  1181. }
  1182.  
  1183. function toggleQualitySection(event) {
  1184. // Target the closest anlink-quality-header span to ensure only clicks on the text/icon trigger toggle
  1185. const qualitySpan = event.currentTarget;
  1186. const headerDiv = qualitySpan.parentElement;
  1187. const qualitySection = headerDiv.closest('.anlink-quality-section');
  1188. const episodeList = qualitySection.querySelector('.anlink-episode-list');
  1189. const icon = qualitySpan.querySelector('.material-icons'); // Query icon within the span
  1190. const isCollapsed = episodeList.style.maxHeight === '0px';
  1191.  
  1192. if (isCollapsed) {
  1193. episodeList.style.maxHeight = `${episodeList.scrollHeight}px`; // Expand to content height
  1194. icon.classList.add('rotate'); // Rotate icon on expand
  1195. } else {
  1196. episodeList.style.maxHeight = '0px'; // Collapse
  1197. icon.classList.remove('rotate'); // Reset icon rotation
  1198. }
  1199. }
  1200.  
  1201. // Attach click listeners to the speed dial buttons for each quality section
  1202. function attachBtnClickListeners(episodeList, qualitySection) {
  1203. const buttonActions = [
  1204. { selector: '.anlink-select-links', handler: onSelectBtnPressed },
  1205. { selector: '.anlink-copy-links', handler: onCopyBtnClicked },
  1206. { selector: '.anlink-export-links', handler: onExportBtnClicked },
  1207. { selector: '.anlink-play-links', handler: onPlayBtnClicked }
  1208. ];
  1209.  
  1210. buttonActions.forEach(({ selector, handler }) => {
  1211. const button = qualitySection.querySelector(selector);
  1212. button.addEventListener('click', () => handler(button, episodeList, qualitySection));
  1213. });
  1214.  
  1215. // Helper function to get checked episode items within a quality section
  1216. function _getSelectedEpisodeItems(qualitySection) {
  1217. return Array.from(qualitySection.querySelectorAll('.anlink-episode-item input[type="checkbox"]:checked'))
  1218. .map(checkbox => checkbox.closest('.anlink-episode-item'));
  1219. }
  1220.  
  1221. // Helper function to prepare m3u8 playlist string from given episodes
  1222. function _preparePlaylist(episodes, quality) {
  1223. let playlistContent = '#EXTM3U\n';
  1224. episodes.forEach(episode => {
  1225. const linkObj = episode.links[quality];;
  1226. if (!linkObj) {
  1227. showToast(`No link found for source ${quality} in episode ${episode.number}`);
  1228. return;
  1229. }
  1230. // Add tracks if present (subtitles, audio, etc.)
  1231. if (linkObj.tracks && Array.isArray(linkObj.tracks) && linkObj.tracks.length > 0) {
  1232. linkObj.tracks.forEach(track => {
  1233. // EXT-X-MEDIA for subtitles or alternate audio
  1234. if (track.kind && track.kind.startsWith('audio')) {
  1235. playlistContent += `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio${episode.number}\",NAME=\"${track.label || 'Audio'}\",DEFAULT=${track.default ? 'YES' : 'NO'},URI=\"${track.file}\"\n`;
  1236. } else if (/^(caption|subtitle)s?/.test(track.kind)) {
  1237. playlistContent += `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs${episode.number}\",NAME=\"${track.label || 'Subtitle'}\",DEFAULT=${track.default ? 'YES' : 'NO'},URI=\"${track.file}\"\n`;
  1238. }
  1239. });
  1240. }
  1241. playlistContent += `#EXTINF:-1,${episode.filename}\n`;
  1242. playlistContent += `${linkObj.stream}\n`;
  1243. });
  1244. return playlistContent;
  1245. }
  1246.  
  1247. // Select Button click event handler
  1248. function onSelectBtnPressed(button, episodes, qualitySection) {
  1249. const episodeItems = qualitySection.querySelector('.anlink-episode-list').querySelectorAll('.anlink-episode-item');
  1250. const checkboxes = Array.from(qualitySection.querySelectorAll('.anlink-episode-item input[type="checkbox"]'));
  1251. const allChecked = checkboxes.every(cb => cb.checked);
  1252. const anyUnchecked = checkboxes.some(cb => !cb.checked);
  1253.  
  1254. if (anyUnchecked || allChecked === false) { // If any unchecked OR not all are checked (for the first click when none are checked)
  1255. checkboxes.forEach(checkbox => { checkbox.checked = true; }); // Check all
  1256. // Select all link texts
  1257. const range = new Range();
  1258. range.selectNodeContents(episodeItems[0]);
  1259. range.setEndAfter(episodeItems[episodeItems.length - 1]);
  1260. window.getSelection().removeAllRanges();
  1261. window.getSelection().addRange(range);
  1262. button.textContent = 'Deselect All'; // Change button text to indicate deselect
  1263. } else { // If all are already checked
  1264. checkboxes.forEach(checkbox => { checkbox.checked = false; }); // Uncheck all
  1265. window.getSelection().removeAllRanges(); // Clear selection
  1266. button.textContent = 'Select All'; // Revert button text
  1267. }
  1268. setTimeout(() => { button.textContent = checkboxes.some(cb => !cb.checked) ? 'Select All' : 'Deselect All'; }, 1500); // slight delay revert text
  1269. }
  1270.  
  1271. // copySelectedLinks click event handler
  1272. function onCopyBtnClicked(button, episodes, qualitySection) {
  1273. const selectedItems = _getSelectedEpisodeItems(qualitySection);
  1274. const linksToCopy = selectedItems.length ? selectedItems.map(item => item.querySelector('.anlink-episode-link').href) : Array.from(qualitySection.querySelectorAll('.anlink-episode-item')).map(item => item.querySelector('.anlink-episode-link').href);
  1275.  
  1276. const string = linksToCopy.join('\n');
  1277. navigator.clipboard.writeText(string);
  1278. button.textContent = 'Copied Selected';
  1279. setTimeout(() => { button.textContent = 'Copy'; }, 1000);
  1280. }
  1281.  
  1282. // exportToPlaylist click event handler
  1283. function onExportBtnClicked(button, episodes, qualitySection) {
  1284. const quality = qualitySection.dataset.quality;
  1285. const selectedItems = _getSelectedEpisodeItems(qualitySection);
  1286.  
  1287. const items = selectedItems.length ? selectedItems : Array.from(qualitySection.querySelectorAll('.anlink-episode-item'));
  1288. const playlist = _preparePlaylist(episodes.filter(ep => items.find(i => i.querySelector(`[data-epnum="${ep.number}"]`))), quality);
  1289. const fileName = JSON.parse(decodeURI(items[0]?.querySelector('.anlink-episode-link')?.dataset.ep)).animeTitle + `${GM_getValue('include_source_in_filename', true) ? ` [${quality}]` : ''}.m3u8`;
  1290. const file = new Blob([playlist], { type: 'application/vnd.apple.mpegurl' });
  1291. const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(file), download: fileName });
  1292. a.click();
  1293.  
  1294. button.textContent = 'Exported Selected';
  1295. setTimeout(() => { button.textContent = 'Export'; }, 1000);
  1296. }
  1297.  
  1298. // Play click event handler
  1299. async function onPlayBtnClicked(button, episodes, qualitySection) {
  1300. const quality = qualitySection.dataset.quality;
  1301. const selectedEpisodeItems = _getSelectedEpisodeItems(qualitySection);
  1302. const items = selectedEpisodeItems.length ? selectedEpisodeItems : Array.from(qualitySection.querySelectorAll('.anlink-episode-item'));
  1303. const epList = episodes.filter(ep => items.find(i => i.querySelector(`[data-epnum="${ep.number}"]`))).filter(Boolean);
  1304.  
  1305. button.textContent = 'Processing...';
  1306. const playlistContent = _preparePlaylist(epList, quality);
  1307. const uploadUrl = await GM_fetch("https://paste.rs/", {
  1308. method: "POST",
  1309. body: playlistContent
  1310. }).then(r => r.text()).then(t => t + '.m3u8');
  1311. console.log(`Playlist URL:`, uploadUrl);
  1312.  
  1313. // Use mpv:// protocol to pass the paste.rs link to mpv (requires mpv-handler installed)
  1314. const mpvUrl = 'mpv://play/' + safeBtoa(uploadUrl.trim()) + '/?v_title=' + safeBtoa(epList[0].animeTitle);
  1315. location.replace(mpvUrl);
  1316.  
  1317. button.textContent = 'Sent to MPV';
  1318. setTimeout(() => { button.textContent = 'Play with MPV'; }, 2000);
  1319. setTimeout(() => {
  1320. showToast('If nothing happened, you need to install <a href="https://github.com/akiirui/mpv-handler" target="_blank" style="color:#1976d2;">mpv-handler</a> to enable this feature.');
  1321. }, 1000);
  1322. }
  1323. }
  1324. }
  1325.  
  1326. /***************************************************************
  1327. * Modern Episode Range Selector with Keyboard Navigation
  1328. ***************************************************************/
  1329. async function showEpisodeRangeSelector(total) {
  1330. return new Promise(resolve => {
  1331. const modal = Object.assign(document.createElement('div'), {
  1332. innerHTML: `
  1333. <div class="anlink-modal-backdrop">
  1334. <div class="anlink-modal">
  1335. <div class="anlink-modal-header">
  1336. <div class="anlink-modal-icon">📺</div>
  1337. <h2>Episode Range</h2>
  1338. <div class="anlink-episode-count">${total} episodes found</div>
  1339. <small style="display:block;color:#ccc;font-size:11px;margin-top:2px;">
  1340. Note: Range is by episode count, not episode number<br>(e.g., 1-6 means the first 6 episodes listed).
  1341. </small>
  1342. </div>
  1343. <div class="anlink-modal-body">
  1344. <div class="anlink-range-inputs">
  1345. <div class="anlink-input-group">
  1346. <label>From</label>
  1347. <input type="number" id="start" min="1" max="${total}" value="1" tabindex="1">
  1348. </div>
  1349. <div class="anlink-range-divider">—</div>
  1350. <div class="anlink-input-group">
  1351. <label>To</label>
  1352. <input type="number" id="end" min="1" max="${total}" value="${Math.min(24, total)}" tabindex="2">
  1353. </div>
  1354. </div>
  1355. <div class="anlink-quick-select">
  1356. <button class="anlink-quick-btn" data-range="1,24" tabindex="3">First 24</button>
  1357. <button class="anlink-quick-btn" data-range="${Math.max(1, total - 23)},${total}" tabindex="4">Last 24</button>
  1358. <button class="anlink-quick-btn" data-range="1,${total}" tabindex="5">All ${total}</button>
  1359. </div>
  1360. <div class="anlink-help-text">
  1361. Use <kbd>Tab</kbd> to navigate • <kbd>↑↓</kbd> to adjust values <kbd>Enter</kbd> to extract • <kbd>Esc</kbd> to cancel
  1362. </div>
  1363. </div>
  1364. <div class="anlink-modal-footer">
  1365. <button class="anlink-btn anlink-btn-cancel" data-key="Escape" tabindex="6"><kbd>Esc</kbd> Cancel</button>
  1366. <button class="anlink-btn anlink-btn-primary" data-key="Enter" tabindex="7"><kbd>Enter</kbd> Extract</button>
  1367. </div>
  1368. </div>
  1369. </div>
  1370. `,
  1371. style: 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:1001;'
  1372. });
  1373.  
  1374. // Enhanced styling with keyboard indicators
  1375. GM_addStyle(`
  1376. .anlink-modal-backdrop { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); }
  1377. .anlink-modal { background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); border-radius: 16px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); width: 420px; max-width: 90vw; color: #fff; overflow: hidden; }
  1378. .anlink-modal-header { text-align: center; padding: 24px 24px 16px; background: linear-gradient(135deg, #26a69a 0%, #20847a 100%); }
  1379. .anlink-modal-icon { font-size: 48px; margin-bottom: 8px; }
  1380. .anlink-modal h2 { margin: 0 0 8px; font-size: 24px; font-weight: 600; }
  1381. .anlink-episode-count { opacity: 0.9; font-size: 14px; }
  1382. .anlink-modal-body { padding: 24px; }
  1383. .anlink-range-inputs { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
  1384. .anlink-input-group { flex: 1; }
  1385. .anlink-input-group label { display: block; margin-bottom: 8px; font-size: 14px; color: #26a69a; font-weight: 500; }
  1386. .anlink-input-group input { width: 100%; padding: 12px; border: 2px solid #444; border-radius: 8px; background: #1a1a1a; color: #fff; font-size: 16px; text-align: center; transition: all 0.2s; }
  1387. .anlink-input-group input:focus { outline: none; border-color: #26a69a; box-shadow: 0 0 0 3px rgba(38,166,154,0.1); }
  1388. .anlink-range-divider { color: #26a69a; font-weight: bold; font-size: 18px; margin-top: 24px; }
  1389. .anlink-quick-select { display: flex; gap: 8px; margin-bottom: 16px; }
  1390. .anlink-quick-btn { flex: 1; padding: 8px 12px; border: 1px solid #444; border-radius: 6px; background: transparent; color: #ccc; cursor: pointer; font-size: 12px; transition: all 0.2s; position: relative; }
  1391. .anlink-quick-btn:hover, .anlink-quick-btn:focus { border-color: #26a69a; color: #26a69a; background: rgba(38,166,154,0.1); outline: none; } .anlink-help-text { font-size: 11px; color: #888; text-align: center; margin-top: 12px; }
  1392. .anlink-modal-footer { display: flex; gap: 12px; padding: 0 24px 24px; }
  1393. .anlink-btn { flex: 1; padding: 12px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; position: relative; }
  1394. .anlink-btn:focus { outline: 2px solid #26a69a; outline-offset: 2px; }
  1395. .anlink-btn-cancel { background: #444; color: #ccc; }
  1396. .anlink-btn-cancel:hover, .anlink-btn-cancel:focus { background: #555; }
  1397. .anlink-btn-primary { background: linear-gradient(135deg, #26a69a 0%, #20847a 100%); color: #fff; }
  1398. .anlink-btn-primary:hover, .anlink-btn-primary:focus { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(38,166,154,0.3); }
  1399. kbd { background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 3px; padding: 1px 4px; font-size: 10px; margin-right: 4px; }
  1400. `);
  1401.  
  1402. document.body.appendChild(modal);
  1403.  
  1404. const [startInput, endInput] = modal.querySelectorAll('input');
  1405. const buttons = modal.querySelectorAll('button');
  1406. const primaryBtn = modal.querySelector('.anlink-btn-primary');
  1407. const cancelBtn = modal.querySelector('.anlink-btn-cancel');
  1408.  
  1409. const validate = () => {
  1410. const s = Math.max(1, Math.min(total, +startInput.value));
  1411. const e = Math.max(s, Math.min(total, +endInput.value));
  1412. startInput.value = s; endInput.value = e;
  1413. };
  1414.  
  1415. const cleanup = () => modal.remove();
  1416. const accept = () => { validate(); cleanup(); resolve({ start: +startInput.value, end: +endInput.value }); };
  1417. const cancel = () => { cleanup(); resolve(null); };
  1418.  
  1419. // Keyboard navigation with arrow keys for number inputs
  1420. modal.addEventListener('keydown', e => {
  1421. switch (e.key) {
  1422. case 'Escape': e.preventDefault(); cancel(); break;
  1423. case 'Enter': e.preventDefault(); accept(); break;
  1424. case 'f': case 'F':
  1425. if (!e.target.matches('input') && !e.ctrlKey && !e.altKey) {
  1426. e.preventDefault();
  1427. startInput.focus();
  1428. startInput.select();
  1429. }
  1430. break;
  1431. }
  1432. });
  1433.  
  1434. // Input validation and arrow key navigation for number inputs
  1435. [startInput, endInput].forEach(input => {
  1436. input.addEventListener('input', validate);
  1437. input.addEventListener('keydown', e => {
  1438. if (e.key === 'ArrowUp') {
  1439. e.preventDefault();
  1440. input.value = Math.min(total, (+input.value || 0) + 1);
  1441. validate();
  1442. } else if (e.key === 'ArrowDown') {
  1443. e.preventDefault();
  1444. input.value = Math.max(1, (+input.value || 2) - 1);
  1445. validate();
  1446. } else if (e.key === 'Tab' && !e.shiftKey && input === endInput) {
  1447. e.preventDefault();
  1448. modal.querySelector('.anlink-quick-btn').focus();
  1449. }
  1450. });
  1451. });
  1452. // Quick select buttons
  1453. modal.querySelectorAll('.anlink-quick-btn').forEach((btn, index) => {
  1454. btn.addEventListener('click', () => {
  1455. const [s, e] = btn.dataset.range.split(',').map(Number);
  1456. startInput.value = s;
  1457. endInput.value = e;
  1458. validate();
  1459. // Focus extract button after quick select
  1460. setTimeout(() => primaryBtn.focus(), 100);
  1461. });
  1462.  
  1463. // Arrow key navigation between quick select buttons
  1464. btn.addEventListener('keydown', e => {
  1465. if (e.key === 'ArrowLeft' && index > 0) {
  1466. e.preventDefault();
  1467. modal.querySelectorAll('.anlink-quick-btn')[index - 1].focus();
  1468. } else if (e.key === 'ArrowRight' && index < 2) {
  1469. e.preventDefault();
  1470. modal.querySelectorAll('.anlink-quick-btn')[index + 1].focus();
  1471. } else if (e.key === 'Tab' && !e.shiftKey && index === 2) {
  1472. e.preventDefault();
  1473. cancelBtn.focus();
  1474. }
  1475. });
  1476. });
  1477. // Button handlers with enhanced keyboard navigation
  1478. cancelBtn.addEventListener('click', cancel);
  1479. cancelBtn.addEventListener('keydown', e => {
  1480. if (e.key === 'ArrowRight') {
  1481. e.preventDefault();
  1482. primaryBtn.focus();
  1483. }
  1484. });
  1485.  
  1486. primaryBtn.addEventListener('click', accept);
  1487. primaryBtn.addEventListener('keydown', e => {
  1488. if (e.key === 'ArrowLeft') {
  1489. e.preventDefault();
  1490. cancelBtn.focus();
  1491. }
  1492. });
  1493.  
  1494. // Focus management - start with first input and select all text
  1495. setTimeout(() => {
  1496. startInput.focus();
  1497. startInput.select();
  1498. }, 100);
  1499. });
  1500. }
  1501.  
  1502. /***************************************************************
  1503. * Apply episode range filtering with modern UI
  1504. ***************************************************************/
  1505. async function applyEpisodeRangeFilter(allEpLinks) {
  1506. const status = document.querySelector('.anlink-status-bar');
  1507. const epRangeThreshold = GM_getValue('ep_range_threshold', 24)
  1508. if (allEpLinks.length <= epRangeThreshold) return allEpLinks;
  1509.  
  1510. status.textContent = `Found ${allEpLinks.length} episodes. Waiting for selection...`;
  1511. const selection = await showEpisodeRangeSelector(allEpLinks.length);
  1512.  
  1513. if (!selection) {
  1514. status.textContent = 'Cancelled by user.';
  1515. return null;
  1516. }
  1517.  
  1518. const filtered = allEpLinks.slice(selection.start - 1, selection.end);
  1519. status.textContent = `Extracting episodes ${selection.start}-${selection.end} of ${allEpLinks.length}...`;
  1520. return filtered;
  1521. }
  1522.  
  1523. /***************************************************************
  1524. * Display a simple toast message on the top right of the screen
  1525. ***************************************************************/
  1526. let toasts = [];
  1527.  
  1528. function showToast(message) {
  1529. const maxToastHeight = window.innerHeight * 0.5;
  1530. const toastHeight = 50; // Approximate height of each toast
  1531. const maxToasts = Math.floor(maxToastHeight / toastHeight);
  1532.  
  1533. console.log(message);
  1534.  
  1535. // Create the new toast element
  1536. const x = document.createElement("div");
  1537. x.innerHTML = message;
  1538. x.style.color = "#000";
  1539. x.style.backgroundColor = "#fdba2f";
  1540. x.style.borderRadius = "10px";
  1541. x.style.padding = "10px";
  1542. x.style.position = "fixed";
  1543. x.style.top = `${toasts.length * toastHeight}px`;
  1544. x.style.right = "5px";
  1545. x.style.fontSize = "large";
  1546. x.style.fontWeight = "bold";
  1547. x.style.zIndex = "10000";
  1548. x.style.display = "block";
  1549. x.style.borderColor = "#565e64";
  1550. x.style.transition = "right 2s ease-in-out, top 0.5s ease-in-out";
  1551. document.body.appendChild(x);
  1552.  
  1553. // Add the new toast to the list
  1554. toasts.push(x);
  1555.  
  1556. // Remove the toast after it slides out
  1557. setTimeout(() => {
  1558. x.style.right = "-1000px";
  1559. }, 3000);
  1560.  
  1561. setTimeout(() => {
  1562. x.style.display = "none";
  1563. if (document.body.contains(x)) document.body.removeChild(x);
  1564. toasts = toasts.filter(toast => toast !== x);
  1565. // Move remaining toasts up
  1566. toasts.forEach((toast, index) => {
  1567. toast.style.top = `${index * toastHeight}px`;
  1568. });
  1569. }, 4000);
  1570.  
  1571. // Limit the number of toasts to maxToasts
  1572. if (toasts.length > maxToasts) {
  1573. const oldestToast = toasts.shift();
  1574. document.body.removeChild(oldestToast);
  1575. toasts.forEach((toast, index) => {
  1576. toast.style.top = `${index * toastHeight}px`;
  1577. });
  1578. }
  1579. }
  1580.  
  1581. // On overlay open, show a help link for mpv-handler if not detected
  1582. function showMPVHandlerHelp() {
  1583. showToast('To play directly in MPV, install <a href="https://github.com/akiirui/mpv-handler" target="_blank" style="color:#1976d2;">mpv-handler</a> and reload this page.');
  1584. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址