Jellyfin元数据辅助

【二合一】功能1: 在详情页添加“一键搜索字幕”按钮。功能2: 在各处“标为已播放”旁添加“刷新元数据”按钮。功能可独立开关。

目前為 2025-08-04 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Jellyfin元数据辅助
// @namespace    http://tampermonkey.net/
// @version      3.0
// @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-search-subs-btn {
            margin-left: 0.25em;
            min-width: 0;
            padding: 0.5em;
        }
        .custom-search-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); }
        }
    `);

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

    // --- 功能1: 一键搜索字幕 ---
    const SUBTITLE_SEARCH_CONSTANTS = {
        MORE_BUTTON_SELECTOR: 'button[is="emby-button"][title="更多"]',
        EDIT_SUBTITLES_SELECTOR: 'button[data-id="editsubtitles"]',
        SEARCH_BUTTON_SELECTOR: 'button.btnSearchSubtitles',
        MARKER_ATTRIBUTE: 'data-search-button-added'
    };

    function simulateUserClick(element) {
        if (!element || typeof element.dispatchEvent !== 'function') return false;
        const eventOptions = { bubbles: true, cancelable: true, view: document.defaultView };
        ['mousedown', 'mouseup', 'click'].forEach(type => element.dispatchEvent(new MouseEvent(type, eventOptions)));
        return true;
    }

    function addSubtitleSearchButton(moreButton) {
        const newButton = document.createElement('button');
        newButton.setAttribute('is', 'emby-button');
        newButton.setAttribute('type', 'button');
        newButton.className = 'button-flat detailButton emby-button custom-search-subs-btn';
        newButton.title = '一键搜索字幕';
        newButton.innerHTML = `<div class="detailButton-content"><span class="material-icons">subtitles</span></div>`;

        newButton.onclick = async () => {
            console.log(`[工具箱] 开始执行“一键搜索字幕”...`);
            const delay = ms => new Promise(res => setTimeout(res, ms));
            try {
                if (!simulateUserClick(document.querySelector(SUBTITLE_SEARCH_CONSTANTS.MORE_BUTTON_SELECTOR))) throw new Error(`步骤1失败`);
                await delay(800);
                if (!simulateUserClick(document.querySelector(SUBTITLE_SEARCH_CONSTANTS.EDIT_SUBTITLES_SELECTOR))) throw new Error(`步骤2失败`);
                await delay(800);
                if (!simulateUserClick(document.querySelector(SUBTITLE_SEARCH_CONSTANTS.SEARCH_BUTTON_SELECTOR))) throw new Error(`步骤3失败`);
                console.log('[工具箱] “一键搜索字幕”任务完成!');
            } catch (error) {
                console.error(error.message);
                document.body.click();
            }
        };

        moreButton.parentNode.insertBefore(newButton, moreButton.nextSibling);
        moreButton.setAttribute(SUBTITLE_SEARCH_CONSTANTS.MARKER_ATTRIBUTE, 'true');
    }

    // --- 功能2: 刷新元数据 ---
    const METADATA_REFRESH_CONSTANTS = {
        PLAYSTATE_BUTTON_SELECTOR: 'button[is="emby-playstatebutton"]',
        MARKER_ATTRIBUTE: 'data-refresh-button-added',
        ICON_NAME: 'sync'
    };

    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">${METADATA_REFRESH_CONSTANTS.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">${METADATA_REFRESH_CONSTANTS.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(METADATA_REFRESH_CONSTANTS.MARKER_ATTRIBUTE, 'true');
    }


    // --- 总调度函数 ---
    function masterScanAndApply() {
        // 如果开启了“一键搜索字幕”功能,则执行扫描和添加
        if (ENABLE_SUBTITLE_SEARCH) {
            const query = `${SUBTITLE_SEARCH_CONSTANTS.MORE_BUTTON_SELECTOR}:not([${SUBTITLE_SEARCH_CONSTANTS.MARKER_ATTRIBUTE}="true"])`;
            document.querySelectorAll(query).forEach(addSubtitleSearchButton);
        }

        // 如果开启了“刷新元数据”功能,则执行扫描和添加
        if (ENABLE_METADATA_REFRESH) {
            const query = `${METADATA_REFRESH_CONSTANTS.PLAYSTATE_BUTTON_SELECTOR}:not([${METADATA_REFRESH_CONSTANTS.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或关注我们的公众号极客氢云获取最新地址