// ==UserScript== // @name Anime Sync // @namespace http://tampermonkey.net/ // @version 1.1 // @description A powerful userscript that automatically tracks and syncs your anime watching progress across various streaming platforms to AniList. Features direct episode detection, smart season handling, and a clean UI for seamless progress updates. // @author github.com/zenjahid // @license MIT // @match *://*.aniwatchtv.to/watch/* // @match *://*.aniwatchtv.com/watch/* // @match *://*.animepahe.com/play/* // @match *://*.animepahe.org/play/* // @match *://*.animepahe.ru/play/* // @match *://*.anime-pahe.com/play/* // @match *://*.pahe.win/play/* // @match *://*.miruro.tv/watch* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect graphql.anilist.co // @downloadURL none // ==/UserScript== (function () { "use strict"; // Debug mode - set to true to see more detailed logs const DEBUG = true; // Constants const ANILIST_API = "https://graphql.anilist.co"; const SUPPORTED_DOMAINS = { ANIWATCHTV: ["aniwatchtv.to", "aniwatchtv.com"], ANIMEPAHE: [ "animepahe.com", "animepahe.org", "animepahe.ru", "anime-pahe.com", "pahe.win", ], MIRURO: ["miruro.tv"], }; // Helper function to check domain function getDomainType(url) { for (const [type, domains] of Object.entries(SUPPORTED_DOMAINS)) { if (domains.some((domain) => url.includes(domain))) { return type; } } return null; } // Get stored credentials let accessToken = GM_getValue("accessToken", ""); let username = GM_getValue("username", ""); // Debug function function debug(message) { if (DEBUG) { console.log("[AniList Updater] " + message); } } // Show a failure popup with error details function showFailurePopup(message) { debug(`Showing failure popup: ${message}`); // Remove any existing popups const existingPopups = document.querySelectorAll( ".anilist-updater-error-popup" ); existingPopups.forEach((popup) => popup.remove()); const popup = document.createElement("div"); popup.className = "anilist-updater-error-popup"; popup.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: #F44336; color: white; padding: 20px; border-radius: 8px; z-index: 100000; max-width: 90%; width: 350px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); font-family: Arial, sans-serif; text-align: center; `; const title = document.createElement("h3"); title.textContent = "Update Failed"; title.style.margin = "0 0 15px 0"; const icon = document.createElement("div"); icon.innerHTML = "❌"; icon.style.fontSize = "32px"; icon.style.marginBottom = "10px"; const text = document.createElement("p"); text.textContent = message; text.style.margin = "0 0 15px 0"; const button = document.createElement("button"); button.textContent = "OK"; button.style.cssText = ` background-color: white; color: #F44336; border: none; padding: 8px 20px; border-radius: 4px; font-weight: bold; cursor: pointer; `; button.addEventListener("click", () => popup.remove()); popup.appendChild(icon); popup.appendChild(title); popup.appendChild(text); popup.appendChild(button); document.body.appendChild(popup); // Auto-close after 15 seconds setTimeout(() => { if (document.body.contains(popup)) { popup.remove(); } }, 15000); return popup; } // ------- UI ELEMENTS ------- // Simple modal dialog function createModal(title, content, buttons) { // Remove any existing modal const oldModal = document.getElementById("anilist-updater-modal"); if (oldModal) oldModal.remove(); const overlay = document.createElement("div"); overlay.id = "anilist-updater-modal"; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); display: flex; justify-content: center; align-items: center; z-index: 10000; font-family: Arial, sans-serif; `; const modal = document.createElement("div"); modal.style.cssText = ` background-color: #2b2d42; color: white; border-radius: 8px; padding: 20px; width: 350px; max-width: 90%; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); `; const headerDiv = document.createElement("div"); headerDiv.style.cssText = ` margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #444; display: flex; justify-content: space-between; align-items: center; `; const titleEl = document.createElement("h3"); titleEl.textContent = title; titleEl.style.cssText = ` margin: 0; color: #6C63FF; font-size: 18px; `; const closeBtn = document.createElement("button"); closeBtn.textContent = "×"; closeBtn.style.cssText = ` background: none; border: none; color: #aaa; font-size: 20px; cursor: pointer; `; closeBtn.onclick = () => overlay.remove(); headerDiv.appendChild(titleEl); headerDiv.appendChild(closeBtn); const contentDiv = document.createElement("div"); if (typeof content === "string") { contentDiv.innerHTML = content; } else { contentDiv.appendChild(content); } const buttonDiv = document.createElement("div"); buttonDiv.style.cssText = ` margin-top: 20px; text-align: right; `; if (buttons && buttons.length) { buttons.forEach((btn) => { const button = document.createElement("button"); button.textContent = btn.text; button.style.cssText = ` background-color: ${btn.primary ? "#6C63FF" : "#444"}; color: white; border: none; padding: 8px 15px; margin-left: 10px; border-radius: 4px; cursor: pointer; `; button.onclick = () => { if (btn.callback) btn.callback(); if (btn.close !== false) overlay.remove(); }; buttonDiv.appendChild(button); }); } modal.appendChild(headerDiv); modal.appendChild(contentDiv); modal.appendChild(buttonDiv); overlay.appendChild(modal); document.body.appendChild(overlay); return overlay; } // Create a notification function showNotification(message, type = "info", duration = 5000) { // Remove any existing notification with the same message const existingNotif = document.querySelectorAll(".anilist-updater-notif"); existingNotif.forEach((notif) => { if (notif.textContent.includes(message)) { notif.remove(); } }); const notif = document.createElement("div"); notif.className = "anilist-updater-notif"; // Style based on type let backgroundColor = "#2196F3"; // info let icon = "ℹ️"; if (type === "success") { backgroundColor = "#4CAF50"; icon = "✅"; } else if (type === "error") { backgroundColor = "#F44336"; icon = "❌"; // Errors stay longer duration = 8000; } else if (type === "warning") { backgroundColor = "#FF9800"; icon = "⚠️"; } notif.style.cssText = ` position: fixed; bottom: 20px; right: 20px; padding: 10px 15px; background-color: ${backgroundColor}; color: white; border-radius: 4px; font-family: Arial, sans-serif; font-size: 14px; z-index: 10000; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); max-width: 300px; word-wrap: break-word; `; notif.textContent = `${icon} ${message}`; document.body.appendChild(notif); // Remove after duration setTimeout(() => { if (document.body.contains(notif)) { notif.remove(); } }, duration); return notif; } // Show login dialog function showLoginDialog() { const content = document.createElement("div"); content.innerHTML = `
Enter your AniList access token to enable automatic updates:
To get your token:
https://anilist.co/api/v2/oauth/pin
https://anilist.co/api/v2/oauth/authorize?client_id=YOUR_CLIENT_ID&response_type=token
Update anime (ID: ${window.anilistDirectId}) to episode ${episode}?
`; createModal("Confirm Update", confirmMessage, [ { text: "Update", primary: true, callback: () => performUpdate(window.anilistDirectId, title, episode), }, { text: "Cancel", primary: false, }, ]); } else { // Automatic update performUpdate(window.anilistDirectId, title, episode); } return; } // Fallback to title search if no direct ID is available showNotification(`Searching for "${title}" on AniList...`, "info"); searchAnime(title) .then((results) => { if (!results || results.length === 0) { showNotification(`Could not find "${title}" on AniList`, "error"); return; } const animeData = results[0]; const displayTitle = animeData.title.english || animeData.title.romaji; const animeId = animeData.id; let actualEpisode = episode; debug(`Selected anime: "${displayTitle}" (ID: ${animeId})`); // Handle multi-season episode calculation if (animeData.episodes && episode > animeData.episodes && season > 1) { debug( `Episode ${episode} exceeds total episodes (${animeData.episodes}) in season ${season}` ); const seasonData = GM_getValue(`anime_seasons_${animeId}`, {}); // Calculate actual episode based on season let offset = 0; for (let i = 1; i < season; i++) { const prevSeasonEps = seasonData[`season${i}`]?.episodes || (i === 1 ? animeData.episodes : 12); offset += prevSeasonEps; } actualEpisode = episode + offset; debug( `Adjusted episode from ${episode} to ${actualEpisode} due to season ${season}` ); // Store season data seasonData[`season${season}`] = { firstEp: 1, anilistOffset: offset, }; GM_setValue(`anime_seasons_${animeId}`, seasonData); } // Show confirm dialog for manual updates if (forceUpdate) { let confirmMessage = `Update ${displayTitle} to episode ${actualEpisode}?
`; if (actualEpisode !== episode) { confirmMessage += `Note: Converting from Season ${season} Episode ${episode} to overall episode ${actualEpisode}.
`; } createModal("Confirm Update", confirmMessage, [ { text: "Update", primary: true, callback: () => performUpdate(animeId, displayTitle, actualEpisode), }, { text: "Cancel", primary: false, }, ]); } else { // Automatic update performUpdate(animeId, displayTitle, actualEpisode); } }) .catch((error) => { debug(`Search error: ${error}`); showNotification(`Error searching anime: ${error}`, "error"); }); } // Perform the actual update function performUpdate(animeId, displayTitle, episode) { debug( `Performing update for ${displayTitle} (ID: ${animeId}) to episode ${episode}` ); // Check if we have a direct AniList ID from meta tag if (window.anilistDirectId) { debug(`Using direct AniList ID from meta tag: ${window.anilistDirectId}`); animeId = window.anilistDirectId; } // Show a notification that we're updating showNotification( `Updating "${displayTitle}" to episode ${episode}...`, "info" ); updateAnimeProgress(animeId, episode) .then(() => { debug("Update successful"); showNotification( `Successfully updated "${displayTitle}" to episode ${episode}!`, "success" ); // Update the status button with success state const statusBtn = document.getElementById("anilist-updater-status"); if (statusBtn) { statusBtn.innerHTML = `✓ Updated EP ${episode}`; statusBtn.style.backgroundColor = "#4CAF50"; // Reset after 5 seconds setTimeout(() => { if (document.body.contains(statusBtn)) { statusBtn.innerHTML = `✓ AniList Connected`; statusBtn.style.backgroundColor = "#6C63FF"; } }, 5000); } }) .catch((error) => { debug(`Update failed: ${error}`); showNotification(`Failed to update: ${error}`, "error"); showFailurePopup( `Failed to update "${displayTitle}" to episode ${episode}. Error: ${error}` ); // Update status button with error state const statusBtn = document.getElementById("anilist-updater-status"); if (statusBtn) { statusBtn.innerHTML = `❌ Update Failed`; statusBtn.style.backgroundColor = "#F44336"; // Reset after 5 seconds setTimeout(() => { if (document.body.contains(statusBtn)) { statusBtn.innerHTML = `✓ AniList Connected`; statusBtn.style.backgroundColor = "#6C63FF"; } }, 5000); } }); } // Add manual update button function addManualUpdateButton() { // Remove any existing button const existingBtn = document.getElementById("anilist-manual-update"); if (existingBtn) existingBtn.remove(); // Get the status button width to calculate positioning const statusBtn = document.getElementById("anilist-updater-status"); let statusWidth = 120; // Default fallback width if (statusBtn) { const statusRect = statusBtn.getBoundingClientRect(); statusWidth = statusRect.width + 20; // Add 20px padding } const button = document.createElement("div"); button.id = "anilist-manual-update"; button.style.cssText = ` position: fixed; bottom: 20px; left: ${statusWidth}px; background-color: #6C63FF; color: white; padding: 5px 10px; border-radius: 20px; font-family: Arial, sans-serif; font-size: 12px; font-weight: bold; z-index: 9999; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2); opacity: 0.7; transition: opacity 0.3s, background-color 0.3s; `; button.innerHTML = `📝 Update Now`; button.title = "Manually update AniList"; button.addEventListener("mouseover", () => { button.style.opacity = "1"; }); button.addEventListener("mouseout", () => { button.style.opacity = "0.7"; }); button.addEventListener("click", () => { detectAndUpdateAnime(true); // Force update }); document.body.appendChild(button); // Reposition on window resize window.addEventListener("resize", () => { const statusBtn = document.getElementById("anilist-updater-status"); if (statusBtn && button) { const statusRect = statusBtn.getBoundingClientRect(); button.style.left = `${statusRect.width + 20}px`; } }); return button; } // Function to detect page changes in SPAs (Single Page Applications) function detectPageChange() { let lastUrl = window.location.href; // Create a new MutationObserver instance const observer = new MutationObserver(() => { if (window.location.href !== lastUrl) { lastUrl = window.location.href; debug(`URL changed to: ${lastUrl}`); // Wait for page content to load setTimeout(() => { detectAndUpdateAnime(); }, 3000); } }); // Start observing the document body for changes observer.observe(document.body, { childList: true, subtree: true }); debug("Page change detection initialized"); } // Initialize everything function init() { debug("Initializing AniList Auto Updater"); // Create status button createStatusButton(); // Add manual update button addManualUpdateButton(); // Check if we have credentials, and if not, show login immediately if (!accessToken || !username) { debug("No credentials found, showing login dialog immediately"); showLoginDialog(); } else { // Verify token silently verifyToken(accessToken, username) .then((isValid) => { if (isValid) { debug("Stored token is valid"); // Successful verification, run detection setTimeout(detectAndUpdateAnime, 2000); } else { debug("Stored token is invalid"); showNotification( "Your AniList token appears to be invalid. Please re-authenticate.", "error" ); showLoginDialog(); } }) .catch((err) => { debug(`Token verification error: ${err}`); showNotification("Error verifying AniList token", "error"); showFailurePopup( `Could not verify your AniList token. Error: ${err}` ); showLoginDialog(); }); } // Setup page change detection for SPAs detectPageChange(); } // Register menu command GM_registerMenuCommand("AniList Auto Updater Settings", showLoginDialog); // Start the script once the page is fully loaded if (document.readyState === "complete") { init(); } else { window.addEventListener("load", init); } // Update the fetchAniWatchTVEpisodes function function fetchAniWatchTVEpisodes(animeId, domain = "aniwatchtv.to") { debug(`Fetching episode data for anime ID: ${animeId}`); try { const xhr = new XMLHttpRequest(); xhr.open( "GET", `https://${domain}/ajax/v2/episode/list/${animeId}`, false ); xhr.send(); if (xhr.status !== 200) { throw new Error(`HTTP ${xhr.status}: ${xhr.statusText}`); } const { status, html } = JSON.parse(xhr.responseText); if (!status || !html) { throw new Error("Invalid API response"); } const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const episodeItems = Array.from( doc.querySelectorAll(".ssl-item.ep-item") ); if (!episodeItems.length) { debug("No episodes found"); return null; } // Sort episodes by their number and create mapping const sortedEpisodes = episodeItems .map((item) => ({ id: item.getAttribute("data-id"), number: parseInt(item.getAttribute("data-number"), 10), })) .sort((a, b) => a.number - b.number); const idToNumberMap = new Map( sortedEpisodes.map((ep, idx) => [ep.id, idx + 1]) ); debug(`Mapped ${idToNumberMap.size} episodes`); return { idToNumberMap }; } catch (error) { debug(`Episode fetch error: ${error.message}`); return null; } } })();