MAL Rating Hover Provider

Shows MAL rating on hover. Features: Gold Badge (>8), Smart Caching, Color Grading, and Menu option to Clear Cache.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         MAL Rating Hover Provider
// @namespace    http://github.com/quantavil
// @version      3.2
// @description  Shows MAL rating on hover. Features: Gold Badge (>8), Smart Caching, Color Grading, and Menu option to Clear Cache.
// @author       Quantavil 

// --- Domain Wildcards ---
// @match        *://123animes.*/*
// @match        *://9anime.*/*
// @match        *://anicore.*/*
// @match        *://anidap.*/*
// @match        *://anigo.*/*
// @match        *://anihq.*/*
// @match        *://anikai.*/*
// @match        *://anikototv.*/*
// @match        *://animedefenders.*/*
// @match        *://animegers.*/*
// @match        *://animeheaven.*/*
// @match        *://animekai.*/*
// @match        *://animeland.*/*
// @match        *://animelok.*/*
// @match        *://animelon.*/*
// @match        *://animenosub.*/*
// @match        *://animepahe.*/*
// @match        *://animestar.*/*
// @match        *://animetsu.*/*
// @match        *://animex.*/*
// @match        *://animeya.*/*
// @match        *://animeyy.*/*
// @match        *://anime.nexus/*
// @match        *://anime.uniquestream.*/*
// @match        *://anitaro.*/*
// @match        *://anitaku.*/*
// @match        *://aniwave.*/*
// @match        *://aniworld.*/*
// @match        *://gogoanime.*/*
// @match        *://hianime.*/*
// @match        *://justanime.*/*
// @match        *://kawaiifu.*/*
// @match        *://kimoitv.*/*
// @match        *://miruro.*/*
// @match        *://ramenflix.*/*
// @match        *://rivestream.*/*
// @match        *://senshi.*/*
// @match        *://wcostream.*/*
// @match        *://yugenanime.*/*

// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        CACHE_PREFIX: 'mal_v5_',
        CACHE_EXPIRY_SUCCESS: 14 * 24 * 60 * 60 * 1000, // 14 Days
        CACHE_EXPIRY_ERROR: 12 * 60 * 60 * 1000,        // 12 Hours
        DEBOUNCE_DELAY: 200,
        LONG_PRESS_DELAY: 200,
        API_INTERVAL: 350,
        MATCH_THRESHOLD: 0.5,
        SELECTORS: {
            ITEM: `
                .flw-item, .film_list-wrap > div, .poster-card, .f-item, .aitem, .anime-item, .ep-item, .anicard,
                .bsx, .bs, .item, .coverListItem,
                .content-card, .new-card-animate, .pe-episode-card, .news-item, .TPostMv, .gallery, .mini-previews,
                .video-block, .card, 
                a[href*="/series/"], a[data-discover], a[href*="/watch/"], .anime-card,
                .vod-item, a[href*="/anime/info/"], .chart2g, .items li,
                .snap-center, [class*="MovieCardSmall"], article.group, app-anime-item,
                div:has(.item-title)
            `,
            TITLE: `
                .film-name, .dynamic-name, .film-name a,
                .title, .d-title, .anime-name, .name, .mv-namevn,
                h2, h3, h5, .content-title, .new-card-title, .pe-title, .news-item-title, .Title,
                .line-clamp-2, .line-clamp-1, .item-title,
                .charttitle2g a
            `
        }
    };

    let hoverTimeout;
    let longPressTimeout;
    let isTouchInteraction = false;
    const KEY_REGEX = /[^a-z0-9]/g;

    // === Request Queue ===
    const requestQueue = {
        queue: [],
        processing: false,
        currentJob: null,

        add(title, cleanT, callback) {
            if ((this.currentJob && this.currentJob.cleanT === cleanT) ||
                this.queue.some(q => q.cleanT === cleanT)) {
                return;
            }
            this.queue.push({ title, cleanT, callback, retries: 0 });
            this.process();
        },
        async process() {
            if (this.processing || this.queue.length === 0) return;
            this.processing = true;

            const job = this.queue.shift();
            this.currentJob = job; // Set current job
            const { cleanT, callback, retries } = job;

            try {
                const data = await fetchMalData(cleanT);

                if (data && data.status === 429) {
                    if (retries < 2) {
                        job.retries++;
                        this.queue.unshift(job);
                        this.currentJob = null; // Clear current before timeout return
                        setTimeout(() => {
                            this.processing = false;
                            this.process();
                        }, 2500);
                        return;
                    } else {
                        callback({ error: true, temp: true });
                    }
                } else {
                    if (!data.temp && !data.error) {
                        const cacheKey = cleanT.toLowerCase().replace(KEY_REGEX, '');
                        // Check if key is valid before caching
                        if (cacheKey.length > 0) setCache(cacheKey, data);
                    }
                    callback(data);
                }
            } catch (e) {
                console.error(e);
                callback({ error: true, temp: true });
            }

            this.currentJob = null; // Clear current job
            setTimeout(() => {
                this.processing = false;
                this.process();
            }, CONFIG.API_INTERVAL);
        }
    };

    // === CSS Styles ===
    GM_addStyle(`
        .mal-container-rel { position: relative !important; }
        
        .mal-rating-badge {
            position: absolute;
            top: 6px; right: 6px;
            background: rgba(18, 20, 32, 0.96);
            backdrop-filter: blur(4px);
            border: 1px solid rgba(255, 255, 255, 0.15);
            color: #e0e0e0;
            padding: 4px 8px;
            border-radius: 6px;
            font-family: sans-serif;
            font-size: 11px;
            font-weight: 700;
            z-index: 9999;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            cursor: pointer;
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            min-width: 40px;
            opacity: 0;
            transform: translateY(-4px);
            animation: malFadeIn 0.2s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
            transition: all 0.2s ease;
            pointer-events: auto; 
            user-select: none;
        }

        .mal-rating-badge:hover, .mal-rating-badge.mobile-active {
            transform: translateY(0) scale(1.05);
            background: rgba(25, 28, 45, 1);
            box-shadow: 0 6px 16px rgba(0,0,0,0.7);
            z-index: 10000;
        }

        .mal-rating-badge .score { 
            font-size: 13px; color: #fff; display: flex; align-items: center; gap: 2px;
        }
        .mal-rating-badge .score::before { content: '★'; font-size: 10px; opacity: 0.8; }
        .mal-rating-badge .members { font-size: 9px; color: #9aa0b0; font-weight: 500; }

        /* Color Grading */
        
        /* 8+ : Golden Yellow */
        .mal-rating-badge.score-gold { border-color: rgba(234, 179, 8, 0.5); background: rgba(30, 25, 10, 0.95); box-shadow: 0 0 8px rgba(234, 179, 8, 0.3); }
        .mal-rating-badge.score-gold .score { color: #facc15; text-shadow: 0 0 5px rgba(250, 204, 21, 0.4); } 
        .mal-rating-badge.score-gold .score::before { color: #fbbf24; font-size: 14px; } /* Bigger star */

        /* 7-8 : Green */
        .mal-rating-badge.score-green { border-color: rgba(74, 222, 128, 0.4); }
        .mal-rating-badge.score-green .score { color: #86efac; } .mal-rating-badge.score-green .score::before { color: #4ade80; }

        /* 6-7 : Orange */
        .mal-rating-badge.score-orange { border-color: rgba(251, 146, 60, 0.4); }
        .mal-rating-badge.score-orange .score { color: #fdba74; } .mal-rating-badge.score-orange .score::before { color: #fb923c; }

        /* 5-6 : Red */
        .mal-rating-badge.score-red { border-color: rgba(248, 113, 113, 0.4); }
        .mal-rating-badge.score-red .score { color: #fca5a5; } .mal-rating-badge.score-red .score::before { color: #f87171; }
        
        /* <5 : Purple */
        .mal-rating-badge.score-purple { border-color: rgba(192, 132, 252, 0.4); background: rgba(20, 10, 30, 0.95); }
        .mal-rating-badge.score-purple .score { color: #d8b4fe; } .mal-rating-badge.score-purple .score::before { color: #c084fc; }

        .mal-rating-badge.loading { 
            background: rgba(0, 0, 0, 0.8); 
            min-width: unset; 
            padding: 6px 10px;
            pointer-events: none;
        }
        .mal-rating-badge.error { background: rgba(80, 80, 80, 0.9); color: #bbb; border-color: rgba(255,255,255,0.1); }

        /* Toast Notification */
        #mal-toast {
            position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
            background: rgba(30,30,30,0.9); color: white; padding: 10px 20px;
            border-radius: 8px; font-family: sans-serif; font-size: 13px;
            box-shadow: 0 4px 10px rgba(0,0,0,0.5); z-index: 9999999;
            opacity: 0; transition: opacity 0.3s ease; pointer-events: none;
        }
        #mal-toast.show { opacity: 1; }

        @media (pointer: coarse) { .mal-rating-badge { padding: 6px 10px; top: 8px; right: 8px; } }
        @keyframes malFadeIn { to { opacity: 1; transform: translateY(0); } }
        @keyframes malPulse { from { opacity: 0.5; } to { opacity: 1; } }
    `);

    // === Logic ===
    function getCache(key) {
        const fullKey = CONFIG.CACHE_PREFIX + key;
        const data = GM_getValue(fullKey);
        if (!data) return null;

        const expiryDuration = data.expiryDuration || CONFIG.CACHE_EXPIRY_SUCCESS;

        if (Date.now() - data.timestamp > expiryDuration) {
            GM_deleteValue(fullKey);
            return null;
        }
        return data.payload;
    }

    function setCache(key, payload) {
        // Double check we aren't caching errors
        if (payload.temp || (payload.error && !payload.found)) return;

        const expiryDuration = payload.found ? CONFIG.CACHE_EXPIRY_SUCCESS : CONFIG.CACHE_EXPIRY_ERROR;
        GM_setValue(CONFIG.CACHE_PREFIX + key, {
            payload,
            timestamp: Date.now(),
            expiryDuration: expiryDuration
        });
    }

    // === Menu Command: Clear Cache ===
    function showToast(msg) {
        let toast = document.getElementById('mal-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'mal-toast';
            document.body.appendChild(toast);
        }
        toast.innerText = msg;
        toast.classList.add('show');
        setTimeout(() => toast.classList.remove('show'), 3000);
    }

    function clearMalCache() {
        const keys = GM_listValues();
        let count = 0;
        keys.forEach(key => {
            if (key.startsWith(CONFIG.CACHE_PREFIX)) {
                GM_deleteValue(key);
                count++;
            }
        });
        showToast(`🗑️ Cleared ${count} items from MAL Cache.`);
    }

    GM_registerMenuCommand("🗑️ Clear MAL Cache", clearMalCache);

    function formatMembers(num) {
        if (!num) return '0';
        return num >= 1e6 ? (num / 1e6).toFixed(1) + 'M' : num >= 1e3 ? (num / 1e3).toFixed(1) + 'K' : num;
    }

    function getSimilarity(s1, s2) {
        const len1 = s1.length, len2 = s2.length;
        const maxDist = Math.max(len1, len2);
        if (len1 === 0 || len2 === 0) return maxDist === 0 ? 1 : 0;
        const row = Array(len1 + 1).fill(0).map((_, i) => i);
        for (let i = 1; i <= len2; i++) {
            let prev = i;
            for (let j = 1; j <= len1; j++) {
                const val = (s2[i - 1] === s1[j - 1]) ? row[j - 1] : Math.min(row[j - 1] + 1, prev + 1, row[j] + 1);
                row[j - 1] = prev;
                prev = val;
            }
            row[len1] = prev;
        }
        return 1 - (row[len1] / maxDist);
    }

    function cleanTitle(title) {
        let clean = title
            .replace(/^Title:\s*/i, '') // Remove "Title: " prefix
            .replace(/(\(|\[)\s*(sub|dub|uncensored|tv|bd|blu-ray|4k|hd|special|ova|ona|complete|re-upload).+?(\)|\])/gi, '')
            .replace(/[-:]\s*season\s*\d+/gi, '')
            .replace(/S\d+$/, '')
            .replace(/\s+/g, ' ')
            .trim();

        return clean;
    }

    async function fetchMalData(cleanT) {
        try {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 8000);
            const res = await fetch(`https://api.jikan.moe/v4/anime?q=${encodeURIComponent(cleanT)}&limit=10`, { signal: controller.signal });
            clearTimeout(timeoutId);
            if (res.status === 429) return { status: 429 };

            const json = await res.json();
            const results = json.data || [];

            let bestMatch = null;
            let highestScore = -1;

            if (results.length > 0) {
                const targetLower = cleanT.toLowerCase();
                const targetKey = targetLower.replace(/[^a-z0-9]/g, '');

                // Keywords that imply we are looking for a special type
                const typeKeywords = ['movie', 'special', 'ova', 'film', 'theater'];
                const hasTypeInQuery = typeKeywords.some(k => targetLower.includes(k));

                results.forEach(item => {
                    const titleLower = (item.title || '').toLowerCase();
                    const titleEngLower = (item.title_english || '').toLowerCase();
                    const titleJapLower = (item.title_japanese || '').toLowerCase();

                    const sim1 = getSimilarity(targetLower, titleLower);
                    const sim2 = getSimilarity(targetLower, titleEngLower);
                    const sim3 = getSimilarity(targetLower, titleJapLower);

                    let score = Math.max(sim1, sim2, sim3);

                    // --- Scoring Adjustments ---

                    // 1. Exact Match Bonus
                    // Check strict alphanumeric match to handle "Fullmetal Alchemist: Brotherhood" vs "Fullmetal Alchemist"
                    const itemKey = titleLower.replace(/[^a-z0-9]/g, '');
                    const itemEngKey = titleEngLower.replace(/[^a-z0-9]/g, '');
                    const itemJapKey = titleJapLower.replace(/[^a-z0-9]/g, '');

                    if (targetKey === itemKey || targetKey === itemEngKey || targetKey === itemJapKey) {
                        score += 0.3; // Major boost for exact match
                    } else if ((itemKey.includes(targetKey) && itemKey.length < targetKey.length + 5) ||
                        (itemEngKey.includes(targetKey) && itemEngKey.length < targetKey.length + 5) ||
                        (itemJapKey.includes(targetKey) && itemJapKey.length < targetKey.length + 5)) {
                        score += 0.1; // Minor boost for very close containment
                    }

                    // 2. Type Penalty
                    // If query doesn't ask for Movie/Special, penalize them.
                    const isTypeVariant = ['Movie', 'Special', 'OVA', 'ONA', 'Music'].includes(item.type);
                    if (isTypeVariant && !hasTypeInQuery) {
                        score -= 0.25;
                    }

                    // 3. Popularity Bias as Tie-Breaker
                    // Add tiny score based on log of members to favor popular shows (TV series) over obscure OVAs in ties
                    if (item.members) {
                        score += (Math.log10(item.members) * 0.01);
                    }

                    if (score > highestScore) {
                        highestScore = score;
                        bestMatch = item;
                    }
                });
            }

            // Threshold check (slightly lower threshold due to penalties potentially lowering valid scores, 
            // but the exact match bonus raises them)
            // We use 0.45 as a safe baseline since purely different titles will have scores < 0.3 usually.
            if (bestMatch && highestScore > 0.45) {
                return {
                    found: true,
                    score: bestMatch.score ? bestMatch.score : 'N/A',
                    members: formatMembers(bestMatch.members),
                    url: bestMatch.url,
                };
            } else {
                return { found: false };
            }

        } catch (e) {
            return { error: true, temp: true };
        }
    }

    // === Render Logic ===
    function renderBadge(container, data) {
        const existing = container.querySelector('.mal-rating-badge');
        if (existing) existing.remove();

        // If it's a temp error, don't show badge (or show loading state if you prefer)
        if (data.temp && !data.loading) return;
        if (data.error && !data.found && !data.loading) return; // Silent fail on error

        const badge = document.createElement('div');
        badge.className = 'mal-rating-badge';
        if (isTouchInteraction) badge.classList.add('mobile-active');

        if (data.loading) {
            badge.classList.add('loading');
            badge.innerText = '• • •';
            badge.style.animation = 'malPulse 0.8s infinite alternate';
        } else if (data.found) {
            badge.title = "View on MyAnimeList";
            badge.innerHTML = `<span class="score">${data.score}</span><span class="members">${data.members}</span>`;

            const numScore = parseFloat(data.score);
            if (!isNaN(numScore)) {
                if (numScore >= 8.0) badge.classList.add('score-gold'); // >8 Golden
                else if (numScore >= 7.0) badge.classList.add('score-green'); // 7-8 Green
                else if (numScore >= 6.0) badge.classList.add('score-orange'); // 6-7 Orange
                else if (numScore >= 5.0) badge.classList.add('score-red'); // 5-6 Red
                else badge.classList.add('score-purple'); // <5 Purple
            } else {
                badge.classList.add('score-purple'); // N/A
            }

            badge.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                GM_openInTab(data.url, { active: true });
            }, { once: true });

        } else {
            badge.classList.add('error');
            badge.innerText = '?';
            badge.title = "Not found (Cached 12h)";
        }

        if (!container.classList.contains('mal-container-rel') && window.getComputedStyle(container).position === 'static') {
            container.classList.add('mal-container-rel');
        }

        if (!data.loading) badge.style.animation = '';
        container.appendChild(badge);
    }

    function processItem(item) {
        if (item.querySelector('.mal-rating-badge')) return;

        const titleEl = item.querySelector(CONFIG.SELECTORS.TITLE);
        let title = item.getAttribute('data-title') || item.getAttribute('aria-label');
        if (!title && titleEl) {
            title = titleEl.getAttribute('title') || titleEl.innerText || titleEl.getAttribute('alt');
        }
        if (!title) return;

        const cleanT = cleanTitle(title);
        if (!cleanT || cleanT.length < 2) return;

        const cacheKey = cleanT.toLowerCase().replace(KEY_REGEX, '');
        if (!cacheKey) return;

        const cachedData = getCache(cacheKey);
        if (cachedData) {
            renderBadge(item, cachedData);
            return;
        }

        const interactionId = Date.now() + Math.random().toString();
        item.dataset.malInteraction = interactionId;
        renderBadge(item, { loading: true });

        requestQueue.add(title, cleanT, (data) => {
            if (document.body.contains(item) && item.dataset.malInteraction === interactionId) {
                renderBadge(item, data);
            }
        });
    }

    // === Event Listeners ===
    document.body.addEventListener('mouseover', function (e) {
        if (isTouchInteraction) return;
        const item = e.target.closest(CONFIG.SELECTORS.ITEM);
        if (!item) return;

        clearTimeout(hoverTimeout);
        hoverTimeout = setTimeout(() => {
            processItem(item);
        }, CONFIG.DEBOUNCE_DELAY);
    });

    document.body.addEventListener('mouseout', (e) => {
        if (isTouchInteraction) return;
        const item = e.target.closest(CONFIG.SELECTORS.ITEM);
        if (item) {
            if (item.contains(e.relatedTarget)) return;
            clearTimeout(hoverTimeout);
            const badge = item.querySelector('.mal-rating-badge.loading');
            if (badge) {
                badge.remove();
                delete item.dataset.malInteraction;
            }
        }
    });

    // Mobile Logic
    let touchStartX = 0;
    let touchStartY = 0;
    document.body.addEventListener('touchstart', (e) => {
        const item = e.target.closest(CONFIG.SELECTORS.ITEM);
        if (!item) return;
        isTouchInteraction = true;
        touchStartX = e.touches[0].clientX;
        touchStartY = e.touches[0].clientY;
        longPressTimeout = setTimeout(() => {
            processItem(item);
            if (navigator.vibrate) navigator.vibrate(40);
        }, CONFIG.LONG_PRESS_DELAY);
    }, { passive: true });

    document.body.addEventListener('touchmove', (e) => {
        if (!longPressTimeout) return;
        if (Math.abs(e.touches[0].clientX - touchStartX) > 15 || Math.abs(e.touches[0].clientY - touchStartY) > 15) {
            clearTimeout(longPressTimeout);
            longPressTimeout = null;
        }
    }, { passive: true });

    document.body.addEventListener('touchend', () => {
        if (longPressTimeout) clearTimeout(longPressTimeout);
        setTimeout(() => { isTouchInteraction = false; }, 600);
    });

})();