Netflix IMDB Ratings [fork]

Show IMDB ratings on Netflix

// ==UserScript==
// @name         Netflix IMDB Ratings [fork]
// @version      1.6
// @description  Show IMDB ratings on Netflix
// @author       ioannisioannou16, kraki5525, joeytwiddle
// @match        https://www.netflix.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_getResourceURL
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @grant        GM_openInTab
// @connect      imdb.com
// @connect      www.omdbapi.com
// @resource     customCSS  https://raw.githubusercontent.com/kraki5525/netflix-imdb/master/netflix-imdb.css
// @resource     imdbIcon   https://raw.githubusercontent.com/kraki5525/netflix-imdb/master/imdb-icon.png
// @namespace https://greasyfork.org/users/8615
// ==/UserScript==

(function () {
    "use strict";

    // Original
    //var source = 'imdb';
    // Faster
    var source = 'omdbapi';

    // If you are getting rate limited, then generate your own key from: https://www.omdbapi.com/apikey.aspx
    var omdbApiKey = '3e29acf0';

    GM_addStyle(GM_getResourceText("customCSS"));

    GM_addStyle(`
        /* We should change the link in some way when hovered, so users know they will be clicking to IMDB and not on the Netflix card. */
        .imdb-rating {
            transition: 200ms all;
        }
        .imdb-rating:hover {
            background: #fff2;
        }
        /* It looks a little better if we move the gap otuside the element, rather than inside it */
        .imdb-rating {
            padding: 0 !important;
            margin: 6px 0;
        }
    `);

    var domParser = new DOMParser();

    function GM_xmlhttpRequest_get(url, cb) {
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: function (x) { cb(null, x); },
            onerror: function () { cb("Request to " + url + " failed"); }
        });
    }

    function requestRating(title, cb) {
        if (source === 'imdb') {
            requestRatingImdb(title, cb);
        } else if (source === 'omdbapi') {
            requestRatingOmdbApi(title, cb);
        }
    }

    function requestRatingImdb(title, cb) {
        var searchUrl = "https://www.imdb.com/find?s=tt&q=" + title;
        GM_xmlhttpRequest_get(searchUrl, function (err, searchRes) {
            if (err) return cb(err);
            var searchResParsed = domParser.parseFromString(searchRes.responseText, "text/html");
            var link = searchResParsed.querySelector(".ipc-metadata-list-summary-item__tc > a");
            var titleEndpoint = link && link.getAttribute("href");
            if (!titleEndpoint) return cb(null, {});
            var titleUrl = "https://www.imdb.com" + titleEndpoint;
            GM_xmlhttpRequest_get(titleUrl, function (err, titleRes) {
                if (err) return cb(err);
                var titleResParsed = domParser.parseFromString(titleRes.responseText, "text/html");
                //
                //var score = titleResParsed.querySelector("span[class^='AggregateRatingButton__RatingScore']");
                //var votes = titleResParsed.querySelector("div[class^='AggregateRatingButton__TotalRatingAmount']");
                // kraki5525 method
                //var score = titleResParsed.querySelector("div[data-testid='hero-rating-bar__aggregate-rating__score'] span");
                //var votes = titleResParsed.querySelector("div[data-testid='hero-rating-bar__aggregate-rating__score'] ~ div:not(:empty)");
                // joey method
                var score = titleResParsed.querySelector("*[data-testid='hero-rating-bar__aggregate-rating__score'] > span:nth-child(1)");
                var votes = titleResParsed.querySelector("*[data-testid='hero-rating-bar__aggregate-rating__score'] + div + div");
                //
                if (!score || (!score.textContent)) return cb(null, {})
                cb(null, { score: score.textContent, votes: (votes || {}).textContent || "", url: titleUrl });
            });
        });
    }

    function requestRatingOmdbApi(title, cb) {
        var searchUrl = "http://www.omdbapi.com/?apikey=" + omdbApiKey + "&t=" + encodeURIComponent(title);
        GM_xmlhttpRequest_get(searchUrl, function (err, searchRes) {
            if (err) return cb(err);
            try {
                var data = JSON.parse(searchRes.responseText);
                if (!data.imdbRating || data.imdbRating === 'N/A' || !data.imdbVotes || data.imdbVotes === 'N/A' || !data.imdbID) {
                    console.warn('Some data missing from OMDB API:', data);
                    // Fall back to IMDB
                    return requestRatingImdb(title, cb);
                }
                var score = data.imdbRating;
                var votesNum = Number(String(data.imdbVotes).replace(/,/g, ''));
                var votesShow = (votesNum / 1000).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + 'k';
                var imdbID = data.imdbID;
                var url = "https://www.imdb.com/title/" + imdbID;
                cb(null, { score, votes: votesShow, url });
            } catch (error) {
                console.error('Failed to fetch OMDB API data:', error);
                cb(error);
            }
        });
    }

    var cache = (function () {

        var cacheKey = "netflix-cache";

        var oneDayMs = 86400000;

        function getRandom(start, end) {
            return Math.ceil(Math.random() * (end - start) + start);
        }

        function mergeWithOtherCache(otherCache) {
            Object.keys(otherCache).forEach(function (otherKey) {
                var thisValue = _cache[otherKey];
                var otherValue = otherCache[otherKey];
                if (!thisValue || otherValue.expiration > thisValue.expiration) {
                    _cache[otherKey] = otherValue;
                }
            });
        }

        var listener = GM_addValueChangeListener(cacheKey, function (name, oldV, newV, remote) {
            if (remote) {
                mergeWithOtherCache(JSON.parse(newV));
            }
        });

        var _cache = JSON.parse(GM_getValue(cacheKey) || "{}");

        function isValid(res) {
            return res && (res.expiration - (new Date()).getTime() > 0);
        }

        function get(key) {
            var res = _cache[key];
            if (isValid(res)) return res.value;
        }

        function set(key, value) {
            var valueObj = { value: value, expiration: (new Date()).getTime() + getRandom(60 * oneDayMs, 65 * oneDayMs) };
            _cache[key] = valueObj;
        }

        function removeInvalidEntries() {
            Object.keys(_cache).forEach(function (key) {
                if (!isValid(_cache[key])) {
                    delete _cache[key];
                }
            });
        }

        window.addEventListener("blur", function () {
            removeInvalidEntries();
            GM_setValue(cacheKey, JSON.stringify(_cache));
        });

        window.addEventListener("beforeunload", function () {
            removeInvalidEntries();
            GM_setValue(cacheKey, JSON.stringify(_cache));
            GM_removeValueChangeListener(listener);
        });

        return { get: get, set: set };
    })();

    function getRating(title, cb) {
        var cacheRes = cache.get(title);
        if (!cacheRes || Object.keys(cacheRes).length === 0) {
            requestRating(title, function (err, rating) {
                if (err) {
                    cb(err);
                } else {
                    cache.set(title, rating);
                    cb(null, rating);
                }
            });
        } else {
            cb(null, cacheRes);
        }
    }

    var imdbIconURL = GM_getResourceURL("imdbIcon");

    function getOutputFormatter() {
        var div = document.createElement("div");
        div.classList.add("imdb-rating");
        div.style.cursor = "default";
        div.addEventListener("click", function () { });
        var img = document.createElement("img");
        img.classList.add("imdb-image");
        img.src = imdbIconURL;
        img.style.marginRight = '0.25em';
        div.appendChild(img);
        div.appendChild(document.createElement("div"));
        return function (res) {
            var restDiv = document.createElement("div");
            var rating = res.rating;
            if (res.error) {
                var error = document.createElement("span");
                error.classList.add("imdb-error");
                error.appendChild(document.createTextNode("ERROR"));
                restDiv.appendChild(error);
            } else if (res.loading) {
                var loading = document.createElement("span");
                loading.classList.add("imdb-loading");
                loading.appendChild(document.createTextNode("Fetching..."));
                loading.style.opacity = 0.6;
                restDiv.appendChild(loading);
            } else if (rating && rating.score && rating.votes && rating.url) {
                var score = document.createElement("span");
                score.classList.add("imdb-score");
                //score.appendChild(document.createTextNode(rating.score + "/10"));
                score.appendChild(document.createTextNode(rating.score));
                score.style.fontWeight = 'bold';
                restDiv.appendChild(score);
                var votes = document.createElement("span");
                votes.classList.add("imdb-votes");
                votes.appendChild(document.createTextNode("(" + rating.votes + " votes)"));
                votes.style.opacity = 0.6;
                restDiv.appendChild(votes);
                div.addEventListener('click', function (evt) {
                    GM_openInTab(rating.url, { active: true, insert: true, setParent: true });
                    // If the card has been expanded, clicking the IMDB link will also play the show, which we don't really want!
                    // If the card hasn't been expanded, then clicking this link will also expand the card.
                    // Unfortunately, this doesn't prevent that, even with 'capture=true' below...
                    evt.preventDefault();
                });
                div.style.cursor = "pointer";
            } else {
                var noRating = document.createElement("span");
                noRating.classList.add("imdb-no-rating");
                noRating.appendChild(document.createTextNode("N/A"));
                restDiv.appendChild(noRating);
            }
            div.replaceChild(restDiv, div.querySelector("div"));
            return div;
        }
    }

    function getRatingNode(title) {
        var node = document.createElement("div");
        var outputFormatter = getOutputFormatter();
        node.appendChild(outputFormatter({ loading: true }));
        getRating(title, function (err, rating) {
            if (err) return node.appendChild(outputFormatter({ error: true }));
            node.appendChild(outputFormatter({ rating: rating }));
        });
        return node;
    }

    function findAncestor(el, cls) {
        while (el && !el.classList.contains(cls)) {
            el = el.parentNode;
        }
        return el;
    }

    var rootElement = document.getElementById("appMountPoint");

    if (!rootElement) return;

    function imdbRenderingForExpandedCard(node) {
            var titleNode = node;
            var title = titleNode && titleNode.getAttribute("alt");
            if (!title) return;
            var ratingNode = getRatingNode(title);
            var parentNode = node.parentElement;
            var buttonContainer = parentNode.querySelector(".buttonControls--container");
            if (!buttonContainer || !parentNode) return;
            parentNode.insertBefore(ratingNode, buttonContainer);
    }

    function imdbRenderingForHeroDisplay(node) {
        var titleNode = node.querySelector(".title-logo");
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        var ratingNode = getRatingNode(title);
        ratingNode.style.fontSize = "1.4vw";
        titleNode.parentNode.insertBefore(ratingNode, titleNode.nextSibling);
    }

    function imdbRenderingForOverview(node) {
        var text = node.querySelector(".image-fallback-text");
        var logo = node.querySelector(".logo");
        var titleFromText = text && text.textContent;
        var titleFromImage = logo && logo.getAttribute("alt");
        var title = titleFromText || titleFromImage;
        if (!title) return;
        var meta = node.querySelector(".meta");
        if (!meta) return;
        var ratingNode = getRatingNode(title);
        meta.parentNode.insertBefore(ratingNode, meta.nextSibling);
    }

    function imdbRenderingForMoreLikeThis(node) {
        var titleNode = node.querySelector(".ptrack-content > img");
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        var meta = node.querySelector(".titleCard--metadataWrapper .videoMetadata--container-container");
        if (!meta) return;
        var ratingNode = getRatingNode(title);

        meta.parentNode.insertBefore(ratingNode, meta.nextSibling);
    }

    function imdbRenderingForCard(node) {
        var titleNode = node.querySelector(".previewModal--boxart")
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        var ratingNode = getRatingNode(title);
        ratingNode.classList.add("imdb-overlay");
        // If the show/film has already been viewed, then Netflix might show a progress-bar instead of the info for the show.
        // In that case, the metadatAndControls-info element might not be in the DOM, but we can add to the metadatAndControls element instead.
        // We prefer the metadatAndControls-info element when it is available, so that the iMDB rating appears above other info.
        //
        // BUG: But it gets worse.  Sometimes the metadatAndControls is there, and we add our element, then Netflix goes and removes it, and replaces it with a fresh metadatAndControls element!  I have not fixed that yet.
        var destination = node.querySelector(".previewModal--metadatAndControls-info")
                          || node.querySelector(".previewModal--metadatAndControls");
                          //|| node.querySelector(".videoMetadata--container")?.parentNode;
        if (!destination) return;
        destination.appendChild(ratingNode);
    }

    function cacheTitleRanking(node) {
        var titleNode = node.querySelector(".titleCard-imageWrapper img");
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        getRating(title, function () { });
    }

    var observerCallback = function (mutationsList) {
        for (var i = 0; i < mutationsList.length; i++) {

            var newNodes = mutationsList[i].addedNodes;

            for (var j = 0; j < newNodes.length; j++) {
                var newNode = newNodes[j];
                if (!(newNode instanceof HTMLElement)) continue;

                if (newNode.classList.contains("previewModal--player-titleTreatment-logo")) {
                    imdbRenderingForExpandedCard(newNode);
                    continue;
                }

                if (newNode.classList.contains("previewModal--wrapper")) {
                    imdbRenderingForCard(newNode);
                    continue;
                }

                // Hero Display
                var trailer = newNode.querySelector(".billboard-row");
                if (trailer) {
                    imdbRenderingForHeroDisplay(trailer);
                    continue;
                }

                const moreLikeItems = newNode.querySelectorAll(".moreLikeThis--container .more-like-this-item");
                if (moreLikeItems && moreLikeItems.length > 0) {
                    for (const item of moreLikeItems.entries()) {
                        imdbRenderingForMoreLikeThis(item[1]);
                    }
                    //continue;
                }
            }
        }
    };

    var observer = new MutationObserver(observerCallback);

    var observerConfig = { childList: true, subtree: true };

    observer.observe(document, observerConfig);

    var existingOverview = document.querySelector(".jawBone");
    existingOverview && imdbRenderingForOverview(existingOverview);

    var existingTrailer = document.querySelector(".billboard-row");
    existingTrailer && imdbRenderingForHeroDisplay(existingTrailer);

    // var existingCard = document.querySelector(".previewModal--player-titleTreatment-logo");
    var existingCard = document.querySelector("previewModal--wrapper");
    existingCard && imdbRenderingForCard(existingCard);

    window.addEventListener("beforeunload", function () {
        observer.disconnect();
    });
})();