Jellyfin 海报图添加分辨率标识(Jellyfin-Qualitytags)

Jellyfin 给海报图添加分辨率标识,来源:https://github.com/BobHasNoSoul/Jellyfin-Qualitytags

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Jellyfin 海报图添加分辨率标识(Jellyfin-Qualitytags)
// @namespace    http://tampermonkey.net/
// @version      2025-11-30
// @description  Jellyfin 给海报图添加分辨率标识,来源:https://github.com/BobHasNoSoul/Jellyfin-Qualitytags
// @author       BobHasNoSoul
// @match        *://*/web/*
// @match        *://*/*/web/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    const overlayClass = 'quality-overlay-label';
    const CACHE_VERSION = 'v9';
    const CACHE_KEY = `qualityOverlayCache-${CACHE_VERSION}`;

    const IGNORE_SELECTORS = [
        'html.preload.layout-desktop body.force-scroll.libraryDocument div#reactRoot div.mainAnimatedPages.skinBody div#itemDetailPage.page.libraryPage.itemDetailPage.noSecondaryNavPage.selfBackdropPage.mainAnimatedPage div.detailPageWrapperContainer div.detailPageSecondaryContainer.padded-bottom-page div.detailPageContent div#castCollapsible.verticalSection.detailVerticalSection.emby-scroller-container a.cardImageContainer',
        'html.preload.layout-desktop body.force-scroll.libraryDocument.withSectionTabs.mouseIdle div#reactRoot div.mainAnimatedPages.skinBody div#indexPage.page.homePage.libraryPage.allLibraryPage.backdropPage.pageWithAbsoluteTabs.withTabs.mainAnimatedPage div#homeTab.tabContent.pageTabContent.is-active div.sections.homeSectionsContainer div.verticalSection.MyMedia.emby-scroller-container a.cardImageContainer'
    ];

    const MEDIA_TYPES = new Set(['Movie', 'Episode', 'Series', 'Season']);

    let qualityOverlayCache = JSON.parse(localStorage.getItem(CACHE_KEY)) || {};
    let seenItems = new Set();
    let pendingRequests = new Set();
    let errorCount = 0;
    let currentDelay = 1000;

    const qualityColors = {
        '720p': 'rgba(255, 165, 0, 0.85)',
        '1080p': 'rgba(0, 204, 204, 0.85)',
        SD: 'rgba(150, 150, 150, 0.85)',
        HD: 'rgba(0, 102, 204, 0.85)',
        UHD: 'rgba(0, 153, 51, 0.85)'
    };

    const config = {
        MAX_CONCURRENT_REQUESTS: 9,
        BASE_DELAY: 1000,
        MAX_DELAY: 10000,
        VISIBLE_PRIORITY_DELAY: 200,
        CACHE_TTL: 7 * 24 * 60 * 60 * 1000,
        REQUEST_TIMEOUT: 5000
    };

    const visibilityObserver = new IntersectionObserver(handleIntersection, {
        rootMargin: '300px',
        threshold: 0.01
    });

    let currentUrl = window.location.href;
    let navigationHandlerSetup = false;

    function getUserId() {
        try {
            return (window.ApiClient?._serverInfo?.UserId) || null;
        } catch {
            return null;
        }
    }

    function saveCache() {
        try {
            const now = Date.now();
            for (const [key, entry] of Object.entries(qualityOverlayCache)) {
                if (now - entry.timestamp > config.CACHE_TTL) {
                    delete qualityOverlayCache[key];
                }
            }
            localStorage.setItem(CACHE_KEY, JSON.stringify(qualityOverlayCache));
        } catch (e) {
            console.warn('Failed to save cache', e);
        }
    }

    function createLabel(label) {
        const badge = document.createElement('div');
        badge.textContent = label;
        badge.className = overlayClass;
        badge.style.background = qualityColors[label] || qualityColors.SD;
        badge.style.position = 'absolute';
        badge.style.top = '6px';
        badge.style.left = '6px';
        badge.style.color = 'white';
        badge.style.padding = '2px 6px';
        badge.style.fontSize = '12px';
        badge.style.fontWeight = 'bold';
        badge.style.borderRadius = '4px';
        badge.style.zIndex = '99';
        badge.style.pointerEvents = 'none';
        badge.style.userSelect = 'none';
        return badge;
    }

    function getQuality(mediaStream) {
        if (!mediaStream) return null;
        const height = mediaStream.Height || 0;

        if (height >= 1200) return 'UHD';
        if (height >= 900 && height < 1200) return '1080p';
        if (height >= 500 && height < 900) return '720p';
        if (height > 0 && height < 500) return 'SD';
        return null;
    }

    async function fetchFirstEpisode(userId, seriesId) {
        try {
            const episodeResponse = await ApiClient.ajax({
                type: "GET",
                url: ApiClient.getUrl("/Items", {
                    ParentId: seriesId,
                    IncludeItemTypes: "Episode",
                    Recursive: true,
                    SortBy: "PremiereDate",
                    SortOrder: "Ascending",
                    Limit: 1,
                    userId: userId
                }),
                dataType: "json"
            });

            const episode = episodeResponse.Items?.[0];
            if (!episode?.Id) return null;
            return episode;
        } catch {
            return null;
        }
    }

    async function fetchItemQuality(userId, itemId) {
        if (pendingRequests.has(itemId)) return null;
        pendingRequests.add(itemId);

        try {
            let item;
            try {
                item = await ApiClient.getItem(userId, itemId);
            } catch {
                const url = ApiClient.getUrl(`/Items/${itemId}`, { userId });
                const response = await fetchWithTimeout(url);
                if (!response.ok) throw new Error(`HTTP ${response.status}`);
                item = await response.json();
            }

            if (!item || !MEDIA_TYPES.has(item.Type)) return null;

            let videoStream = null;
            let quality = null;

            if (item.Type === "Series") {
                const ep = await fetchFirstEpisode(userId, item.Id);
                if (ep?.Id) {
                    const fullEp = await ApiClient.getItem(userId, ep.Id);
                    videoStream = fullEp?.MediaSources?.[0]?.MediaStreams?.find(s => s.Type === 'Video');
                    quality = getQuality(videoStream);
                }
            } else if (item.Type === "Season") {
                const seasonEpisodes = await ApiClient.ajax({
                    type: "GET",
                    url: ApiClient.getUrl("/Items", {
                        ParentId: item.Id,
                        IncludeItemTypes: "Episode",
                        Limit: 1,
                        SortBy: "PremiereDate",
                        SortOrder: "Ascending",
                        userId: userId
                    }),
                    dataType: "json"
                });

                if (seasonEpisodes.Items?.[0]?.Id) {
                    const episode = await ApiClient.getItem(userId, seasonEpisodes.Items[0].Id);
                    videoStream = episode?.MediaSources?.[0]?.MediaStreams?.find(s => s.Type === 'Video');
                    quality = getQuality(videoStream);
                }
            } else {
                videoStream = item?.MediaSources?.[0]?.MediaStreams?.find(s => s.Type === 'Video');
                quality = getQuality(videoStream);
            }

            if (quality) {
                qualityOverlayCache[itemId] = {
                    quality,
                    timestamp: Date.now()
                };
                saveCache();
                return quality;
            }

            return null;
        } catch {
            handleApiError();
            return null;
        } finally {
            pendingRequests.delete(itemId);
        }
    }

    function handleApiError() {
        errorCount++;
        currentDelay = Math.min(
            config.MAX_DELAY,
            config.BASE_DELAY * Math.pow(2, Math.min(errorCount, 5)) * (0.8 + Math.random() * 0.4)
        );
    }

    function insertOverlay(container, quality) {
        if (!container || container.querySelector(`.${overlayClass}`)) return;

        if (getComputedStyle(container).position === 'static') {
            container.style.position = 'relative';
        }

        const label = createLabel(quality);
        container.appendChild(label);
    }

    function getItemIdFromElement(el) {
        if (el.href) {
            const match = el.href.match(/id=([a-f0-9]{32})/i);
            if (match) return match[1];
        }
        if (el.style.backgroundImage) {
            const match = el.style.backgroundImage.match(/\/Items\/([a-f0-9]{32})\//i);
            if (match) return match[1];
        }
        return null;
    }

    function shouldIgnoreElement(el) {
        return IGNORE_SELECTORS.some(selector => el.closest(selector) !== null);
    }

    async function processElement(el, isPriority = false) {
        if (shouldIgnoreElement(el)) return;

        const itemId = getItemIdFromElement(el);
        if (!itemId || seenItems.has(itemId)) return;
        seenItems.add(itemId);

        const cached = qualityOverlayCache[itemId];
        if (cached) {
            insertOverlay(el, cached.quality);
            return;
        }

        const userId = getUserId();
        if (!userId) return;

        const delay = isPriority ?
            Math.min(config.VISIBLE_PRIORITY_DELAY, currentDelay) :
            currentDelay;

        await new Promise(resolve => setTimeout(resolve, delay));

        if (qualityOverlayCache[itemId]) {
            insertOverlay(el, qualityOverlayCache[itemId].quality);
            return;
        }

        const quality = await fetchItemQuality(userId, itemId);
        if (quality) insertOverlay(el, quality);
    }

    function isElementVisible(el) {
        const rect = el.getBoundingClientRect();
        return (
            rect.top <= (window.innerHeight + 300) &&
            rect.bottom >= -300 &&
            rect.left <= (window.innerWidth + 300) &&
            rect.right >= -300
        );
    }

    function handleIntersection(entries) {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const el = entry.target;
                visibilityObserver.unobserve(el);
                processElement(el, true);
            }
        });
    }

    function renderVisibleTags() {
        const elements = Array.from(document.querySelectorAll('a.cardImageContainer, div.listItemImage'));

        elements.forEach(el => {
            if (shouldIgnoreElement(el)) return;

            const itemId = getItemIdFromElement(el);
            if (!itemId) return;

            const cached = qualityOverlayCache[itemId];
            if (cached) {
                insertOverlay(el, cached.quality);
                return;
            }

            if (isElementVisible(el)) {
                processElement(el, true);
            } else {
                visibilityObserver.observe(el);
            }
        });
    }

    function hookIntoHistoryChanges(callback) {
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function (...args) {
            originalPushState.apply(this, args);
            callback();
        };

        history.replaceState = function (...args) {
            originalReplaceState.apply(this, args);
            callback();
        };

        window.addEventListener('popstate', callback);
    }

    function setupNavigationHandlers() {
        if (navigationHandlerSetup) return;
        navigationHandlerSetup = true;

        document.addEventListener('click', (e) => {
            const backButton = e.target.closest('button.headerButtonLeft:nth-child(1) > span:nth-child(1)');
            if (backButton) {
                setTimeout(() => {
                    seenItems.clear();
                    renderVisibleTags();
                }, 500);
            }
        });

        hookIntoHistoryChanges(() => {
            currentUrl = window.location.href;
            seenItems.clear();
            visibilityObserver.disconnect();
            setTimeout(renderVisibleTags, 300);
        });
    }

    function addStyles() {
        if (document.getElementById('quality-tag-style')) return;
        const style = document.createElement('style');
        style.id = 'quality-tag-style';
        style.textContent = `
            .${overlayClass} {
                user-select: none;
                pointer-events: none;
            }
        `;
        document.head.appendChild(style);
    }

    addStyles();

    setTimeout(() => {
        setupNavigationHandlers();
        renderVisibleTags();
    }, 1500);

    window.addEventListener('beforeunload', saveCache);
    setInterval(saveCache, 60000);

    const mutationObserver = new MutationObserver((mutations) => {
        if (mutations.some(m => m.addedNodes.length > 0)) {
            setTimeout(renderVisibleTags, 1000);
        }
    });
    mutationObserver.observe(document.body, { childList: true, subtree: true });

    async function fetchWithTimeout(url, timeout = config.REQUEST_TIMEOUT) {
        return Promise.race([
            fetch(url),
            new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout))
        ]);
    }
})();