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!
// ==UserScript== // @name AniLINK - Episode Link Extractor // @namespace https://greasyfork.org/en/users/781076-jery-js // @version 6.2.1 // @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! // @icon https://www.google.com/s2/favicons?domain=animepahe.ru // @author Jery // @license MIT // @match https://anitaku.*/* // @match https://anitaku.bz/* // @match https://gogoanime.*/* // @match https://gogoanime3.cc/* // @match https://gogoanime3.*/* // @match https://animepahe.*/play/* // @match https://animepahe.*/anime/* // @match https://animepahe.ru/play/* // @match https://animepahe.com/play/* // @match https://animepahe.org/play/* // @match https://yugenanime.*/anime/*/*/watch/ // @match https://yugenanime.tv/anime/*/*/watch/ // @match https://yugenanime.sx/anime/*/*/watch/ // @match https://hianime.*/watch/* // @match https://hianime.to/watch/* // @match https://hianime.nz/watch/* // @match https://hianime.sz/watch/* // @match https://otaku-streamers.com/info/*/* // @match https://beta.otaku-streamers.com/watch/*/* // @match https://beta.otaku-streamers.com/title/*/* // @match https://animeheaven.me/anime.php?* // @match https://animez.org/*/* // @grant GM_registerMenuCommand // @grant GM_addStyle // ==/UserScript== class Episode { constructor(number, title, links, type, thumbnail) { this.number = number; // The episode number this.title = title; // The title of the episode (this can be the specific ep title or just the anime name). this.links = links; // An object containing the download links for the episode, keyed by quality (eg: {"source1":"http://linktovideo.mp4", "source2":"vid2.mp4"}). this.type = type; // The file type of the video links (eg: "mp4", "m3u8"). 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). this.name = `${this.title} - ${this.number.padStart(3, '0')}.${this.type}`; // The formatted name of the episode, combining title and number. } } /** * @typedef {Object} Websites[] * @property {string} name - The name of the website (required). * @property {string[]} url - An array of URL patterns that identify the website (required). * @property {string} thumbnail - A CSS selector to identify the episode thumbnail on the website (required). * @property {Function} addStartButton - A function to add the "Generate Download Links" button to the website (required). * @property {AsyncGeneratorFunction} extractEpisodes - An async generator function to extract episode information from the website (required). * @property {string} epLinks - A CSS selector to identify the episode links on the website (optional). * @property {string} epTitle - A CSS selector to identify the episode title on the website (optional). * @property {string} linkElems - A CSS selector to identify the download link elements on the website (optional). * @property {string} [animeTitle] - A CSS selector to identify the anime title on the website (optional). * @property {string} [epNum] - A CSS selector to identify the episode number on the website (optional). * @property {Function} [_getVideoLinks] - A function to extract video links from the website (optional). * @property {string} [styles] - Custom CSS styles to be applied to the website (optional). * * @description An array of website configurations for extracting episode links. * * @note To add a new website, follow these steps: * 1. Create a new object with the following properties: * - `name`: The name of the website. * - `url`: An array of URL patterns that identify the website. * - `thumbnail`: A CSS selector to identify the episode thumbnail on the website. * - `addStartButton`: A function to add the "Generate Download Links" button to the website. * - `extractEpisodes`: An async generator function to extract episode information from the website. * 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): * - `animeTitle`: A CSS selector to identify the anime title on the website. * - `epLinks`: A CSS selector to identify the episode links on the website. * - `epTitle`: A CSS selector to identify the episode title on the website. * - `linkElems`: A CSS selector to identify the download link elements on the website. * - `epNum`: A CSS selector to identify the episode number on the website. * - `_getVideoLinks`: A function to extract video links from the website. * - `styles`: Custom CSS styles to be applied to the website. * 3. Implement the `addStartButton` function to add the "Generate Download Links" button to the website. * - This function should create a element and append it to the appropriate location on the website. * - The button should have an ID of "AniLINK_startBtn". * 4. Implement the `extractEpisodes` function to extract episode information from the website. * - This function should be an async generator function that yields Episode objects (To ensure fast processing, using chunks is recommended). * - Use the `fetchPage` function to fetch the HTML content of each episode page. * - Parse the HTML content to extract the episode title, number, links, and thumbnail. * - Create an `Episode` object for each episode and yield it using the `yieldEpisodesFromPromises` function. * 5. Optionally, implement the `_getVideoLinks` function to extract video links from the website. * - This function should return a promise that resolves to an object containing video links. * - Use this function if the video links require additional processing or API calls. * - 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). */ const websites = [ { name: 'GoGoAnime', url: ['anitaku.to/', 'gogoanime3.co/', 'gogoanime3', 'anitaku', 'gogoanime'], epLinks: '#episode_related > li > a', epTitle: '.title_name > h2', linkElems: '.cf-download > a', thumbnail: '.headnav_left > a > img', addStartButton: function() { const button = Object.assign(document.createElement('a'), { id: "AniLINK_startBtn", style: "cursor: pointer; background-color: #145132;", innerHTML: document.querySelector("div.user_auth a[href='/login.html']") ? `<b style="color:#FFC119;">AniLINK:</b> Please <a href="/login.html"><u>log in</u></a> to download` : '<i class="icongec-dowload"></i> Generate Download Links' }); const target = location.href.includes('/category/') ? '#episode_page' : '.cf-download'; document.querySelector(target)?.appendChild(button); return button; }, extractEpisodes: async function* (status) { status.textContent = 'Starting...'; const throttleLimit = 12; // Number of episodes to extract in parallel const epLinks = Array.from(document.querySelectorAll(this.epLinks)); for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async epLink => { try { const page = await fetchPage(epLink.href); const [, epTitle, epNumber] = page.querySelector(this.epTitle).textContent.match(/(.+?) Episode (\d+(?:\.\d+)?)/); const thumbnail = page.querySelector(this.thumbnail).src; status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, '0')}...`; const links = [...page.querySelectorAll(this.linkElems)].reduce((obj, elem) => ({ ...obj, [elem.textContent.trim()]: elem.href }), {}); status.textContent = `Extracted ${epTitle} - ${epNumber.padStart(3, '0')}`; return new Episode(epNumber, epTitle, links, 'mp4', thumbnail); // Return Episode object } catch (e) { showToast(e); return null; } }); // Handle errors and return null yield* yieldEpisodesFromPromises(episodePromises); // Use helper function } } }, { name: 'YugenAnime', url: ['yugenanime.tv', 'yugenanime.sx'], epLinks: '.ep-card > a.ep-thumbnail', animeTitle: '.ani-info-ep .link h1', epTitle: 'div.col.col-w-65 > div.box > h1', thumbnail: 'a.ep-thumbnail img', addStartButton: function() { return document.querySelector(".content .navigation").appendChild(Object.assign(document.createElement('a'), { id: "AniLINK_startBtn", className: "link p-15", textContent: "Generate Download Links" })); }, extractEpisodes: async function* (status) { status.textContent = 'Getting list of episodes...'; const epLinks = Array.from(document.querySelectorAll(this.epLinks)); const throttleLimit = 6; // Number of episodes to extract in parallel for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async (epLink, index) => { try { status.textContent = `Loading ${epLink.pathname}` const page = await fetchPage(epLink.href); const animeTitle = page.querySelector(this.animeTitle).textContent; const epNumber = epLink.href.match(/(\d+)\/?$/)[1]; const epTitle = page.querySelector(this.epTitle).textContent.match(/^${epNumber} : (.+)$/) || animeTitle; const thumbnail = document.querySelectorAll(this.thumbnail)[index].src; status.textContent = `Extracting ${`${epNumber.padStart(3, '0')} - ${animeTitle}` + (epTitle != animeTitle ? `- ${epTitle}` : '')}...`; const links = await this._getVideoLinks(page, status, (`${epNumber.padStart(3, '0')} - ${animeTitle}` + (epTitle != animeTitle ? `- ${epTitle}` : ''))); return new Episode(epNumber, epTitle, links, 'm3u8', thumbnail); // Return Episode object } catch (e) { showToast(e); return null; } }); // Handle errors and return null yield* yieldEpisodesFromPromises(episodePromises); // Use helper function } }, _getVideoLinks: async function (page, status, episodeTitle) { const embedLinkId = page.body.innerHTML.match(new RegExp(`src="//${page.domain}/e/(.*?)/"`))[1]; const embedApiResponse = await fetch(`https://${page.domain}/api/embed/`, { method: 'POST', headers: {"X-Requested-With": "XMLHttpRequest"}, body: new URLSearchParams({ id: embedLinkId, ac: "0" }) }); const json = await embedApiResponse.json(); const m3u8GeneralLink = json.hls[0]; status.textContent = `Parsing ${episodeTitle}...`; // Fetch the m3u8 file content const m3u8Response = await fetch(m3u8GeneralLink); const m3u8Text = await m3u8Response.text(); // Parse the m3u8 file to extract different qualities const qualityMatches = m3u8Text.matchAll(/#EXT-X-STREAM-INF:.*RESOLUTION=\d+x\d+.*NAME="(\d+p)"\n(.*\.m3u8)/g); const links = {}; for (const match of qualityMatches) { const [_, quality, m3u8File] = match; links[quality] = `${m3u8GeneralLink.slice(0, m3u8GeneralLink.lastIndexOf('/') + 1)}${m3u8File}`; } return links; } }, { name: 'AnimePahe', url: ['animepahe.ru', 'animepahe.com', 'animepahe.org', 'animepahe'], epLinks: (document.location.pathname.startsWith('/anime/'))? '.play': '.dropup.episode-menu .dropdown-item', epTitle: '.theatre-info > h1', linkElems: '#resolutionMenu > button', thumbnail: '.theatre-info > a > img', addStartButton: function() { GM_addStyle(`.theatre-settings .col-sm-3 { max-width: 20%; }`); (document.location.pathname.startsWith('/anime/')) ? 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>` : 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> `; return document.getElementById("AniLINK_startBtn"); }, extractEpisodes: async function* (status) { status.textContent = 'Starting...'; const epLinks = Array.from(document.querySelectorAll(this.epLinks)); const throttleLimit = 200; // Setting high throttle limit actually improves performance for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async epLink => { try { const page = await fetchPage(epLink.href); if (page.querySelector(this.epTitle) == null) return; const [, epTitle, epNumber] = page.querySelector(this.epTitle).outerText.split(/Watch (.+) - (\d+(?:\.\d+)?) Online$/); const thumbnail = page.querySelector(this.thumbnail).src; status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, "0")}...`; async function getVideoUrl(kwikUrl) { const response = await fetch(kwikUrl, { headers: { "Referer": "https://animepahe.com" } }); const data = await response.text(); return eval(/(eval)(\(f.*?)(\n<\/script>)/s.exec(data)[2].replace("eval", "")).match(/https.*?m3u8/)[0]; } let links = {}; for (const elm of [...page.querySelectorAll(this.linkElems)]) { links[elm.textContent] = await getVideoUrl(elm.getAttribute('data-src')); status.textContent = `Parsed ${`${epNumber.padStart(3, '0')} - ${epTitle}`}`; } return new Episode(epNumber, epTitle, links, 'm3u8', thumbnail); } catch (e) { showToast(e); return null; } }); yield* yieldEpisodesFromPromises(episodePromises); } }, styles: `div#AniLINK_LinksContainer { font-size: 10px; } #Quality > b > div > ul {font-size: 16px;}` }, { name: 'Beta-Otaku-Streamers', url: ['beta.otaku-streamers.com'], epLinks: (document.location.pathname.startsWith('/title/')) ? '.item-title a' : '.video-container .clearfix > a', epTitle: '.title > a', epNum: '.watch_curep', thumbnail: 'video', addStartButton: function() { (document.location.pathname.startsWith('/title/') ? document.querySelector(".album-top-box"): document.querySelector('.video-container .title-box')) .innerHTML += `<a id="AniLINK_startBtn" class="btn btn-outline rounded-btn">Generate Download Links</a>`; return document.getElementById("AniLINK_startBtn"); }, extractEpisodes: async function* (status) { status.textContent = 'Starting...'; const epLinks = Array.from(document.querySelectorAll(this.epLinks)); const throttleLimit = 12; for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async epLink => { try { const page = await fetchPage(epLink.href); const epTitle = page.querySelector(this.epTitle).textContent.trim(); const epNumber = page.querySelector(this.epNum).textContent.replace("Episode ", '') const thumbnail = page.querySelector(this.thumbnail).poster; status.textContent = `Extracting ${epTitle} - ${epNumber}...`; const links = { 'Video Links': page.querySelector('video > source').src }; return new Episode(epNumber, epTitle, links, 'mp4', thumbnail); } catch (e) { showToast(e); return null; } }); yield* yieldEpisodesFromPromises(episodePromises); } } }, { name: 'Otaku-Streamers', url: ['otaku-streamers.com'], epLinks: 'table > tbody > tr > td:nth-child(2) > a', epTitle: '#strw_player > table > tbody > tr:nth-child(1) > td > span:nth-child(1) > a', epNum: '#video_episode', thumbnail: 'otaku-streamers.com/images/os.jpg', addStartButton: function() { const button = document.createElement('a'); button.id = "AniLINK_startBtn"; button.style.cssText = `cursor: pointer; background-color: #145132; float: right;`; button.innerHTML = 'Generate Download Links'; document.querySelector('table > tbody > tr:nth-child(2) > td > div > table > tbody > tr > td > h2').appendChild(button); return button; }, extractEpisodes: async function* (status) { status.textContent = 'Starting...'; const epLinks = Array.from(document.querySelectorAll(this.epLinks)); const throttleLimit = 12; // Number of episodes to extract in parallel for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async epLink => { try { const page = await fetchPage(epLink.href); const epTitle = page.querySelector(this.epTitle).textContent; const epNumber = page.querySelector(this.epNum).textContent.replace("Episode ", '') status.textContent = `Extracting ${epTitle} - ${epNumber}...`; const links = { 'mp4': page.querySelector('video > source').src }; return new Episode(epNumber, epTitle, links, 'mp4', this.thumbnail); // Return Episode object } catch (e) { showToast(e); return null; } }); // Handle errors and return null yield* yieldEpisodesFromPromises(episodePromises); // Use helper function } } }, { name: 'AnimeHeaven', url: ['animeheaven.me'], epLinks: 'a.ac3', epTitle: 'a.c2.ac2', epNumber: '.boxitem.bc2.c1.mar0', thumbnail: 'img.posterimg', addStartButton: function() { const button = document.createElement('a'); button.id = "AniLINK_startBtn"; button.style.cssText = `cursor: pointer; border: 2px solid red; padding: 4px;`; button.innerHTML = 'Generate Download Links'; document.querySelector("div.linetitle2.c2").parentNode.insertBefore(button, document.querySelector("div.linetitle2.c2")); return button; }, extractEpisodes: async function* (status) { status.textContent = 'Starting...'; const epLinks = Array.from(document.querySelectorAll(this.epLinks)); const throttleLimit = 12; // Number of episodes to extract in parallel for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async epLink => { try { const page = await fetchPage(epLink.href); const epTitle = page.querySelector(this.epTitle).textContent; const epNumber = page.querySelector(this.epNum).textContent.replace("Episode ", ''); const thumbnail = document.querySelector(this.thumbnail).src; status.textContent = `Extracting ${epTitle} - ${epNumber}...`; const links = [...page.querySelectorAll('#vid > source')].reduce((acc, source) => ({ ...acc, [source.src.match(/\/\/(\w+)\./)[1]]: source.src }), {}); return new Episode(epNumber, epTitle, links, 'mp4', thumbnail); // Return Episode object } catch (e) { showToast(e); return null; } }); // Handle errors and return null yield* yieldEpisodesFromPromises(episodePromises); // Use helper function } } }, { name: 'AnimeZ', url: ['animez.org'], epLinks: '.list-chapter .wp-manga-chapter a', epTitle: '#title-detail-manga', epNum: '.wp-manga-chapter.active', thumbnail: '.Image > figure > img', addStartButton: function() { (document.querySelector(".MovieTabNav.ControlPlayer") || document.querySelector(".mb-3:has(#keyword_chapter)")) .innerHTML += `<div class="Lnk AAIco-link" id="AniLINK_startBtn">Extract Episode Links</div>`; return document.getElementById("AniLINK_startBtn"); }, extractEpisodes: async function* (status) { status.textContent = 'Starting...'; const epLinks = Array.from(document.querySelectorAll(this.epLinks)) .filter((el, index, self) => self.findIndex(e => e.href === el.href && e.textContent.trim() === el.textContent.trim()) === index);; const throttleLimit = 12; // Number of episodes to extract in parallel for (let i = 0; i < epLinks.length; i += throttleLimit) { const chunk = epLinks.slice(i, i + throttleLimit); const episodePromises = chunk.map(async epLink => { try { const page = await fetchPage(epLink.href); const epTitle = page.querySelector(this.epTitle).textContent; const isDub = page.querySelector(this.epNum).textContent.includes('-Dub'); const epNumber = page.querySelector(this.epNum).textContent.replace(/-Dub/, '').trim(); const thumbnail = document.querySelector(this.thumbnail).src; status.textContent = `Extracting ${epTitle} - ${epNumber}...`; const links = { [isDub ? "Dub" : "Sub"]: page.querySelector('iframe').src.replace('/embed/', '/anime/') }; return new Episode(epNumber, epTitle, links, 'mp4', thumbnail); // Return Episode object } catch (e) { showToast(e); return null; } }); // Handle errors and return null yield* yieldEpisodesFromPromises(episodePromises); // Use helper function } } } ]; /** * Fetches the HTML content of a given URL and parses it into a DOM object. * * @param {string} url - The URL of the page to fetch. * @returns {Promise<Document>} A promise that resolves to a DOM Document object. * @throws {Error} If the fetch operation fails. */ async function fetchPage(url) { const response = await fetch(url); if (response.ok) { const page = (new DOMParser()).parseFromString(await response.text(), 'text/html'); return page; } else { showToast(`Failed to fetch HTML for ${url} : ${response.status}`); throw new Error(`Failed to fetch HTML for ${url} : ${response.status}`); } } /** * Asynchronously processes an array of episode promises and yields each resolved episode. * * @param {Array<Promise>} episodePromises - An array of promises, each resolving to an episode. * @returns {AsyncGenerator} An async generator yielding each resolved episode. */ async function* yieldEpisodesFromPromises(episodePromises) { for (const episodePromise of episodePromises) { const episode = await episodePromise; if (episode) { yield episode; } } } // initialize console.log('Initializing AniLINK...'); const site = websites.find(site => site.url.some(url => window.location.href.includes(url))); // register menu command to start script GM_registerMenuCommand('Extract Episodes', extractEpisodes); // attach start button to page site.addStartButton().addEventListener('click', extractEpisodes); // append site specific css styles document.body.style.cssText += (site.styles || ''); // This function creates an overlay on the page and displays a list of episodes extracted from a website. // The function is triggered by a user command registered with `GM_registerMenuCommand`. // The episode list is generated by calling the `extractEpisodes` method of a website object that matches the current URL. async function extractEpisodes() { // Restore last overlay if it exists if (document.getElementById("AniLINK_Overlay")) { document.getElementById("AniLINK_Overlay").style.display = "flex"; return; } // Flag to control extraction process let isExtracting = true; // --- Materialize CSS Initialization --- GM_addStyle(` @import url('https://fonts.googleapis.com/icon?family=Material+Icons'); #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; } #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 */ .anlink-status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } /* Header for status bar and stop button */ .anlink-status-bar { color: #eee; flex-grow: 1; margin-right: 10px; display: block; } /* Status bar takes space */ .anlink-status-icon { background: transparent; border: none; color: #eee; cursor: pointer; padding-right: 10px; } /* status icon style */ .anlink-status-icon i { font-size: 24px; transition: transform 0.3s ease-in-out; } /* Icon size and transition */ .anlink-status-icon i::before { content: 'check_circle'; } /* Show check icon when not extracting */ .anlink-status-icon i.extracting::before { content: 'auto_mode'; animation: spinning 2s linear infinite; } /* Spinner animation class */ .anlink-status-icon:hover i.extracting::before { content: 'stop_circle'; animation: stop; } /* Show stop icon on hover when extracting */ .anlink-quality-section { margin-top: 20px; margin-bottom: 10px; border-bottom: 1px solid #444; padding-bottom: 5px; } .anlink-quality-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; } /* Added cursor pointer */ .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 */ .anlink-quality-header i { margin-right: 8px; transition: transform 0.3s ease-in-out; } /* Transition for icon rotation */ .anlink-quality-header i.rotate { transform: rotate(90deg); } /* Rotate class */ .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 */ .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 */ .anlink-episode-item:last-child { border-bottom: none; } .anlink-episode-checkbox { appearance: none; width: 20px; height: 20px; margin-right: 10px; border: 1px solid #26a69a; border-radius: 4px; outline: none; cursor: pointer; transition: background-color 0.3s, border-color 0.3s; } .anlink-episode-checkbox:checked { background-color: #26a69a; border-color: #26a69a; } .anlink-episode-checkbox:checked::after { content: '✔'; display: block; color: white; font-size: 14px; text-align: center; line-height: 20px; animation: checkTilt 0.3s; } .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 */ .anlink-episode-link:hover { color: #fff; } .anlink-header-buttons { display: flex; gap: 10px; } .anlink-header-buttons button { background-color: #26a69a; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; } .anlink-header-buttons button:hover { background-color: #2bbbad; } @keyframes spinning { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Spinning animation */ @keyframes checkTilt { from { transform: rotate(-20deg); } to { transform: rotate(0deg); } } /* Checkmark tilt animation */ `); // Create an overlay to cover the page const overlayDiv = document.createElement("div"); overlayDiv.id = "AniLINK_Overlay"; document.body.appendChild(overlayDiv); overlayDiv.onclick = event => linksContainer.contains(event.target) ? null : overlayDiv.style.display = "none"; // Create a container for links const linksContainer = document.createElement('div'); linksContainer.id = "AniLINK_LinksContainer"; overlayDiv.appendChild(linksContainer); // Status bar header - container for status bar and status icon const statusBarHeader = document.createElement('div'); statusBarHeader.className = 'anlink-status-header'; linksContainer.appendChild(statusBarHeader); // Create dynamic status icon const statusIconElement = document.createElement('a'); statusIconElement.className = 'anlink-status-icon'; statusIconElement.innerHTML = '<i class="material-icons extracting"></i>'; statusIconElement.title = 'Stop Extracting'; statusBarHeader.appendChild(statusIconElement); statusIconElement.addEventListener('click', () => { isExtracting = false; // Set flag to stop extraction statusBar.textContent = "Extraction Stopped."; }); // Create a status bar const statusBar = document.createElement('span'); statusBar.className = "anlink-status-bar"; statusBar.textContent = "Extracting Links..." statusBarHeader.appendChild(statusBar); // Create a container for qualities and episodes const qualitiesContainer = document.createElement('div'); qualitiesContainer.id = "AniLINK_QualitiesContainer"; linksContainer.appendChild(qualitiesContainer); // --- Process Episodes using Generator --- const episodeGenerator = site.extractEpisodes(statusBar); const qualityLinkLists = {}; // Stores lists of links for each quality for await (const episode of episodeGenerator) { if (!isExtracting) { // Check if extraction is stopped statusIconElement.querySelector('i').classList.remove('extracting'); // Stop spinner animation statusBar.textContent = "Extraction Stopped By User."; return; // Exit if extraction is stopped } if (!episode) continue; // Skip if episode is null (error during extraction) // Get all links into format - {[qual1]:[ep1,2,3,4], [qual2]:[ep1,2,3,4], ...} for (const quality in episode.links) { qualityLinkLists[quality] = qualityLinkLists[quality] || []; qualityLinkLists[quality].push(episode); } // Update UI in real-time - RENDER UI HERE BASED ON qualityLinkLists renderQualityLinkLists(qualityLinkLists, qualitiesContainer); } isExtracting = false; // Extraction completed statusIconElement.querySelector('i').classList.remove('extracting'); statusBar.textContent = "Extraction Complete!"; // Renders quality link lists inside a given container element function renderQualityLinkLists(sortedLinks, container) { const previousExpandedState = {}; container.querySelectorAll('.anlink-quality-section').forEach(section => { const quality = section.dataset.quality; const episodeList = section.querySelector('.anlink-episode-list'); previousExpandedState[quality] = episodeList.style.maxHeight !== '0px'; }); container.innerHTML = ''; // Clear existing content for (const quality in sortedLinks) { const episodes = sortedLinks[quality].sort((a, b) => a.number - b.number); // Ensure episodes are sorted const qualitySection = document.createElement('div'); qualitySection.className = 'anlink-quality-section'; qualitySection.dataset.quality = quality; // Store quality-string in data attribute const headerDiv = document.createElement('div'); // Header div for quality-string and buttons - ROW headerDiv.className = 'anlink-quality-header'; // Create a span for the clickable header text and icon const qualitySpan = document.createElement('span'); qualitySpan.innerHTML = `<i class="material-icons">chevron_right</i> ${quality}`; // Expand icon and quality text qualitySpan.addEventListener('click', toggleQualitySection); // Add click listener to the span headerDiv.appendChild(qualitySpan); // --- Create Speed Dial Button in the Quality Section --- const headerButtons = document.createElement('div'); headerButtons.className = 'anlink-header-buttons'; headerButtons.innerHTML = ` <button type="button" class="anlink-select-links">Select</button> <button type="button" class="anlink-copy-links">Copy</button> <button type="button" class="anlink-export-links">Export</button> <button type="button" class="anlink-play-links">Play</button> `; headerDiv.appendChild(headerButtons); qualitySection.appendChild(headerDiv); // --- Populate the quality section with episodes --- const episodeListElem = document.createElement('ul'); episodeListElem.className = 'anlink-episode-list'; episodeListElem.style.maxHeight = '0px'; // Default to collapsed episodes.forEach(ep => { const listItem = document.createElement('li'); listItem.className = 'anlink-episode-item'; listItem.innerHTML = ` <label> <input type="checkbox" class="anlink-episode-checkbox" /> <span>Ep ${ep.number.replace(/^0+/, '')}: </span> <a href="${ep.links[quality]}" class="anlink-episode-link" download="${encodeURI(ep.name)}" data-epnum="${ep.number}" title="${ep.title.replace(/[<>:"/\\|?*]/g, '')}">${ep.links[quality]}</a> </label> `; const episodeLinkElement = listItem.querySelector('.anlink-episode-link'); const link = episodeLinkElement.href; const name = decodeURIComponent(episodeLinkElement.download); listItem.addEventListener('mouseenter', () => window.getSelection().isCollapsed && (episodeLinkElement.textContent = name)); listItem.addEventListener('mouseleave', () => episodeLinkElement.textContent = decodeURIComponent(link)); episodeListElem.appendChild(listItem); }); qualitySection.appendChild(episodeListElem); // Append ul inside section container.appendChild(qualitySection); // Attach handlers to the quality sections attachBtnClickListeners(episodes, qualitySection); // Restore expand state if (previousExpandedState[quality]) { const icon = qualitySpan.querySelector('.material-icons'); episodeListElem.style.maxHeight = `${episodeListElem.scrollHeight}px`; icon.classList.add('rotate'); } } } function toggleQualitySection(event) { // Target the closest anlink-quality-header span to ensure only clicks on the text/icon trigger toggle const qualitySpan = event.currentTarget; const headerDiv = qualitySpan.parentElement; const qualitySection = headerDiv.closest('.anlink-quality-section'); const episodeList = qualitySection.querySelector('.anlink-episode-list'); const icon = qualitySpan.querySelector('.material-icons'); // Query icon within the span const isCollapsed = episodeList.style.maxHeight === '0px'; if (isCollapsed) { episodeList.style.maxHeight = `${episodeList.scrollHeight}px`; // Expand to content height icon.classList.add('rotate'); // Rotate icon on expand } else { episodeList.style.maxHeight = '0px'; // Collapse icon.classList.remove('rotate'); // Reset icon rotation } } // Attach click listeners to the speed dial buttons for each quality section function attachBtnClickListeners(episodeList, qualitySection) { const buttonActions = [ { selector: '.anlink-select-links', handler: onSelectBtnPressed }, { selector: '.anlink-copy-links', handler: onCopyBtnClicked }, { selector: '.anlink-export-links', handler: onExportBtnClicked }, { selector: '.anlink-play-links', handler: onPlayBtnClicked } ]; buttonActions.forEach(({ selector, handler }) => { const button = qualitySection.querySelector(selector); button.addEventListener('click', () => handler(button, episodeList, qualitySection)); }); // Helper function to get checked episode items within a quality section function _getSelectedEpisodeItems(qualitySection) { return Array.from(qualitySection.querySelectorAll('.anlink-episode-item input[type="checkbox"]:checked')) .map(checkbox => checkbox.closest('.anlink-episode-item')); } // Helper function to prepare m3u8 playlist string from given episodes function _preparePlaylist(episodes, quality) { let playlistContent = '#EXTM3U\n'; episodes.forEach(episode => { playlistContent += `#EXTINF:-1,${episode.name}\n`; playlistContent += `${episode.links[quality]}\n`; }); return playlistContent; } // Select Button click event handler function onSelectBtnPressed(button, episodes, qualitySection) { const episodeItems = qualitySection.querySelector('.anlink-episode-list').querySelectorAll('.anlink-episode-item'); const checkboxes = Array.from(qualitySection.querySelectorAll('.anlink-episode-item input[type="checkbox"]')); const allChecked = checkboxes.every(cb => cb.checked); const anyUnchecked = checkboxes.some(cb => !cb.checked); if (anyUnchecked || allChecked === false) { // If any unchecked OR not all are checked (for the first click when none are checked) checkboxes.forEach(checkbox => { checkbox.checked = true; }); // Check all // Select all link texts const range = new Range(); range.selectNodeContents(episodeItems[0]); range.setEndAfter(episodeItems[episodeItems.length - 1]); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); button.textContent = 'Deselect All'; // Change button text to indicate deselect } else { // If all are already checked checkboxes.forEach(checkbox => { checkbox.checked = false; }); // Uncheck all window.getSelection().removeAllRanges(); // Clear selection button.textContent = 'Select All'; // Revert button text } setTimeout(() => { button.textContent = checkboxes.some(cb => !cb.checked) ? 'Select All' : 'Deselect All'; }, 1500); // slight delay revert text } // copySelectedLinks click event handler function onCopyBtnClicked(button, episodes, qualitySection) { const selectedItems = _getSelectedEpisodeItems(qualitySection); 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); const string = linksToCopy.join('\n'); navigator.clipboard.writeText(string); button.textContent = 'Copied Selected'; setTimeout(() => { button.textContent = 'Copy'; }, 1000); } // exportToPlaylist click event handler function onExportBtnClicked(button, episodes, qualitySection) { const quality = qualitySection.dataset.quality; const selectedItems = _getSelectedEpisodeItems(qualitySection); const items = selectedItems.length ? selectedItems : Array.from(qualitySection.querySelectorAll('.anlink-episode-item')); const playlist = _preparePlaylist(episodes.filter(ep => items.find(i => i.querySelector(`[data-epnum="${ep.number}"]`))), quality); const fileName = items[0]?.querySelector('.anlink-episode-link')?.title + `_${quality}.m3u`; const file = new Blob([playlist], { type: 'application/vnd.apple.mpegurl' }); const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(file), download: fileName }); a.click(); button.textContent = 'Exported Selected'; setTimeout(() => { button.textContent = 'Export'; }, 1000); } // PlayWithVLC click event handler function onPlayBtnClicked(button, episodes, qualitySection) { const quality = qualitySection.dataset.quality; const selectedEpisodeItems = _getSelectedEpisodeItems(qualitySection); const items = selectedEpisodeItems.length ? selectedEpisodeItems : Array.from(qualitySection.querySelectorAll('.anlink-episode-item')); const playlist = _preparePlaylist(episodes.filter(ep => items.find(i => i.querySelector(`[data-epnum="${ep.number}"]`))), quality); const file = new Blob([playlist], {type:'application/vnd.apple.mpegurl', }); const fileUrl = URL.createObjectURL(file); window.open(fileUrl); button.textContent = 'Playing Selected'; setTimeout(() => { button.textContent = 'Play'; }, 2000); alert("Due to browser limitations, there is a high possibility that this feature may not work correctly.\nIf the video does not automatically play, please utilize the export button and manually open the playlist file manually."); } } } /*************************************************************** * Display a simple toast message on the top right of the screen ***************************************************************/ let toasts = []; function showToast(message) { const maxToastHeight = window.innerHeight * 0.5; const toastHeight = 50; // Approximate height of each toast const maxToasts = Math.floor(maxToastHeight / toastHeight); console.log(message); // Create the new toast element const x = document.createElement("div"); x.innerHTML = message; x.style.color = "#000"; x.style.backgroundColor = "#fdba2f"; x.style.borderRadius = "10px"; x.style.padding = "10px"; x.style.position = "fixed"; x.style.top = `${toasts.length * toastHeight}px`; x.style.right = "5px"; x.style.fontSize = "large"; x.style.fontWeight = "bold"; x.style.zIndex = "10000"; x.style.display = "block"; x.style.borderColor = "#565e64"; x.style.transition = "right 2s ease-in-out, top 0.5s ease-in-out"; document.body.appendChild(x); // Add the new toast to the list toasts.push(x); // Remove the toast after it slides out setTimeout(() => { x.style.right = "-1000px"; }, 3000); setTimeout(() => { x.style.display = "none"; if (document.body.contains(x)) document.body.removeChild(x); toasts = toasts.filter(toast => toast !== x); // Move remaining toasts up toasts.forEach((toast, index) => { toast.style.top = `${index * toastHeight}px`; }); }, 4000); // Limit the number of toasts to maxToasts if (toasts.length > maxToasts) { const oldestToast = toasts.shift(); document.body.removeChild(oldestToast); toasts.forEach((toast, index) => { toast.style.top = `${index * toastHeight}px`; }); } }