CCCPaste

Jellyfin IMDb ratings on homescreen

/*

Script to get IMDb rating next to the release year under poster on cards in Jellyfin
Inspired by this post: https://www.reddit.com/r/jellyfin/comments/1qn68yy/jellyfin_ratings_setup_complete/
Disclaimer: Everything apart from code copied from the above script was written by my AI slaves.

1. Install Jellyfin JavaScript Injector
   https://github.com/n00bcodr/Jellyfin-JavaScript-Injector
 
2. Restart Jellyfin after installing the plugin.

3. Create an account on https://api.mdblist.com/ and generate an API key
   You will get 1K free requests per day. A $1.80 Patreon sub gives you 10K.
   If you show a lot of cards, 1K won't be enough, but you can set cache below.
   cURL command to check your rate limit info:
   $ curl "https://api.mdblist.com/user?apikey=YOUR_MDBLIST_API_KEY_HERE"

4. Go to: Dashboard → Plugins → JavaScript Injector
 
5. Paste the full JavaScript code into the large Custom JavaScript box and save.

*/


(function () {
'use strict';

const API_KEY = 'YOUR_MDBLIST_API_KEY_HERE';
const LOGO = 'https://cdn.jsdelivr.net/gh/Druidblack/jellyfin_ratings@main/logo/IMDb.png';

const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const REQUEST_DELAY_MS = 300;

const ratingCache = new Map();
const queue = [];
const queued = new WeakSet();

let queueRunning = false;

const visibleObserver = new IntersectionObserver(entries => {
    for (const entry of entries) {
        if (!entry.isIntersecting) continue;

        visibleObserver.unobserve(entry.target);
        enqueue(entry.target);
    }
}, {
    root: null,
    rootMargin: '600px 0px',
    threshold: 0.01
});

function scan(root = document) {
    root.querySelectorAll('.card[data-id]:not([data-imdb-done])').forEach(observeCard);
    root.querySelectorAll('.listItem[data-id]:not([data-imdb-done])').forEach(observeListItem);
}

function observeCard(card) {
    if (!['Movie', 'Series'].includes(card.dataset.type)) return;

    const target =
        card.querySelector('.cardText-secondary') ||
        card.querySelector('.cardText-first');

    if (!target) return;

    visibleObserver.observe(card);
}

function observeListItem(item) {
    if (!['Movie', 'Series'].includes(item.dataset.type)) return;

    const target = item.querySelector('.listItemBodyText');

    if (!target) return;

    visibleObserver.observe(item);
}

function enqueue(el) {
    if (el.dataset.imdbDone) return;
    if (queued.has(el)) return;

    queued.add(el);
    queue.push(el);

    runQueue();
}

function runQueue() {
    if (queueRunning) return;

    queueRunning = true;
    processNext();
}

function processNext() {
    const el = queue.shift();

    if (!el) {
        queueRunning = false;
        return;
    }

    processElement(el)
        .finally(() => {
            setTimeout(processNext, REQUEST_DELAY_MS);
        });
}

function processElement(el) {
    if (el.dataset.imdbDone) return Promise.resolve();

    const isList = el.classList.contains('listItem');

    let target;

    if (isList) {
        target = el.querySelector('.listItemBodyText');
    } else {
        target =
            el.querySelector('.cardText-secondary') ||
            el.querySelector('.cardText-first');
    }

    if (!target) return Promise.resolve();

    el.dataset.imdbDone = '1';

    return injectRating(el, target);
}

function injectRating(el, target) {

    const row = document.createElement('span');

    row.className = 'imdb-home-rating';

    row.style.cssText =
        'display:inline-flex;align-items:center;gap:0px;font-size:inherit;margin-left:8px;vertical-align:middle;color:inherit';

    target.appendChild(row);

    return ApiClient
        .getItem(ApiClient.getCurrentUserId(), el.dataset.id)

        .then(item => {

            if (!item || !item.ProviderIds) return null;

            const type =
                item.Type === 'Series'
                    ? 'show'
                    : 'movie';

            return getRating(type, item.ProviderIds);
        })

        .then(rating => {

            if (!rating) {
                row.remove();
                return;
            }

            row.innerHTML = '';

            const logo = document.createElement('span');

            logo.title = 'IMDb: ' + rating;

            logo.style.cssText =
                'display:inline-block;' +
                'height:1.15em;' +
                'width:2.6em;' +
                'background-color:currentColor;' +
                'mask:url("' + LOGO + '") center / contain no-repeat;' +
                '-webkit-mask:url("' + LOGO + '") center / contain no-repeat;' +
                'vertical-align:middle;' +
                'opacity:.55';

            const value = document.createElement('span');

            value.textContent = rating;
            value.style.color = 'inherit';

            row.append(
                logo,
                value
            );
        })

        .catch(() => {
            row.remove();
        });
}

function getRating(type, providerIds) {

    const tmdb = providerIds.Tmdb;
    const imdb = providerIds.Imdb;
    const tvdb = providerIds.Tvdb;

    const key =
        type +
        ':' +
        (tmdb || imdb || tvdb);

    if (ratingCache.has(key)) {
        return Promise.resolve(
            ratingCache.get(key)
        );
    }

    const cached =
        getCachedRating(key);

    if (cached !== undefined) {

        ratingCache.set(
            key,
            cached
        );

        return Promise.resolve(
            cached
        );
    }

    const urls = [];

    if (tmdb) {
        urls.push(
            `https://api.mdblist.com/tmdb/${type}/${tmdb}?apikey=${API_KEY}`
        );
    }

    if (imdb) {
        urls.push(
            `https://api.mdblist.com/imdb/${type}/${imdb}?apikey=${API_KEY}`
        );
    }

    if (tvdb) {
        urls.push(
            `https://api.mdblist.com/tvdb/${type}/${tvdb}?apikey=${API_KEY}`
        );
    }

    return tryUrls(
        urls,
        key
    );
}

function tryUrls(urls, key) {

    if (!urls.length) {

        ratingCache.set(
            key,
            null
        );

        setCachedRating(
            key,
            null
        );

        return Promise.resolve(
            null
        );
    }

    const url =
        urls.shift();

    return fetch(url)

        .then(r =>
            r.ok
                ? r.json()
                : null
        )

        .then(j => {

            const imdb =
                j &&
                Array.isArray(j.ratings)

                    ? j.ratings.find(
                        x =>
                            String(
                                x.source || ''
                            ).toLowerCase()
                            === 'imdb'
                    )

                    : null;

            const value =
                imdb &&
                imdb.value != null

                    ? Number(
                        imdb.value
                    ).toFixed(1)

                    : null;

            if (value) {

                ratingCache.set(
                    key,
                    value
                );

                setCachedRating(
                    key,
                    value
                );

                return value;
            }

            return tryUrls(
                urls,
                key
            );
        })

        .catch(() =>
            tryUrls(
                urls,
                key
            )
        );
}

function getCachedRating(key) {

    const raw =
        localStorage.getItem(
            'imdb-rating:' + key
        );

    if (!raw)
        return undefined;

    try {

        const cached =
            JSON.parse(raw);

        if (
            !cached ||
            typeof cached !== 'object'
        )
            return undefined;

        if (
            Date.now()
            - cached.time
            > CACHE_TTL_MS
        )
            return undefined;

        return cached.value;

    } catch {

        return undefined;
    }
}

function setCachedRating(
    key,
    value
) {
    try {

        localStorage.setItem(
            'imdb-rating:' + key,

            JSON.stringify({
                time:
                    Date.now(),

                value
            })
        );

    } catch {}
}

new MutationObserver(
    mutations => {

        for (
            const mutation
            of mutations
        ) {

            for (
                const node
                of mutation.addedNodes
            ) {

                if (
                    node.nodeType !== 1
                )
                    continue;

                scan(node);
            }
        }
    }
)

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

setTimeout(
    scan,
    1000
);

setTimeout(
    scan,
    3000
);

setTimeout(
    scan,
    6000
);

window.addEventListener(
    'hashchange',
    () =>
        setTimeout(
            scan,
            1000
        )
);

window.addEventListener(
    'popstate',
    () =>
        setTimeout(
            scan,
            1000
        )
);

document.addEventListener(
    'viewshow',
    () =>
        setTimeout(
            scan,
            1000
        )
);

setInterval(
    scan,
    3000
);

})();