// ==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
// ==/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 = `
<p style="margin: 0 0 15px 0;">Enter your AniList access token to enable automatic updates:</p>
<div style="margin-bottom: 15px;">
<label for="anilist-token" style="display: block; margin-bottom: 5px; font-size: 14px;">Access Token:</label>
<input type="password" id="anilist-token" value="${accessToken}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #555; background: #383A59; color: white;">
</div>
<div style="margin-bottom: 10px;">
<label for="anilist-username" style="display: block; margin-bottom: 5px; font-size: 14px;">Your AniList Username:</label>
<input type="text" id="anilist-username" value="${username}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #555; background: #383A59; color: white;">
</div>
<div style="font-size: 12px; margin-top: 15px; color: #aaa;">
<p style="margin: 0 0 5px 0;">To get your token:</p>
<ol style="margin: 0 0 0 20px; padding: 0;">
<li>Go to <a href="https://anilist.co/settings/developer" target="_blank" style="color: #6C63FF;">AniList Developer Settings</a></li>
<li>Create a new client (name it whatever you want)</li>
<li>Set redirect URL to: <code style="background: #383A59; padding: 2px 4px; border-radius: 2px;">https://anilist.co/api/v2/oauth/pin</code></li>
<li>Copy your Client ID</li>
<li>Visit: <code style="background: #383A59; padding: 2px 4px; border-radius: 2px;">https://anilist.co/api/v2/oauth/authorize?client_id=YOUR_CLIENT_ID&response_type=token</code></li>
<li>After authorization, copy the provided access token</li>
</ol>
</div>
`;
const modal = createModal("AniList Auto Updater Setup", content, [
{
text: "Save & Connect",
primary: true,
callback: () => {
const tokenInput = document.getElementById("anilist-token");
const usernameInput = document.getElementById("anilist-username");
if (!tokenInput || !usernameInput) return;
const newToken = tokenInput.value.trim();
const newUsername = usernameInput.value.trim();
if (!newToken || !newUsername) {
showNotification("Please fill both fields!", "error");
return;
}
showNotification("Verifying credentials...", "info");
verifyToken(newToken, newUsername)
.then((isValid) => {
if (isValid) {
accessToken = newToken;
username = newUsername;
GM_setValue("accessToken", accessToken);
GM_setValue("username", username);
showNotification(
"Successfully connected to AniList!",
"success"
);
createStatusButton();
// Try to detect and update
setTimeout(detectAndUpdateAnime, 1000);
} else {
showNotification("Invalid token or username!", "error");
}
})
.catch((err) => {
debug("Verification error: " + err);
showNotification("Failed to verify token: " + err, "error");
});
},
close: false,
},
{
text: "Cancel",
primary: false,
},
]);
return modal;
}
// Create or update status button
function createStatusButton() {
// Remove any existing button
const existingBtn = document.getElementById("anilist-updater-status");
if (existingBtn) existingBtn.remove();
const btn = document.createElement("div");
btn.id = "anilist-updater-status";
btn.style.cssText = `
position: fixed;
bottom: 20px;
left: 20px;
background-color: #6C63FF;
color: white;
padding: 8px 12px;
border-radius: 20px;
font-family: Arial, sans-serif;
font-size: 13px;
font-weight: bold;
z-index: 9999;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
transition: background-color 0.3s;
display: flex;
align-items: center;
`;
if (accessToken && username) {
btn.innerHTML = `<span style="margin-right: 5px;">✓</span> AniList Connected`;
btn.title = `Connected as ${username}`;
} else {
btn.innerHTML = `<span style="margin-right: 5px;">⚠️</span> AniList Setup Required`;
btn.title = "Click to connect your AniList account";
btn.style.backgroundColor = "#FF9800";
}
// Add click handler
btn.addEventListener("click", () => {
showLoginDialog();
});
document.body.appendChild(btn);
// Reposition the update button if it exists
const updateBtn = document.getElementById("anilist-manual-update");
if (updateBtn) {
const statusRect = btn.getBoundingClientRect();
updateBtn.style.left = `${statusRect.width + 20}px`;
}
return btn;
}
// ------- API FUNCTIONS -------
// Verify token is valid
function verifyToken(token, user) {
debug(`Verifying token for user: ${user}`);
return new Promise((resolve, reject) => {
if (!token || !user) {
reject("Missing token or username");
return;
}
const query = `
query {
Viewer {
id
name
}
}
`;
GM_xmlhttpRequest({
method: "POST",
url: ANILIST_API,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: "Bearer " + token,
},
data: JSON.stringify({ query }),
onload: function (response) {
try {
debug("Verification response received");
const result = JSON.parse(response.responseText);
if (result.errors) {
debug(`Verification error: ${JSON.stringify(result.errors)}`);
reject(result.errors[0].message);
return;
}
if (result.data && result.data.Viewer && result.data.Viewer.name) {
const isValid =
result.data.Viewer.name.toLowerCase() === user.toLowerCase();
debug(
`Token validation result: ${isValid ? "Valid" : "Invalid"}`
);
debug(
`API returned username: ${result.data.Viewer.name}, expected: ${user}`
);
resolve(isValid);
} else {
debug("Invalid response structure");
reject("Invalid API response");
}
} catch (e) {
debug(`Parse error: ${e.message}`);
reject(e.message);
}
},
onerror: function (error) {
debug(`Network error: ${error}`);
reject("Network error");
},
});
});
}
// Extract anime title and episode from the current page
function extractAnimeInfo() {
debug("Extracting anime info from page");
let title = "";
let episode = 0;
let season = 1;
let rawTitle = "";
const currentUrl = window.location.href;
const domainType = getDomainType(currentUrl);
try {
switch (domainType) {
case "MIRURO": {
debug("Detected Miruro");
const urlParams = new URLSearchParams(new URL(currentUrl).search);
const anilistId = urlParams.get("id");
const episodeNumber = urlParams.get("ep");
if (anilistId && episodeNumber) {
debug(
`Found AniList ID: ${anilistId} and Episode: ${episodeNumber}`
);
window.anilistDirectId = anilistId;
episode = parseInt(episodeNumber, 10);
// Get title from page for display purposes
title = document.title.replace(/ Episode \d+$/, "").trim();
rawTitle = title;
} else {
debug("Could not find AniList ID or episode number in URL");
}
break;
}
case "ANIWATCHTV": {
debug("Detected AniWatchTV");
const urlMatch = currentUrl.match(
/\/watch\/[^\/]+-(?<animeId>\d+)\?ep=(?<episodeId>\d+)/
);
if (!urlMatch?.groups) {
debug("URL format not recognized");
break;
}
const { animeId, episodeId } = urlMatch.groups;
const syncData = JSON.parse(
document.getElementById("syncData")?.textContent || "{}"
);
title = syncData.name?.replace(/'/g, "'") || "";
rawTitle = title;
if (syncData.anilist_id) {
window.anilistDirectId = syncData.anilist_id;
debug(`Using AniList ID from syncData: ${syncData.anilist_id}`);
}
const domain = currentUrl.includes("aniwatchtv.com")
? "aniwatchtv.com"
: "aniwatchtv.to";
const { idToNumberMap } =
fetchAniWatchTVEpisodes(animeId, domain) ?? {};
episode = idToNumberMap?.get(episodeId);
if (!title || !episode) {
title =
document
.querySelector(".film-name h2, .film-name")
?.textContent?.trim() || "";
rawTitle = title;
const epMatch = document
.querySelector(".ep-item.active")
?.textContent?.match(/(\d+)/);
episode = epMatch ? parseInt(epMatch[1], 10) : 0;
}
break;
}
case "ANIMEPAHE": {
debug("Detected AnimePahe");
// Step 1: Get AniList ID directly from meta tag
const anilistMetaTag = document.querySelector('meta[name="anilist"]');
if (anilistMetaTag) {
const anilistId = anilistMetaTag.getAttribute("content");
debug(`Found AniList ID directly from meta tag: ${anilistId}`);
// Store the AniList ID in a global variable
window.anilistDirectId = anilistId;
// Get title from page title
const pageTitle = document.title;
const titleMatch = pageTitle.match(/(.*?)(?:Episode|Ep\.) ?(\d+)/i);
if (titleMatch) {
title = titleMatch[1].trim();
rawTitle = title;
debug(`Title extracted from page title: ${title}`);
}
}
// Step 2: Get episode number from scrollArea and map to actual episode number
const scrollArea = document.getElementById("scrollArea");
if (scrollArea) {
debug("Found scrollArea element for episode list");
// Get all episode links
const episodeLinks = Array.from(
scrollArea.querySelectorAll("a.dropdown-item")
);
debug(`Found ${episodeLinks.length} episode links in scrollArea`);
if (episodeLinks.length > 0) {
// Sort episodes by their displayed number to ensure correct mapping
episodeLinks.sort((a, b) => {
const aNum = parseInt(
a.textContent.match(/Episode\s+(\d+)/i)?.[1] || "0",
10
);
const bNum = parseInt(
b.textContent.match(/Episode\s+(\d+)/i)?.[1] || "0",
10
);
return aNum - bNum;
});
// Create mapping of displayed episode numbers to actual episode numbers (1-based)
const episodeMapping = new Map();
episodeLinks.forEach((link, index) => {
const displayedEp = parseInt(
link.textContent.match(/Episode\s+(\d+)/i)?.[1] || "0",
10
);
const actualEp = index + 1; // 1-based indexing
episodeMapping.set(displayedEp, actualEp);
debug(
`Mapped displayed episode ${displayedEp} to actual episode ${actualEp}`
);
});
// Find the active episode
const activeEpisodeLink = scrollArea.querySelector(
"a.dropdown-item.active"
);
const currentPath = window.location.pathname;
if (activeEpisodeLink) {
// Get displayed episode number from active link text
const epTextMatch =
activeEpisodeLink.textContent.match(/Episode\s+(\d+)/i);
if (epTextMatch) {
const displayedEp = parseInt(epTextMatch[1], 10);
episode = episodeMapping.get(displayedEp) || displayedEp;
debug(
`Found displayed episode ${displayedEp}, mapped to actual episode ${episode}`
);
}
} else {
// No active link, try to match URL path
for (let i = 0; i < episodeLinks.length; i++) {
if (
episodeLinks[i]
.getAttribute("href")
.includes(currentPath.split("/").pop())
) {
const epTextMatch =
episodeLinks[i].textContent.match(/Episode\s+(\d+)/i);
if (epTextMatch) {
const displayedEp = parseInt(epTextMatch[1], 10);
episode = episodeMapping.get(displayedEp) || displayedEp;
debug(
`Matched URL to displayed episode ${displayedEp}, mapped to actual episode ${episode}`
);
}
break;
}
}
}
}
}
break;
}
case "CRUNCHYROLL": {
debug("Detected Crunchyroll");
const titleElem = document.querySelector(
".show-title-link h4, [data-t='show_title'], h4.title span, meta[property='og:title']"
);
if (titleElem?.tagName === "META") {
title = titleElem.getAttribute("content").split(" - ")[0].trim();
} else {
title = titleElem?.textContent?.trim() || "";
}
rawTitle = title;
const episodeMatch =
currentUrl.match(/\/(\d+)$/) ||
document
.querySelector(".episode-title")
?.textContent?.match(/Episode (\d+)/);
if (episodeMatch) {
episode = parseInt(episodeMatch[1], 10);
}
const seasonMatch =
document.title.match(/Season (\d+)/) ||
document
.querySelector(".season-name")
?.textContent?.match(/Season (\d+)/);
if (seasonMatch) {
season = parseInt(seasonMatch[1], 10);
}
break;
}
default:
debug(`Unsupported domain type: ${domainType}`);
break;
}
// Clean up title if we found one
if (title) {
const originalTitle = title;
title = title
.replace(/ \(TV\)/gi, "")
.replace(/ \((Sub|Dub|Dubbed|Subbed)\)/gi, "")
.replace(/ Season \d+/gi, "")
.replace(/ Part \d+/gi, "")
.replace(/ \(\d{4}\)/g, "")
.replace(/ \- \d+/g, "")
.replace(/^Watch /i, "")
.replace(/ Online$/i, "")
.replace(/English Sub\/Dub$/i, "")
.trim();
debug(`Cleaned title: "${originalTitle}" → "${title}"`);
}
debug(
`Final extraction: Title="${title}", Season=${season}, Episode=${episode}, Raw Title="${rawTitle}"`
);
return { title, episode, season, rawTitle };
} catch (error) {
debug(`Error extracting anime info: ${error.message}`);
return { title: "", episode: 0, season: 1, rawTitle: "" };
}
}
// Search for anime on AniList
function searchAnime(title) {
debug(`Searching for anime: "${title}"`);
return new Promise((resolve, reject) => {
if (!title) {
reject("No title provided for search");
return;
}
const query = `
query ($search: String) {
Page (page: 1, perPage: 5) {
media (search: $search, type: ANIME) {
id
title {
romaji
english
native
}
status
episodes
}
}
}
`;
GM_xmlhttpRequest({
method: "POST",
url: ANILIST_API,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
data: JSON.stringify({
query: query,
variables: { search: title },
}),
onload: function (response) {
try {
debug("Search response received");
const result = JSON.parse(response.responseText);
if (result.errors) {
debug(`Search error: ${JSON.stringify(result.errors)}`);
reject(result.errors[0].message);
return;
}
if (
result.data &&
result.data.Page &&
result.data.Page.media &&
result.data.Page.media.length > 0
) {
debug(
`Found ${result.data.Page.media.length} results for "${title}"`
);
debug(
`First result: ${
result.data.Page.media[0].title.english ||
result.data.Page.media[0].title.romaji
}`
);
resolve(result.data.Page.media);
} else {
debug("No results found");
reject(`No anime found with title "${title}"`);
}
} catch (e) {
debug(`Parse error: ${e.message}`);
reject(e.message);
}
},
onerror: function (error) {
debug(`Network error during search: ${error}`);
reject("Network error during search");
},
});
});
}
// Update anime progress on AniList
function updateAnimeProgress(animeId, episode) {
debug(`Updating anime (ID: ${animeId}) to episode ${episode}`);
return new Promise((resolve, reject) => {
if (!accessToken) {
reject("No access token provided");
return;
}
if (!animeId || !episode) {
reject("Invalid anime ID or episode number");
return;
}
const query = `
mutation ($mediaId: Int, $progress: Int) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: CURRENT) {
id
progress
status
}
}
`;
GM_xmlhttpRequest({
method: "POST",
url: ANILIST_API,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: "Bearer " + accessToken,
},
data: JSON.stringify({
query: query,
variables: {
mediaId: animeId,
progress: episode,
},
}),
onload: function (response) {
try {
debug("Update response received");
const result = JSON.parse(response.responseText);
if (result.errors) {
debug(`Update error: ${JSON.stringify(result.errors)}`);
reject(result.errors[0].message);
return;
}
if (result.data && result.data.SaveMediaListEntry) {
debug(`Successfully updated to episode ${episode}`);
// Store the last updated anime and episode
GM_setValue("lastUpdatedAnime", animeId);
GM_setValue("lastUpdatedEpisode", episode);
GM_setValue("lastUpdateTime", Date.now());
// Store in history
const history = GM_getValue("updateHistory", []);
history.unshift({
id: animeId,
episode: episode,
timestamp: Date.now(),
});
if (history.length > 10) history.pop();
GM_setValue("updateHistory", history);
resolve(result.data.SaveMediaListEntry);
} else {
debug("Unknown error in update response");
reject("Unknown error updating anime progress");
}
} catch (e) {
debug(`Parse error: ${e.message}`);
reject(e.message);
}
},
onerror: function (error) {
debug(`Network error during update: ${error}`);
reject("Network error during update");
},
});
});
}
// Main function to detect anime and update AniList
function detectAndUpdateAnime(forceUpdate = false) {
debug("Starting detection process");
// Check if we have credentials
if (!accessToken || !username) {
debug("Missing credentials");
if (!document.getElementById("anilist-updater-modal")) {
showNotification("AniList Auto Updater needs setup", "warning");
showLoginDialog();
}
return;
}
// Extract anime info from page
const { title, episode, season, rawTitle } = extractAnimeInfo();
if (!title || !episode) {
debug(`Missing title (${title}) or episode (${episode})`);
showNotification(
"Could not detect anime information on this page",
"warning"
);
return;
}
debug(
`Detected anime: "${title}", Season: ${season}, Episode: ${episode}, Raw Title: "${rawTitle}"`
);
// Check if this is the same as our last update
const lastUpdatedAnime = GM_getValue("lastUpdatedAnime", null);
const lastUpdatedEpisode = GM_getValue("lastUpdatedEpisode", null);
const lastUpdateTime = GM_getValue("lastUpdateTime", 0);
const updateThreshold = 30 * 60 * 1000; // 30 minutes threshold
// Only update if:
// 1. We're forcing an update, OR
// 2. This is a different anime or episode than last time, OR
// 3. The same anime/episode but last update was over threshold ago
const needsUpdate =
forceUpdate ||
lastUpdatedAnime !== title ||
lastUpdatedEpisode !== episode ||
Date.now() - lastUpdateTime > updateThreshold;
if (!needsUpdate) {
debug("Skipping update - same anime/episode updated recently");
return;
}
// If we have a direct AniList ID, use it instead of searching
if (window.anilistDirectId) {
debug(`Using direct AniList ID: ${window.anilistDirectId}`);
// Show confirm dialog for manual updates
if (forceUpdate) {
let confirmMessage = `<p>Update anime (ID: ${window.anilistDirectId}) to episode <strong>${episode}</strong>?</p>`;
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 = `<p>Update <strong>${displayTitle}</strong> to episode <strong>${actualEpisode}</strong>?</p>`;
if (actualEpisode !== episode) {
confirmMessage += `<p style="font-size: 12px; color: #aaa;">Note: Converting from Season ${season} Episode ${episode} to overall episode ${actualEpisode}.</p>`;
}
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 = `<span style="margin-right: 5px;">✓</span> Updated EP ${episode}`;
statusBtn.style.backgroundColor = "#4CAF50";
// Reset after 5 seconds
setTimeout(() => {
if (document.body.contains(statusBtn)) {
statusBtn.innerHTML = `<span style="margin-right: 5px;">✓</span> 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 = `<span style="margin-right: 5px;">❌</span> Update Failed`;
statusBtn.style.backgroundColor = "#F44336";
// Reset after 5 seconds
setTimeout(() => {
if (document.body.contains(statusBtn)) {
statusBtn.innerHTML = `<span style="margin-right: 5px;">✓</span> 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;
}
}
})();