Jellyfin元数据辅助

【二合一】功能1: 在详情页和媒体卡片上添加“一键搜索字幕”按钮(内置智能重试机制,更稳定)。功能2: 在各处“标为已播放”旁添加“刷新元数据”按钮。功能可独立开关。

当前为 2025-08-04 提交的版本,查看 最新版本

// ==UserScript==
// @name         Jellyfin元数据辅助
// @namespace    http://tampermonkey.net/
// @version      3.5
// @description  【二合一】功能1: 在详情页和媒体卡片上添加“一键搜索字幕”按钮(内置智能重试机制,更稳定)。功能2: 在各处“标为已播放”旁添加“刷新元数据”按钮。功能可独立开关。
// @author       Gemini & mojie
// @match        *://*/web/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=jellyfin.org
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
    'use strict';

    // =========================================================================================
    // ——— 配置中心 ———
    // =========================================================================================

    // --- 功能开关 ---
    const ENABLE_SUBTITLE_SEARCH = true;   // true: 开启“一键搜索字幕”功能, false: 关闭
    const ENABLE_METADATA_REFRESH = true; // true: 开启“刷新元数据”功能, false: 关闭

    // --- “刷新元数据”功能所需的 API Key ---
    // 获取方法: 登录(不可用) Jellyfin -> 控制台 -> API 密钥 -> 点击(+)创建 -> 复制并粘贴到下方引号之间
    const MANUAL_API_KEY = '在此处粘贴你的API密钥'; // <--- 在这里替换

    // =========================================================================================
    // ——— 样式定义 (合并) ———
    // =========================================================================================
    GM_addStyle(`
        /* 详情页“一键搜索字幕”按钮的样式 */
        .custom-detail-page-subs-btn {
            margin-left: 0.25em;
            min-width: 0;
            padding: 0.5em;
        }
        .custom-detail-page-subs-btn .detailButton-content {
            padding: 0 !important;
            display: flex;
            align-items: center;
        }

        /* 刷新元数据按钮的样式 */
        .btn-item-refresh {
            min-width: 42px !important;
            padding: 0 !important;
        }
        .btn-item-refresh .material-icons {
            color: rgba(255,255,255,0.87);
            transition: transform 0.3s cubic-bezier(.34,1.56,.64,1);
        }
        .btn-item-refresh:hover .material-icons {
            transform: scale(1.1) rotate(-180deg);
        }
        .btn-item-refresh.is-loading .material-icons {
            animation: spin 1s linear infinite;
        }
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
    `);

    // =========================================================================================
    // ——— 核心逻辑 (合并) ———
    // =========================================================================================

    const CONSTANTS = {
        SUBTITLE: {
            DETAIL_PAGE_MORE_SELECTOR: 'button.detailButton[data-action="menu"][title="更多"]',
            CARD_MORE_SELECTOR: 'button.cardOverlayButton[data-action="menu"][title="更多"]',
            EDIT_SUBTITLES_SELECTOR: 'button[data-id="editsubtitles"]',
            SEARCH_BUTTON_SELECTOR: 'button.btnSearchSubtitles',
            MARKER_ATTRIBUTE: 'data-search-button-added'
        },
        METADATA: {
            PLAYSTATE_BUTTON_SELECTOR: 'button[is="emby-playstatebutton"]',
            MARKER_ATTRIBUTE: 'data-refresh-button-added',
            ICON_NAME: 'sync'
        }
    };

    // --- 功能1: 一键搜索字幕 ---

    /**
     * 【新】智能重试函数:尝试查找元素,如果失败则重试。
     * @param {string} selector - 要查找的元素的CSS选择器。
     * @param {number} retries - 重试次数上限。
     * @param {number} interval - 每次重试之间的间隔(毫秒)。
     * @returns {Promise<HTMLElement|null>} - 成功时返回找到的元素,失败时返回null。
     */
    async function findElementWithRetry(selector, retries = 3, interval = 300) {
        for (let i = 0; i <= retries; i++) {
            const element = document.querySelector(selector);
            if (element) return element; // 成功找到元素
            if (i < retries) {
                console.log(`[Jellyfin元数据辅助] 未找到元素 "${selector}", ${interval}ms后重试 (第 ${i + 1} 次)...`);
                await new Promise(res => setTimeout(res, interval));
            }
        }
        console.error(`[Jellyfin元数据辅助] 超过最大重试次数,未能找到元素 "${selector}"。`);
        return null; // 超过最大重试次数
    }

    /**
     * 【新】通用的“一键搜索字幕”点击逻辑(采用智能重试)
     * @param {HTMLElement} moreButtonElement - 触发操作的“更多”按钮元素
     */
    async function performSubtitleSearch(moreButtonElement) {
        console.log(`[Jellyfin元数据辅助] 开始执行“一键搜索字幕”...`);
        try {
            // 步骤1: 点击“更多”按钮。这是起始操作,无需重试。
            if (moreButtonElement && typeof moreButtonElement.click === 'function') {
                moreButtonElement.click();
            } else {
                throw new Error('步骤1失败: "更多"按钮无效');
            }

            // 步骤2: 查找并点击“编辑字幕”按钮 (带重试机制)
            const editSubsBtn = await findElementWithRetry(CONSTANTS.SUBTITLE.EDIT_SUBTITLES_SELECTOR, 3, 800);
            if (editSubsBtn) {
                editSubsBtn.click();
            } else {
                throw new Error('步骤2失败: 未能在多次尝试后找到“编辑字幕”按钮');
            }

            // 步骤3: 查找并点击“搜索”按钮 (带重试机制)
            const searchBtn = await findElementWithRetry(CONSTANTS.SUBTITLE.SEARCH_BUTTON_SELECTOR, 3, 800);
            if (searchBtn) {
                searchBtn.click();
            } else {
                throw new Error('步骤3失败: 未能在多次尝试后找到“搜索”按钮');
            }

            console.log('[Jellyfin元数据辅助] “一键搜索字幕”任务完成!');

        } catch (error) {
            console.error(`[Jellyfin元数据辅助] ${error.message}`);
            document.body.click(); // 如果出错,点击页面主体以关闭可能打开的菜单
        }
    }


    /**
     * 在详情页的“更多”按钮旁添加字幕搜索按钮
     * @param {HTMLElement} moreButton - 详情页的“更多”按钮
     */
    function addSubtitleButtonToDetailPage(moreButton) {
        const newButton = document.createElement('button');
        newButton.setAttribute('is', 'emby-button');
        newButton.setAttribute('type', 'button');
        newButton.className = 'button-flat detailButton emby-button custom-detail-page-subs-btn';
        newButton.title = '一键搜索字幕';
        newButton.innerHTML = `<div class="detailButton-content"><span class="material-icons">subtitles</span></div>`;
        newButton.onclick = (e) => {
            e.stopPropagation();
            performSubtitleSearch(moreButton);
        };
        moreButton.parentNode.insertBefore(newButton, moreButton.nextSibling);
        moreButton.setAttribute(CONSTANTS.SUBTITLE.MARKER_ATTRIBUTE, 'true');
    }

    /**
     * 在媒体卡片的“更多”按钮旁添加字幕搜索按钮
     * @param {HTMLElement} moreButton - 媒体卡片上的“更多”按钮
     */
    function addSubtitleButtonToCard(moreButton) {
        const newButton = document.createElement('button');
        newButton.className = moreButton.className;
        newButton.setAttribute('is', moreButton.getAttribute('is'));
        newButton.title = '一键搜索字幕';
        newButton.innerHTML = `<span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover" aria-hidden="true">subtitles</span>`;
        newButton.onclick = (e) => {
            e.stopPropagation();
            performSubtitleSearch(moreButton);
        };
        moreButton.parentNode.insertBefore(newButton, moreButton.nextSibling);
        moreButton.setAttribute(CONSTANTS.SUBTITLE.MARKER_ATTRIBUTE, 'true');
    }

    // --- 功能2: 刷新元数据 ---
    function addMetadataRefreshButton(playStateButton) {
        const itemId = playStateButton.getAttribute('data-id');
        if (!itemId) return;
        const refreshButton = document.createElement('button');
        refreshButton.type = 'button';
        refreshButton.className = playStateButton.className + ' btn-item-refresh';
        refreshButton.title = '刷新元数据';
        refreshButton.innerHTML = `<span class="material-icons" aria-hidden="true">${CONSTANTS.METADATA.ICON_NAME}</span>`;
        refreshButton.onclick = (e) => {
            e.stopPropagation();
            if (MANUAL_API_KEY === '在此处粘贴你的API密钥' || MANUAL_API_KEY.length < 10) {
                alert('错误:请先在脚本顶部的配置中心填写正确的 API Key!');
                return;
            }
            refreshButton.classList.add('is-loading');
            refreshButton.disabled = true;
            const serverUrl = window.location.origin;
            const requestUrl = `${serverUrl}/Items/${itemId}/Refresh?Recursive=true&ImageRefreshMode=FullRefresh&MetadataRefreshMode=FullRefresh&ReplaceAllImages=false&RegenerateTrickplay=false&ReplaceAllMetadata=true`;
            GM_xmlhttpRequest({
                method: "POST",
                url: requestUrl,
                headers: { "X-Emby-Token": MANUAL_API_KEY },
                onload: (response) => {
                    refreshButton.classList.remove('is-loading');
                    refreshButton.disabled = false;
                    if (response.status >= 200 && response.status < 300) {
                        refreshButton.innerHTML = '<span class="material-icons" aria-hidden="true" style="color: #4CAF50;">done</span>';
                        setTimeout(() => {
                            refreshButton.innerHTML = `<span class="material-icons" aria-hidden="true">${CONSTANTS.METADATA.ICON_NAME}</span>`;
                        }, 2000);
                    } else {
                        alert(`刷新 Item ID ${itemId} 失败!\n状态码: ${response.status}\n可能是API Key不正确或无权限。`);
                    }
                },
                onerror: () => {
                    refreshButton.classList.remove('is-loading');
                    refreshButton.disabled = false;
                    alert('发送刷新请求时发生网络错误。');
                }
            });
        };
        playStateButton.before(refreshButton);
        playStateButton.setAttribute(CONSTANTS.METADATA.MARKER_ATTRIBUTE, 'true');
    }

    // --- 总调度函数 ---
    function masterScanAndApply() {
        if (ENABLE_SUBTITLE_SEARCH) {
            const detailPageQuery = `${CONSTANTS.SUBTITLE.DETAIL_PAGE_MORE_SELECTOR}:not([${CONSTANTS.SUBTITLE.MARKER_ATTRIBUTE}="true"])`;
            document.querySelectorAll(detailPageQuery).forEach(addSubtitleButtonToDetailPage);
            const cardQuery = `${CONSTANTS.SUBTITLE.CARD_MORE_SELECTOR}:not([${CONSTANTS.SUBTITLE.MARKER_ATTRIBUTE}="true"])`;
            document.querySelectorAll(cardQuery).forEach(addSubtitleButtonToCard);
        }
        if (ENABLE_METADATA_REFRESH) {
            const query = `${CONSTANTS.METADATA.PLAYSTATE_BUTTON_SELECTOR}:not([${CONSTANTS.METADATA.MARKER_ATTRIBUTE}="true"])`;
            document.querySelectorAll(query).forEach(addMetadataRefreshButton);
        }
    }

    // =========================================================================================
    // ——— 启动 MutationObserver ———
    // =========================================================================================
    const observer = new MutationObserver(masterScanAndApply);
    observer.observe(document.body, { childList: true, subtree: true });
    masterScanAndApply();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址