Greasy Fork is available in English.

IMDb with additional ratings

Adds additional ratings (TMDB, Douban, Metacritic, Rotten Tomatoes, MyAnimeList) to imdb.com for movies and series. These can be activated or deactivated individually in the extension's configuration menu, which is accessible via the Tampermonkey menu. The extension also allows you to copy movie metadata by simply clicking on the runtime below the movie title.

// ==UserScript==
// @name            IMDb with additional ratings
// @description     Adds additional ratings (TMDB, Douban, Metacritic, Rotten Tomatoes, MyAnimeList) to imdb.com for movies and series. These can be activated or deactivated individually in the extension's configuration menu, which is accessible via the Tampermonkey menu. The extension also allows you to copy movie metadata by simply clicking on the runtime below the movie title.
// @version         20241211
// @author          mykarean
// @icon            https://icons.duckduckgo.com/ip2/imdb.com.ico
// @match           https://*.imdb.com/title/*
// @match           https://*.imdb.com/*/title/*
// @connect         api.douban.com
// @connect         wikidata.org
// @connect         metacritic.com
// @connect         rottentomatoes.com
// @connect         jikan.moe
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_addStyle
// @grant           GM_xmlhttpRequest
// @grant           GM_registerMenuCommand
// @run-at          document-start
// @compatible      chrome
// @license         GPL3
// @namespace https://greasyfork.org/users/1367334
// ==/UserScript==

"use strict";

// -----------------------------------------------------------------------------------------------------
// Config/Requirements
// -----------------------------------------------------------------------------------------------------

const ratingSourceOptions = ["TMDB", "Douban", "Metacritic", "Rotten Tomatoes", "My Anime List"];
const imdbId = window.location.pathname.match(/title\/(tt\d+)\//)[1];
const USER_AGENT = "Mozilla/5.0 (x64; rv) Gecko Firefox";
const undefinedValue = "X";
const initialValue = 0;
let local;

function getTitleElement() {
    return document.querySelector('[data-testid="hero__pageTitle"]');
}
function getMainTitle() {
    return getTitleElement()?.textContent;
}
function getOriginalTitle() {
    let originalTitle = document.querySelector('[data-testid="hero__pageTitle"] ~ div')?.textContent?.match(/^.*:\ (.*)/)?.[1];
    // Unicode normalisation and removal of diacritical characters to improve search on other pages
    return originalTitle?.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function getAlsoKnownAsTitle() {
    let alsoKnownAsTitle = document.querySelector("section[data-testid=Details] li[data-testid=title-details-akas] > div");
    return alsoKnownAsTitle?.textContent;
}

GM_registerMenuCommand("Configuration", configurationMenu, "c");

// -----------------------------------------------------------------------------------------------------
// General Functions
// -----------------------------------------------------------------------------------------------------

async function addCss() {
    if (!document.getElementById("custom-css-style")) {
        GM_addStyle(`
            /* all Badges */
            .rating-bar__base-button {
                margin-right: 0 !important;
            }

            /* added Badges */
            span[data-testid="hero-rating-bar__aggregate-rating"],
            .rating-bar__base-button > .ipc-btn {
                padding: 2px 3px;
                border-radius: 5px !important;
            }
            span[data-testid=hero-rating-bar__aggregate-rating] {
                margin: 0 3px;
                background-color: rgba(255, 255, 255, 0.08);
            }
            /* format rating content */
            span[data-testid=hero-rating-bar__aggregate-rating] .ipc-btn__text > div > div {
                 align-items: center;
                 padding-right: 0;
            }
            span[data-testid=hero-rating-bar__aggregate-rating] div[data-testid=hero-rating-bar__aggregate-rating__score] > span:nth-child(1) {
                padding-right: 0;
            }
            [data-testid=hero-rating-bar__aggregate-rating] div[data-testid=hero-rating-bar__aggregate-rating__score] > span:nth-child(1) {
                letter-spacing: -0.4px;
            }
            /* remove /10 */
            span[data-testid=hero-rating-bar__aggregate-rating] div[data-testid=hero-rating-bar__aggregate-rating__score] > span:nth-child(2) {
                display: none;
            }
            /* vote count */
            span[data-testid=hero-rating-bar__aggregate-rating] .ipc-btn__text > div > div > div {
                letter-spacing: -0.2px;
            }

            /* IMDb Badges */
            [data-testid="hero-rating-bar__popularity"],
            [data-testid="hero-rating-bar__user-rating"] {
                padding-left: 0 !important;
                padding-right: 0 !important;
            }
            [data-testid="hero-rating-bar__popularity__score"] {
                letter-spacing: -0.3px !important;
            }

            /* Badge Header */
            .rating-bar__base-button > div {
                letter-spacing: unset;
            }
            span.rating-bar__base-button[myanimelist] > div,
            span.rating-bar__base-button[rottentomatoes] > div {
                letter-spacing: -0.5px;
            }
            
            /* for badges without rating data */
            .disabled-anchor {
                cursor: default !important;
            }
            .disabled-anchor:before {
                background: unset !important;
            }

            /* title if line break */
            span.hero__primary-text {
                line-height: 30px;
                display: inline-block;
            }
        `).setAttribute("id", "custom-css-style");
    }
    const imdbRatingName = document.querySelector('div[data-testid="hero-rating-bar__aggregate-rating"] > div');
    if (imdbRatingName) {
        imdbRatingName.textContent = "IMDb";
    }

    const authorsMode = await GM_getValue("authorsMode2", false);
    if (authorsMode) {
        if (!document.getElementById("authors-custom-css-style")) {
            GM_addStyle(`
                /* remove star */
                div[data-testid=hero-rating-bar__aggregate-rating] .ipc-btn__text > div > div:first-child {
                    display: none;
                }
                /* center rating */
                div[data-testid=hero-rating-bar__aggregate-rating] div[data-testid=hero-rating-bar__aggregate-rating__score] {
                    align-self: center;
                }
                /* remove /10 */
                div[data-testid=hero-rating-bar__aggregate-rating] div[data-testid=hero-rating-bar__aggregate-rating__score] > span:nth-child(2) {
                    display: none;
                }
            `).setAttribute("id", "authors-custom-css-style");
        }
    }
}

// create the initial rating template
function createRatingBadge(ratingSource) {
    const ratingElementImdb = document.querySelector('div[data-testid="hero-rating-bar__aggregate-rating"]');

    // ignore if the rating badge has already been created
    if (!ratingElementImdb || document.querySelector(`span.rating-bar__base-button[${ratingSource}]`)) return null;

    function updateRatingElement(element, rating, voteCount) {
        let ratingElement = element.querySelector("div[data-testid=hero-rating-bar__aggregate-rating__score]");
        if (ratingElement) {
            ratingElement.querySelector("span").innerText = rating;
            ratingElement.nextSibling.nextSibling.innerText = voteCount;
        }
    }

    let clonedRatingBadge = ratingElementImdb.cloneNode(true);
    clonedRatingBadge.setAttribute(ratingSource, "");
    clonedRatingBadge.childNodes[0].innerText = ratingSource;

    // disable link per default
    clonedRatingBadge.querySelector("a").removeAttribute("href");
    clonedRatingBadge.querySelector("a").classList.add("disabled-anchor");

    if (ratingSource === "Metacritic" || ratingSource === "RottenTomatoes") {
        const criticRatingElement = clonedRatingBadge.querySelector(
            "div[data-testid=hero-rating-bar__aggregate-rating__score]"
        )?.parentElement;

        if (!criticRatingElement) {
            console.error("Critic rating element not found");
            return;
        }

        // Critic rating: replace star svg element with critic rating by cloning the rating element
        let criticRating = clonedRatingBadge
            .querySelector("div[data-testid=hero-rating-bar__aggregate-rating__score]")
            .parentElement.cloneNode(true);
        criticRating.classList.add("critic-rating");
        clonedRatingBadge
            .querySelector("div[data-testid=hero-rating-bar__aggregate-rating__score]")
            .parentElement.classList.add("user-rating");

        //  critic rating
        updateRatingElement(criticRating, initialValue, initialValue);
        criticRating.title = "Critics Rating";
        criticRating.style.cssText = `
        background-color: rgba(255, 255, 255, 0.1);
        padding-left: 2px;
        padding-right: 2px;
        margin-right: 4px;
        border-radius: 5px;
        `;
        clonedRatingBadge.querySelector("a > span > div > div").outerHTML = criticRating.outerHTML;

        // user rating
        updateRatingElement(clonedRatingBadge.querySelector(".user-rating"), initialValue, initialValue);
        clonedRatingBadge.querySelector(".user-rating").title = "User Rating";
    } else {
        updateRatingElement(clonedRatingBadge, initialValue, initialValue);
        clonedRatingBadge.querySelector("a > span > div > div").remove();
    }

    // convert div to span element, otherwise it will be removed from IMDb scripts
    const ratingElement = document.createElement("span");
    // Transfer all attributes from the cloned div element to the new span element
    for (let attr of clonedRatingBadge.attributes) {
        ratingElement.setAttribute(attr.name, attr.value);
    }
    // transfer the content of the cloned IMDb rating element to the new span element
    ratingElement.innerHTML = clonedRatingBadge.innerHTML;

    ratingElementImdb.insertAdjacentElement("beforebegin", ratingElement);

    fitTitleToSingleLine();

    return ratingElement;
}

// update the rating template with actual data
function updateRatingBadge(newRatingBadge, ratingData) {
    if (!newRatingBadge || !ratingData) return;

    const selectors = {
        url: "a",
        generalRating: "div[data-testid=hero-rating-bar__aggregate-rating__score] > span",
        generalVoteCount: "div[data-testid=hero-rating-bar__aggregate-rating__score] + * + *",
        criticRating: ".critic-rating div[data-testid=hero-rating-bar__aggregate-rating__score] > span",
        criticVoteCount: ".critic-rating div[data-testid=hero-rating-bar__aggregate-rating__score] + * + *",
        userRating: ".user-rating div[data-testid=hero-rating-bar__aggregate-rating__score] > span",
        userVoteCount: ".user-rating div[data-testid=hero-rating-bar__aggregate-rating__score] + * + *",
    };

    function updateElement(selector, value, voteCount = 0) {
        const element = newRatingBadge.querySelector(selector);

        if (!voteCount) {
            element.textContent = value !== undefined && value !== 0 ? value : undefinedValue;
        } else {
            element.textContent = value !== undefined && value !== 0 ? value : undefinedValue.toLowerCase();
        }
    }

    newRatingBadge.querySelector(selectors.url).href = ratingData.url;
    newRatingBadge.querySelector(selectors.url).classList.remove("disabled-anchor");

    if (ratingData.criticRating !== undefined || ratingData.userRating !== undefined) {
        updateElement(selectors.criticRating, ratingData.criticRating);
        updateElement(selectors.userRating, ratingData.userRating);
        updateElement(selectors.criticVoteCount, ratingData.criticVoteCount, 1);
        updateElement(selectors.userVoteCount, ratingData.userVoteCount, 1);
    } else {
        updateElement(selectors.generalRating, ratingData.rating);
        updateElement(selectors.generalVoteCount, ratingData.voteCount, 1);
    }

    fitTitleToSingleLine();
}

// reduce titles font size to avoid line breaks
function fitTitleToSingleLine() {
    const element = document.querySelector('h1[data-testid="hero__pageTitle"] > span');
    let fontSize = parseFloat(window.getComputedStyle(element).fontSize);

    // if (element.offsetHeight < 68) {
    //     while (element.offsetHeight < 68 && fontSize < 48) {
    //         fontSize += 1;
    //         element.style.fontSize = fontSize + "px";
    //         if (element.offsetHeight >= 68) {
    //             fontSize -= 1;
    //             element.style.fontSize = fontSize + "px";
    //             break;
    //         }
    //     }
    // } else {
    while (element.offsetHeight >= 58 && fontSize >= 26) {
        fontSize -= 1;
        element.style.fontSize = fontSize + "px";
    }
    // }
}

// -----------------------------------------------------------------------------------------------------
// TMDB
// -----------------------------------------------------------------------------------------------------

let tmdbDataPromise = null;
async function getTmdbData() {
    const configured = await GM_getValue("TMDB", true);
    if (!configured) return;

    if (tmdbDataPromise) return tmdbDataPromise;

    if (!imdbId) {
        throw new Error("IMDb ID not found in URL.");
    }

    const options = {
        method: "GET",
        headers: {
            accept: "application/json",
            Authorization:
                "Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIyMzc1ZGIzOTYwYWVhMWI1OTA1NWMwZmM3ZDcwYjYwZiIsInN1YiI6IjYwYmNhZTk0NGE0YmY2MDA1OWJhNWE1ZSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.DU51juQWlAIIfZ2lK99b3zi-c5vgc4jAwVz5h2WjOP8",
        },
    };

    tmdbDataPromise = fetch(`https://api.themoviedb.org/3/find/${imdbId}?external_source=imdb_id`, options)
        .then((response) => {
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json();
        })
        .then((data) => {
            const result = data.movie_results[0] || data.tv_results[0];
            if (!result) {
                throw new Error("No data found for the provided IMDb ID.");
            }

            console.log("TMDB: ", result);
            return {
                source: "TMDB",
                rating: (Math.round(result.vote_average * 10) / 10).toLocaleString(local, {
                    minimumFractionDigits: 1,
                    maximumFractionDigits: 1,
                }),
                voteCount: result.vote_count?.toLocaleString(local),
                url: `https://www.themoviedb.org/${result.media_type}/${result.id}`,
            };
        })
        .catch((error) => {
            console.error("Error fetching TMDb data:", error);
            return Promise.resolve({
                source: "TMDB",
                rating: initialValue,
                voteCount: initialValue,
                url: null,
            });
        });

    return tmdbDataPromise;
}

async function addTmdbRatingBadge() {
    const configured = await GM_getValue("TMDB", true);
    if (!configured) return;

    const newRatingBadge = createRatingBadge("TMDB");

    // if the template for the rating badge was not created, it already exists
    if (!newRatingBadge) return;

    const ratingData = await getTmdbData();

    // Copy ratingData to avoid modifying the original object
    let finalRatingData = { ...ratingData };

    // Check if ratingData or ratingData.url is undefined and provide a default value
    if (!ratingData?.url) {
        const searchTitle = getOriginalTitle() ?? getMainTitle();
        const defaultUrl = `https://www.themoviedb.org/search?query=${searchTitle}`;
        finalRatingData.url = defaultUrl;
    }

    updateRatingBadge(newRatingBadge, finalRatingData);
}

// -----------------------------------------------------------------------------------------------------
// Douban
// -----------------------------------------------------------------------------------------------------

let doubanDataPromise = null;
async function getDoubanData() {
    const configured = await GM_getValue("Douban", true);
    if (!configured) return;

    if (doubanDataPromise) return doubanDataPromise;

    if (!imdbId) {
        throw new Error("IMDb ID not found in URL.");
    }

    const fetchFromDouban = (url, method = "GET", data = null) =>
        new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method,
                url,
                data,
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded; charset=utf8",
                },
                onload: (response) => {
                    if (response.status >= 200 && response.status < 400) {
                        resolve(JSON.parse(response.responseText));
                    } else {
                        console.error(`Error getting ${url}:`, response.status, response.statusText, response.responseText);
                        resolve(null);
                    }
                },
                onerror: (error) => {
                    console.error(`Error during GM.xmlHttpRequest to ${url}:`, error.statusText);
                    reject(error);
                },
            });
        });

    const getDoubanInfo = async (imdbId) => {
        const data = await fetchFromDouban(
            `https://api.douban.com/v2/movie/imdb/${imdbId}`,
            "POST",
            "apikey=0ac44ae016490db2204ce0a042db2916"
        );
        if (data && data.alt && data.alt !== "N/A") {
            const url = data.alt.replace("/movie/", "/subject/") + "/";
            return { url, rating: data.rating, title: data.title };
        }
    };

    doubanDataPromise = (async function () {
        try {
            const result = await getDoubanInfo(imdbId);
            if (!result) {
                throw new Error("No data found for the provided IMDb ID.");
            }

            let ratingRaw = result.rating.average;
            let rating =
                !isNaN(ratingRaw) && ratingRaw !== ""
                    ? Number(ratingRaw).toLocaleString(local, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
                    : 0;
            let voteCountRaw = result.rating.numRaters;
            let voteCount = !isNaN(voteCountRaw) && voteCountRaw !== 0 ? Number(voteCountRaw).toLocaleString(local) : 0;

            console.log("Douban: ", result);
            return {
                source: "Douban",
                rating: rating,
                voteCount: voteCount,
                url: result.url,
            };
        } catch (error) {
            console.error("Error fetching Douban data:", error);
            return Promise.resolve({
                source: "Douban",
                rating: initialValue,
                voteCount: initialValue,
                url: null,
            });
        }
    })();

    return doubanDataPromise;
}

async function addDoubanRatingBadge() {
    const configured = await GM_getValue("Douban", true);
    if (!configured) return;

    const newRatingBadge = createRatingBadge("Douban");

    // if the template for the rating badge was not created, it already exists
    if (!newRatingBadge) return;

    const ratingData = await getDoubanData();

    // Copy ratingData to avoid modifying the original object
    let finalRatingData = { ...ratingData };

    // Check if ratingData or ratingData.url is undefined and provide a default value
    if (!ratingData?.url) {
        const searchTitle = getOriginalTitle() ?? getMainTitle();
        const defaultUrl = `https://search.douban.com/movie/subject_search?search_text=${searchTitle}`;
        finalRatingData.url = defaultUrl;
    }

    updateRatingBadge(newRatingBadge, finalRatingData);
}

// -----------------------------------------------------------------------------------------------------
// Metacritic
// -----------------------------------------------------------------------------------------------------
// wikidata solution inspired by IMDb Scout Mod

let metacriticDataPromise = null;
async function getMetacriticData() {
    const configured = await GM_getValue("Metacritic", true);
    if (!configured) return;

    if (metacriticDataPromise) return metacriticDataPromise;

    if (!imdbId) {
        throw new Error("IMDb ID not found in URL.");
    }

    async function getMetacriticId() {
        return new Promise((resolve) => {
            GM.xmlHttpRequest({
                method: "GET",
                timeout: 10000,
                headers: { "User-Agent": USER_AGENT },
                url: `https://query.wikidata.org/sparql?format=json&query=SELECT * WHERE {?s wdt:P345 "${imdbId}". OPTIONAL { ?s wdt:P1712 ?Metacritic_ID. }}`,
                onload: function (response) {
                    const result = JSON.parse(response.responseText);
                    const bindings = result.results.bindings[0];
                    const metacriticId = bindings && bindings.Metacritic_ID ? bindings.Metacritic_ID.value : "";
                    resolve(metacriticId);
                },
                onerror: function () {
                    console.error("getMetacriticId: Request Error.");
                    reject("Request Error");
                },
                onabort: function () {
                    console.error("getMetacriticId: Request Aborted.");
                    reject("Request Abort");
                },
                ontimeout: function () {
                    console.error("getMetacriticId: Request Timeout.");
                    reject("Request Timeout");
                },
            });
        });
    }

    function fetchMetacriticData(url) {
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: "GET",
                url: url,
                headers: { "User-Agent": USER_AGENT },
                onload: function (response) {
                    const parser = new DOMParser();
                    const result = parser.parseFromString(response.responseText, "text/html");

                    const parseRating = (ratingElement, voteSelector, divideByTen = false) => {
                        if (!ratingElement) return { rating: 0, voteCount: 0 };

                        let ratingText = ratingElement.textContent.trim();
                        let rating = !isNaN(ratingText) ? Number(ratingText) : 0;

                        if (divideByTen) {
                            rating = rating / 10;
                        }

                        // no fractions for 10 and 0
                        if (rating !== 10 && rating !== 0) {
                            rating = rating.toLocaleString(local, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
                        }

                        const voteCountText = result.querySelector(voteSelector)?.textContent;
                        const voteCount = voteCountText
                            ? Number(voteCountText.match(/\d{1,3}(?:,\d{3})*/)[0].replace(/,/g, "")).toLocaleString(local)
                            : 0;

                        return { rating, voteCount };
                    };

                    const criticRatingElement = result.querySelector(
                        ".c-siteReviewScore_background-critic_medium .c-siteReviewScore span"
                    );
                    const { rating: criticRating, voteCount: criticVoteCount } = parseRating(
                        criticRatingElement,
                        '.c-productScoreInfo_scoreContent a[href*="critic-reviews"] span',
                        true
                    );

                    const userRatingElement = result.querySelector(".c-siteReviewScore_background-user .c-siteReviewScore span");
                    const { rating: userRating, voteCount: userVoteCount } = parseRating(
                        userRatingElement,
                        '.c-productScoreInfo_scoreContent a[href*="user-reviews"] span',
                        false
                    );

                    console.log(
                        `Critic rating: ${criticRating}, User rating: ${userRating}, Critic vote count: ${criticVoteCount}, User vote count: ${userVoteCount}, URL: ${url}`
                    );

                    resolve({
                        source: "Metacritic",
                        criticRating,
                        userRating,
                        criticVoteCount,
                        userVoteCount,
                        url,
                    });
                },

                onerror: function () {
                    console.log("getMetacriticRatings: Request Error.");
                    reject("Request Error");
                },
                onabort: function () {
                    console.log("getMetacriticRatings: Request is aborted.");
                    reject("Request Aborted");
                },
                ontimeout: function () {
                    console.log("getMetacriticRatings: Request timed out.");
                    reject("Request Timed Out");
                },
            });
        });
    }

    metacriticDataPromise = (async () => {
        const metacriticId = await getMetacriticId();
        const url = encodeURI(`https://www.metacritic.com/${metacriticId}`);

        if (metacriticId !== "") {
            return fetchMetacriticData(url);
        } else {
            return Promise.resolve({
                source: "Metacritic",
                criticRating: initialValue,
                userRating: initialValue,
                criticVoteCount: initialValue,
                userVoteCount: initialValue,
                url: null,
            });
        }
    })();

    return metacriticDataPromise;
}

async function addMetacriticRatingBadge() {
    const configured = await GM_getValue("Metacritic", true);
    if (!configured) return;

    const newRatingBadge = createRatingBadge("Metacritic");

    // if the template for the rating badge was not created, it already exists
    if (!newRatingBadge) return;

    const ratingData = await getMetacriticData();

    // Copy ratingData to avoid modifying the original object
    let finalRatingData = { ...ratingData };

    // Check if ratingData or ratingData.url is undefined and provide a default value
    if (!ratingData?.url) {
        const searchTitle = getOriginalTitle() ?? getMainTitle();
        const defaultUrl = `https://www.metacritic.com/search/${searchTitle}`;
        finalRatingData.url = defaultUrl;
    }

    updateRatingBadge(newRatingBadge, finalRatingData);
}

// -----------------------------------------------------------------------------------------------------
// Rotten Tomatoes
// -----------------------------------------------------------------------------------------------------
// wikidata solution inspired by IMDb Scout Mod

let rottenTomatoesDataPromise = null;
async function getRottenTomatoesData() {
    const configured = await GM_getValue("RottenTomatoes", true);
    if (!configured) return;

    if (rottenTomatoesDataPromise) return rottenTomatoesDataPromise;

    if (!imdbId) {
        throw new Error("IMDb ID not found in URL.");
    }

    async function getRottenTomatoesId() {
        return new Promise((resolve) => {
            GM.xmlHttpRequest({
                method: "GET",
                timeout: 10000,
                headers: { "User-Agent": USER_AGENT },
                url: `https://query.wikidata.org/sparql?format=json&query=SELECT * WHERE {?s wdt:P345 "${imdbId}". OPTIONAL { ?s wdt:P1258 ?RottenTomatoes_ID. }}`,
                onload: function (response) {
                    const result = JSON.parse(response.responseText);
                    const bindings = result.results.bindings[0];
                    const rottenTomatoesId = bindings && bindings.RottenTomatoes_ID ? bindings.RottenTomatoes_ID.value : "";
                    resolve(rottenTomatoesId);
                },
                onerror: function () {
                    console.error("getRottenTomatoesId: Request Error.");
                    reject("Request Error");
                },
                onabort: function () {
                    console.error("getRottenTomatoesId: Request Aborted.");
                    reject("Request Abort");
                },
                ontimeout: function () {
                    console.error("getRottenTomatoesId: Request Timeout.");
                    reject("Request Timeout");
                },
            });
        });
    }

    function fetchRottenTomatoesData(url) {
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: "GET",
                url: url,
                headers: { "User-Agent": USER_AGENT },
                onload: function (response) {
                    const parser = new DOMParser();
                    const result = parser.parseFromString(response.responseText, "text/html");

                    const ratingDataElement = result.getElementById("media-scorecard-json");
                    const ratingData = JSON.parse(ratingDataElement.textContent);

                    const formatRating = (rawRating) => {
                        const rating = !isNaN(rawRating) ? Number(rawRating) / 10 : 0;
                        // no fractions for 10 and 0
                        return rating === 10 || rating === 0
                            ? rating
                            : rating.toLocaleString(local, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
                    };

                    const formatVoteCount = (rawCount) => {
                        if (!rawCount) return 0;
                        const formattedCount = Number(String(rawCount).replace(/[^\d]/g, "")).toLocaleString(local);
                        return String(rawCount).includes("+") ? `${formattedCount}+` : formattedCount;
                    };

                    const criticRating = formatRating(ratingData.criticsScore.score);
                    const userRating = formatRating(ratingData.audienceScore.score);
                    const criticVoteCount = criticRating !== 0 ? formatVoteCount(ratingData.criticsScore.ratingCount) : 0;
                    const userVoteCount = userRating !== 0 ? formatVoteCount(ratingData.audienceScore.bandedRatingCount) : 0;

                    console.log(
                        "Critic rating: " +
                            criticRating +
                            ", User rating: " +
                            userRating +
                            ", criticVoteCount: " +
                            criticVoteCount +
                            ", userVoteCount: " +
                            userVoteCount +
                            ", Url: " +
                            url
                    );

                    resolve({
                        source: "RottenTomatoes",
                        criticRating,
                        userRating,
                        criticVoteCount,
                        userVoteCount,
                        url,
                    });
                },
                onerror: function () {
                    console.log("getRottenTomatoesRatings: Request Error.");
                    reject("Request Error");
                },
                onabort: function () {
                    console.log("getRottenTomatoesRatings: Request is aborted.");
                    reject("Request Aborted");
                },
                ontimeout: function () {
                    console.log("getRottenTomatoesRatings: Request timed out.");
                    reject("Request Timed Out");
                },
            });
        });
    }

    rottenTomatoesDataPromise = (async () => {
        const rottenTomatoesId = await getRottenTomatoesId();
        const url = encodeURI(`https://www.rottentomatoes.com/${rottenTomatoesId}`);

        if (rottenTomatoesId !== "") {
            return fetchRottenTomatoesData(url);
        } else {
            return Promise.resolve({
                source: "RottenTomatoes",
                criticRating: initialValue,
                userRating: initialValue,
                criticVoteCount: initialValue,
                userVoteCount: initialValue,
                url: null,
            });
        }
    })();

    return rottenTomatoesDataPromise;
}

async function addRottenTomatoesRatingBadge() {
    const configured = await GM_getValue("RottenTomatoes", true);
    if (!configured) return;

    const newRatingBadge = createRatingBadge("RottenTomatoes");

    // if the template for the rating badge was not created, it already exists
    if (!newRatingBadge) return;

    const ratingData = await getRottenTomatoesData();

    // Copy ratingData to avoid modifying the original object
    let finalRatingData = { ...ratingData };

    // Check if ratingData or ratingData.url is undefined and provide a default value
    if (!ratingData?.url) {
        const searchTitle = getOriginalTitle() ?? getMainTitle();
        const defaultUrl = `https://www.rottentomatoes.com/search?search=${searchTitle}`;
        finalRatingData.url = defaultUrl;
    }

    updateRatingBadge(newRatingBadge, finalRatingData);
}

// -----------------------------------------------------------------------------------------------------
// MyAnimeList
// -----------------------------------------------------------------------------------------------------
// wikidata solution inspired by IMDb Scout Mod

let myAnimeListDataByImdbIdPromise = null;
async function getMyAnimeListDataByImdbId() {
    // to execute only once
    if (myAnimeListDataByImdbIdPromise) return myAnimeListDataByImdbIdPromise;

    // only if genre is anime
    const genreAnime = document.querySelector(".ipc-chip-list__scroller")?.textContent.includes("Anime");
    if (!genreAnime) return Promise.resolve(null);

    // only if enabled in settings
    const configured = await GM_getValue("MyAnimeList", true);
    if (!configured) return Promise.resolve(null);

    function getAnimeID() {
        const url = `https://query.wikidata.org/sparql?format=json&query=SELECT * WHERE {?s wdt:P345 "${imdbId}". OPTIONAL {?s wdt:P4086 ?MyAnimeList_ID.} OPTIONAL {?s wdt:P8729 ?AniList_ID.}}`;

        return new Promise((resolve) => {
            GM.xmlHttpRequest({
                method: "GET",
                timeout: 10000,
                url: url,
                headers: { "User-Agent": USER_AGENT },
                onload: function (response) {
                    const result = JSON.parse(response.responseText);
                    let myAnimeListId = "";
                    let aniListId = "";

                    if (result.results.bindings[0] !== undefined) {
                        if (result.results.bindings[0].MyAnimeList_ID !== undefined) {
                            myAnimeListId = result.results.bindings[0].MyAnimeList_ID.value;
                        } else {
                            console.log("getMyAnimeListDataByImdbId: No MyAnimeList_ID found on wikidata.org");
                        }
                        if (result.results.bindings[0].AniList_ID !== undefined) {
                            aniListId = result.results.bindings[0].AniList_ID.value;
                        }
                        // console.log("getMyAnimeListDataByImdbId: ", result.results);
                        resolve([myAnimeListId, aniListId]);
                    } else {
                        console.log("getMyAnimeListDataByImdbId: No results found on wikidata.org");
                        resolve([myAnimeListId, aniListId]);
                    }
                },
                onerror: function () {
                    console.log("getMyAnimeListDataByImdbId: Request Error.");
                    reject("Request Error");
                },
                onabort: function () {
                    console.log("getMyAnimeListDataByImdbId: Request Abort.");
                    reject("Request Abort");
                },
                ontimeout: function () {
                    console.log("getMyAnimeListDataByImdbId: Request Timeout.");
                    reject("Request Timeout");
                },
            });
        });
    }

    function fetchMyAnimeListData(myAnimeListId) {
        return new Promise((resolve, reject) => {
            const url = "https://api.jikan.moe/v4/anime/" + myAnimeListId;
            GM.xmlHttpRequest({
                method: "GET",
                timeout: 10000,
                url: url,
                headers: { "User-Agent": USER_AGENT },
                onload: function (response) {
                    if (response.status === 200) {
                        const result = JSON.parse(response.responseText);
                        const rating = result.data.score;
                        if (!isNaN(rating) && rating > 0) {
                            console.log("getMyAnimeListDataByImdbId: ", result.data.mal_id, result);

                            resolve({
                                source: "MyAnimeList",
                                rating: Number(rating).toLocaleString(local, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
                                voteCount: result.data.scored_by?.toLocaleString(local),
                                url: result.data.url,
                            });
                        } else {
                            reject("Invalid rating");
                        }
                    } else {
                        console.log("MyAnimeList: HTTP Error: " + response.status);
                        reject("HTTP Error");
                    }
                },
                onerror: function () {
                    console.log("MyAnimeList: Request Error.");
                    reject("Request Error");
                },
                onabort: function () {
                    console.log("MyAnimeList: Request is aborted.");
                    reject("Request Aborted");
                },
                ontimeout: function () {
                    console.log("MyAnimeList: Request timed out.");
                    reject("Request Timeout");
                },
            });
        });
    }

    myAnimeListDataByImdbIdPromise = (async () => {
        const id = await getAnimeID();
        const myAnimeListId = id[0];
        const aniListId = id[1];

        if (myAnimeListId !== "") {
            return fetchMyAnimeListData(myAnimeListId);
        } else {
            return null;
        }
    })();

    return myAnimeListDataByImdbIdPromise;
}

let myAnimeListDataByTitlePromise = null;
async function getMyAnimeListDataByTitle() {
    // to execute only once
    if (myAnimeListDataByTitlePromise) return myAnimeListDataByTitlePromise;

    const titleElement = getTitleElement();
    if (!titleElement) return Promise.resolve(null);

    // only if genre is anime
    const genreAnime = document.querySelector(".ipc-chip-list__scroller")?.textContent.includes("Anime");
    if (!genreAnime) return Promise.resolve(null);

    const mainTitle = getMainTitle();
    const originalTitle = getOriginalTitle();

    // get the year of release
    const metaData = titleElement?.parentElement?.querySelector("ul");
    const metaItems = metaData?.querySelectorAll("li");
    // If the text content type is an integer, it is a tv show, otherwise it is a movie.
    const type = isNaN(metaItems?.[0]?.textContent) ? "tv" : "movie";
    const yearIndex = type === "tv" ? 1 : 0;
    const yearText = metaItems?.[yearIndex]?.textContent;
    // Extract the first number up to the non-number sign and convert it into a integer (2018-2020)
    const year = parseInt(yearText);

    async function fetchAllPages(searchTitle) {
        let currentPage = 1;
        let allResults = [];
        const maxRetries = 3;
        const retryDelay = 1000;
        const normalizeSearchString = (string) => {
            return (
                string
                    .replace(/Ô/g, "oo")
                    .replace(/ô/g, "ou")
                    .toLowerCase()
                    .replace(/û/g, "uu")
                    // Removes all characters that are not letters, numbers or spaces
                    .replace(/[^a-z0-9\s]/g, " ")
                    // Replaces several consecutive spaces with a single space
                    .replace(/\s+/g, " ")
                    .trim()
            );
        };

        async function fetchWithRetry(url, retries = 0) {
            try {
                const response = await fetch(url);
                if (response.status === 429) {
                    if (retries < maxRetries) {
                        console.log(`Rate limited. Retrying in ${retryDelay / 1000} seconds...`);
                        await new Promise((resolve) => setTimeout(resolve, retryDelay));
                        return fetchWithRetry(url, retries + 1);
                    } else {
                        throw new Error("Max retries reached. Please try again later.");
                    }
                }
                return response;
            } catch (error) {
                console.error("Fetch error:", error);
                throw error;
            }
        }

        while (true) {
            try {
                const response = await fetchWithRetry(
                    `https://api.jikan.moe/v4/anime?q=${encodeURIComponent(searchTitle)}&type=${type}&page=${currentPage}`
                );
                const data = await response.json();
                allResults = allResults.concat(data.data);
                if (!data.pagination.has_next_page) break;
                currentPage++;
            } catch (error) {
                console.error("Error fetching data:", error);
                break;
            }
        }

        // for debug information
        // console.log(searchTitle, year, type, allResults);

        const result = allResults.find((anime, index) => {
            const normalizedSearchTitle = normalizeSearchString(searchTitle);
            const normalizedAnimeTitle = normalizeSearchString(anime.title);
            console.log(`Normalized Search Title: "${normalizedSearchTitle}"`);
            console.log(`Normalized Anime Title: "${normalizedAnimeTitle}"`);

            const titleMatch = normalizedAnimeTitle.includes(normalizedSearchTitle);
            const yearMatch = anime.aired.prop.from.year === year;
            if (titleMatch && yearMatch) {
                console.log(`✅ Title and year match for "${anime.title}"`);
                return true;
            }

            if (!titleMatch && anime.title_english) {
                const normalizedEnglishTitle = normalizeSearchString(anime.title_english);
                const englishTitleMatch = normalizedEnglishTitle.includes(normalizedSearchTitle);
                console.log(`✅ English title match for "${anime.title_english}": ${englishTitleMatch}`);

                if (englishTitleMatch && yearMatch) {
                    console.log(`🎉 English title and year match for "${anime.title_english}"`);
                    return true;
                }
            }

            if (!titleMatch && anime.title_synonyms && anime.title_synonyms.length > 0) {
                console.log(`Checking synonyms for anime[${index}] - ${anime.title}, Synonyms: ${anime.title_synonyms}`);

                const synonymMatch = anime.title_synonyms.some((synonym) =>
                    normalizeSearchString(synonym).includes(normalizedSearchTitle)
                );

                console.log(`✅ Synonym match for anime[${index}] - ${anime.title}: ${synonymMatch}`);

                if (synonymMatch && yearMatch) {
                    console.log(`🎉 Synonym and year match for "${anime.title}"`);
                    return true;
                }
            }
            console.log(`❌ No match found for "${anime.title}"`);

            return false;
        });
        return result;
    }

    async function getAnimeData() {
        try {
            let result = await fetchAllPages(mainTitle);

            if (!result && originalTitle) {
                console.log(
                    `getMyAnimeListDataByTitle: No results found for "${mainTitle}", retrying with originalTitle "${originalTitle}"`
                );
                result = await fetchAllPages(originalTitle);
            }

            if (result) {
                console.log("getMyAnimeListDataByTitle: ", result);
                return result;
            } else {
                console.log("No results found for either title.");
                return null;
            }
        } catch (error) {
            console.error("Error retrieving data:", error);
            return null;
        }
    }

    myAnimeListDataByTitlePromise = (async () => {
        const anime = await getAnimeData();

        if (anime) {
            const data = {
                source: "MyAnimeList",
                rating: Number(anime.score).toLocaleString(local, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
                voteCount: anime.scored_by?.toLocaleString(local),
                url: anime.url,
            };

            // console.log("myAnimeListDataByTitlePromise: ", data);

            return data;
        } else {
            console.log("No anime data found.");
            return Promise.resolve({
                source: "MyAnimeList",
                rating: initialValue,
                voteCount: initialValue,
                url: null,
            });
        }
    })();

    return myAnimeListDataByTitlePromise;
}

async function addMyAnimeListRatingBadge() {
    // only if genre is anime
    const genreAnime = document.querySelector(".ipc-chip-list__scroller")?.textContent.includes("Anime");
    if (!genreAnime) return;

    // only if enabled in settings
    const configured = await GM_getValue("MyAnimeList", true);
    if (!configured) return;

    const newRatingBadge = createRatingBadge("MyAnimeList");

    // if the template for the rating badge was not created, it already exists
    if (!newRatingBadge) return;

    let ratingData = await getMyAnimeListDataByImdbId();
    if (ratingData === null) {
        ratingData = await getMyAnimeListDataByTitle();
    }

    // Copy ratingData to avoid modifying the original object
    let finalRatingData = { ...ratingData };

    // Check if ratingData or ratingData.url is undefined and provide a default value
    if (!ratingData?.url) {
        const searchTitle = getOriginalTitle() ?? getMainTitle();
        const defaultUrl = `https://myanimelist.net/anime.php?q=${searchTitle}`;
        finalRatingData.url = defaultUrl;
    }

    updateRatingBadge(newRatingBadge, finalRatingData);
}

// -----------------------------------------------------------------------------------------------------

let metadataAsText = "";
function collectMetadataForClipboard() {
    let title = document.querySelector("span.hero__primary-text")?.textContent;
    let genres = document.querySelector("div[data-testid='interests'] div.ipc-chip-list__scroller")?.childNodes;
    let additionalMetadataRuntime = document
        .querySelector('[data-testid="hero__pageTitle"]')
        ?.parentElement?.querySelector("ul li:last-of-type");
    let additionalMetadata = document.querySelector('[data-testid="hero__pageTitle"]')?.parentElement?.querySelectorAll("ul > li");

    // if click listener does not exist
    if (!document.querySelector(".collectMetadataForClipboardListener") && title) {
        if (genres && additionalMetadata) {
            if (metadataAsText === "") {
                // add title
                metadataAsText += title + " | ";
                // collect additional metadata
                for (let element of additionalMetadata) metadataAsText += element.textContent + " | ";
                // collect genres
                let iteration = genres?.length;
                for (let genre of genres) {
                    metadataAsText += genre.textContent;

                    // append "," as long as not last iteration
                    if (--iteration) metadataAsText += ", ";
                }
            }

            additionalMetadataRuntime.style.cursor = "pointer";
            additionalMetadataRuntime.addEventListener("click", function () {
                console.log("test");
                navigator.clipboard.writeText(metadataAsText);
            });

            // to know if click listener is still there
            additionalMetadataRuntime.classList.add("collectMetadataForClipboardListener");
        }
    }
}

// Configuration Modal
function configurationMenu() {
    GM_addStyle(`
    .modal-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100vw;
        height: 100vh;
        background-color: rgba(0, 0, 0, 0.6) !important;
        z-index: 9998;
        transition: background-color 0.5s ease;
    }
    
    .modal {
        font-family: var(--ipt-font-family);
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 300px;
        padding: 20px;
        background-color: #fff;
        border-radius: 10px;
        box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
        z-index: 9999;
        opacity: 0;
        transition: opacity 0.5s ease;
    }
    
    .modal-title {
        margin-bottom: 20px;
        font-size: 16px;
        font-weight: bold;
    }
    
    .checkbox-label {
        display: block;
        margin-bottom: 10px;
    }
    
    .close-button {
        display: block;
        margin: 20px auto 0;
    }
    `);

    // Darken background
    const overlay = document.createElement("div");
    overlay.className = "modal-overlay";
    overlay.style.backgroundColor = "rgba(0, 0, 0, 0)";
    setTimeout(() => {
        overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
    }, 50);

    // Create modal
    const modal = document.createElement("div");
    modal.className = "modal";
    setTimeout(() => {
        modal.style.opacity = "1";
    }, 50);

    // Title of the modal
    const title = document.createElement("h3");
    title.innerText = "Which ratings should be displayed?";
    title.className = "modal-title";
    modal.appendChild(title);

    // Add checkboxes
    ratingSourceOptions.forEach((ratingSource) => {
        const label = document.createElement("label");
        label.className = "checkbox-label";

        const checkbox = document.createElement("input");
        checkbox.type = "checkbox";
        checkbox.checked = GM_getValue(ratingSource.replace(/\s/g, ""), true);

        checkbox.addEventListener("change", () => {
            GM_setValue(ratingSource.replace(/\s/g, ""), checkbox.checked);
            if (!checkbox.checked) {
                document.querySelector(`span.rating-bar__base-button[${ratingSource.replace(/\s/g, "")}]`).remove();
            } else {
                // trigger observer to add new badges
                const tempElement = document.createElement("div");
                document.body.appendChild(tempElement);
                document.body.removeChild(tempElement);
            }
        });

        label.appendChild(checkbox);
        label.appendChild(document.createTextNode(` ${ratingSource}`));
        modal.appendChild(label);
    });

    // Add button to close
    const closeButton = document.createElement("button");
    closeButton.innerText = "Close";
    closeButton.className =
        "close-button ipc-btn ipc-btn--half-padding ipc-btn--default-height ipc-btn--core-accent1 ipc-btn--theme-baseAlt ";

    closeButton.addEventListener("click", () => {
        document.body.removeChild(overlay);
        document.body.removeChild(modal);
    });

    modal.appendChild(closeButton);

    // Add modal and overlay to the DOM
    document.body.appendChild(overlay);
    document.body.appendChild(modal);

    // Close modal on click outside
    overlay.addEventListener("click", () => {
        document.body.removeChild(overlay);
        document.body.removeChild(modal);
    });
}

// add and keep elements in header container
async function main() {
    // ignore episode view
    if (!document.title.includes('"')) {
        addCss();
        // getTmdbData();
        // getDoubanData();
        // getMetacriticData();
        // getMyAnimeListDataByImdbId();
        // getRottenTomatoesData();

        const observer = new MutationObserver(async () => {
            // ignore video games
            const metadataFirstElement = document
                .querySelector('[data-testid="hero__pageTitle"]')
                ?.parentElement?.querySelector("ul > li");
            if (
                metadataFirstElement &&
                !metadataFirstElement.textContent.toLowerCase().includes("vid") &&
                !metadataFirstElement.textContent.includes("वीडियो गेम")
            ) {
                addCss();
                await addMyAnimeListRatingBadge();
                await addRottenTomatoesRatingBadge();
                await addMetacriticRatingBadge();
                await addDoubanRatingBadge();
                await addTmdbRatingBadge();

                collectMetadataForClipboard();
            }
        });

        observer.observe(document.documentElement, { childList: true, subtree: true });
    }
}

// -----------------------------------------------------------------------------------------------------
// Main
// -----------------------------------------------------------------------------------------------------

main();
// GM_setValue("authorsMode", true);