Emby Anzeigefelder-Persistenz-Fix

Verhindert, dass Emby die Anzeigefelder beim Wechseln der Ansicht zurücksetzt. Fängt localStorage ab, um Feldeinstellungen zu sichern und wiederherzustellen. Unterstützt das Kopieren von Feldkonfigurationen zwischen Bibliotheken.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Emby Show Fields Persistence Fix
// @name:en      Emby Show Fields Persistence Fix
// @name:ja      Emby 表示フィールド永続化修正
// @name:zh-CN   Emby 显示字段持久化修复
// @name:zh-TW   Emby 顯示欄位持久化修復
// @name:ko      Emby 표시 필드 지속성 수정
// @name:ru      Emby Исправление сохранения полей отображения
// @name:es      Emby Corrección de persistencia de campos
// @name:pt-BR   Emby Correção de persistência de campos
// @name:fr      Emby Correction de persistance des champs
// @name:de      Emby Anzeigefelder-Persistenz-Fix
// @namespace    https://github.com/CheerChen
// @version      1.3.0
// @description  Prevents Emby from resetting Show Fields when switching views. Intercepts localStorage to backup and restore field settings. Supports copying field config across libraries.
// @description:en  Prevents Emby from resetting Show Fields when switching views. Intercepts localStorage to backup and restore field settings. Supports copying field config across libraries.
// @description:ja  Emby がビュー切替時に表示フィールドをリセットするのを防ぎます。localStorage を傍受してフィールド設定をバックアップ・復元します。ライブラリ間での設定コピーにも対応。
// @description:zh-CN  防止 Emby 在切换视图时重置「显示字段」设置,通过拦截 localStorage 自动备份和恢复字段配置。支持跨媒体库复制字段配置。
// @description:zh-TW  防止 Emby 在切換檢視時重置「顯示欄位」設定,透過攔截 localStorage 自動備份和還原欄位設定。支援跨媒體庫複製欄位設定。
// @description:ko  Emby가 뷰 전환 시 표시 필드를 초기화하는 것을 방지합니다. localStorage를 가로채 필드 설정을 백업 및 복원합니다. 라이브러리 간 설정 복사를 지원합니다.
// @description:ru  Предотвращает сброс полей отображения Emby при переключении видов. Перехватывает localStorage для резервного копирования и восстановления настроек полей. Поддерживает копирование настроек между библиотеками.
// @description:es  Evita que Emby restablezca los campos de visualización al cambiar de vista. Intercepta localStorage para respaldar y restaurar la configuración de campos. Permite copiar configuración entre bibliotecas.
// @description:pt-BR  Impede que o Emby redefina os campos de exibição ao alternar visualizações. Intercepta o localStorage para fazer backup e restaurar as configurações de campos. Suporta copiar configurações entre bibliotecas.
// @description:fr  Empêche Emby de réinitialiser les champs d'affichage lors du changement de vue. Intercepte localStorage pour sauvegarder et restaurer les paramètres de champs. Permet de copier les paramètres entre bibliothèques.
// @description:de  Verhindert, dass Emby die Anzeigefelder beim Wechseln der Ansicht zurücksetzt. Fängt localStorage ab, um Feldeinstellungen zu sichern und wiederherzustellen. Unterstützt das Kopieren von Feldkonfigurationen zwischen Bibliotheken.
// @author       cheerchen37
// @match        *://*/web/index.html*
// @grant        none
// @run-at       document-start
// @icon         https://www.google.com/s2/favicons?domain=emby.media
// @license      MIT
// @homepage     https://github.com/CheerChen/userscripts
// @supportURL   https://github.com/CheerChen/userscripts/issues
// ==/UserScript==

(function () {
    'use strict';

    const BACKUP_PREFIX = '__emby_fields_fix::';
    const FIELDS_SUFFIX = '-fields';
    const DEFAULT_FIELDS = 'Name,ProductionYear';

    // 判断是否是 fields key: {userId}-{libraryId}-{page}-{type}-fields
    function isFieldsKey(key) {
        return typeof key === 'string' && key.endsWith(FIELDS_SUFFIX);
    }

    function backupKey(key) {
        return BACKUP_PREFIX + key;
    }

    const originalSetItem = Storage.prototype.setItem;
    const originalGetItem = Storage.prototype.getItem;

    Storage.prototype.setItem = function (key, value) {
        if (this === localStorage && isFieldsKey(key)) {
            const prev = originalGetItem.call(this, key);
            const backup = originalGetItem.call(this, backupKey(key));

            // 如果新值是默认值(被重置),但我们有更丰富的备份,就阻止写入并恢复
            if (
                value === DEFAULT_FIELDS &&
                prev &&
                prev !== DEFAULT_FIELDS
            ) {
                console.log(
                    '%c[Fields Fix] 阻止重置:',
                    'color: #f90; font-weight: bold',
                    key,
                    '\n  被丢弃:', value,
                    '\n  保留:', prev
                );
                // 确保备份是最新的
                originalSetItem.call(this, backupKey(key), prev);
                return; // 不执行写入,保留当前值
            }

            // 如果新值也是默认值,但之前没有非默认值,检查备份
            if (
                value === DEFAULT_FIELDS &&
                (!prev || prev === DEFAULT_FIELDS) &&
                backup &&
                backup !== DEFAULT_FIELDS
            ) {
                console.log(
                    '%c[Fields Fix] 从备份恢复:',
                    'color: #0f0; font-weight: bold',
                    key,
                    '\n  恢复为:', backup
                );
                originalSetItem.call(this, key, backup);
                return;
            }

            // 正常写入(用户主动修改),同时更新备份
            if (value !== DEFAULT_FIELDS) {
                originalSetItem.call(this, backupKey(key), value);
                console.log(
                    '%c[Fields Fix] 备份已更新:',
                    'color: #0ff;',
                    key,
                    '→',
                    value
                );
            }
        }

        originalSetItem.call(this, key, value);
    };

    // 页面加载时检查所有已有的 fields key,确保备份存在
    window.addEventListener('DOMContentLoaded', () => {
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            if (isFieldsKey(key) && !key.startsWith(BACKUP_PREFIX)) {
                const val = localStorage.getItem(key);
                const bk = localStorage.getItem(backupKey(key));
                if (val && val !== DEFAULT_FIELDS && (!bk || bk === DEFAULT_FIELDS)) {
                    originalSetItem.call(localStorage, backupKey(key), val);
                }
                // 如果当前值是默认但备份有内容,恢复
                if (
                    (!val || val === DEFAULT_FIELDS) &&
                    bk &&
                    bk !== DEFAULT_FIELDS
                ) {
                    originalSetItem.call(localStorage, key, bk);
                    console.log(
                        '%c[Fields Fix] 页面加载恢复:',
                        'color: #0f0; font-weight: bold',
                        key,
                        '→',
                        bk
                    );
                }
            }
        }
        console.log('%c[Fields Fix] 已激活', 'color: #0f0; font-size: 14px');
        initCopyUI();
    });

    // ===================== 跨媒体库复制配置 UI =====================

    const i18n = {
        zh: {
            title: '复制显示字段配置',
            source: '复制来源',
            target: '应用到',
            applyAll: '应用到全部',
            apply: '复制配置',
            close: '关闭',
            success: '已复制 {n} 条配置',
            noSource: '请选择来源媒体库',
            noTarget: '请选择目标媒体库',
            noConfig: '来源媒体库没有已保存的字段配置',
        },
        en: {
            title: 'Copy Field Config',
            source: 'Copy from',
            target: 'Apply to',
            applyAll: 'Apply to All',
            apply: 'Copy Config',
            close: 'Close',
            success: 'Copied {n} config(s)',
            noSource: 'Please select a source library',
            noTarget: 'Please select target libraries',
            noConfig: 'Source library has no saved field config',
        }
    };

    function getLang() {
        const lang = (navigator.language || '').toLowerCase();
        return lang.startsWith('zh') ? 'zh' : 'en';
    }

    function t(key) {
        return i18n[getLang()][key] || i18n.en[key];
    }

    // 从 localStorage keys 中提取所有 libraryId
    function parseFieldsEntries() {
        const entries = [];
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            if (isFieldsKey(key) && !key.startsWith(BACKUP_PREFIX)) {
                entries.push({ key, value: localStorage.getItem(key) });
            }
        }
        return entries;
    }

    // 从 fields key 中提取 libraryId(key 的第二段)
    // key 格式: {userId}-{libraryId}-{page}-{type}-fields
    // Emby ID 通常是无连字符的 hex 字符串,用 - 分隔各段
    function extractSegments(key) {
        // 去掉 -fields 后缀,按 - 拆分
        const body = key.replace(/-fields$/, '');
        const parts = body.split('-');
        // Emby ID 是 32 位 hex,找到前两个匹配的段作为 userId 和 libraryId
        // 简化处理:假设格式固定,取 parts[0] 为 userId, parts[1] 为 libraryId
        // 后续为 page-type
        if (parts.length < 3) return null;
        return {
            userId: parts[0],
            libraryId: parts[1],
            rest: parts.slice(2).join('-'), // page-type
        };
    }

    function getUniqueLibraryIds() {
        const entries = parseFieldsEntries();
        const libIds = new Set();
        for (const { key } of entries) {
            const seg = extractSegments(key);
            if (seg) libIds.add(seg.libraryId);
        }
        return [...libIds];
    }

    // 通过 Emby API 获取媒体库名称
    async function fetchLibraryNames() {
        const map = {};
        try {
            if (typeof ApiClient === 'undefined' || !ApiClient.getJSON || !ApiClient.getUrl) return map;
            const views = await ApiClient.getJSON(ApiClient.getUrl('Library/VirtualFolders'));
            if (Array.isArray(views)) {
                for (const v of views) {
                    const id = v.ItemId || v.Id;
                    if (id) map[String(id)] = v.Name;
                }
            }
        } catch (e) {
            console.warn('[Fields Fix] 获取媒体库名称失败:', e);
        }
        return map;
    }

    function copyFieldsConfig(sourceLibId, targetLibIds) {
        const entries = parseFieldsEntries();
        let count = 0;
        for (const { key, value } of entries) {
            const seg = extractSegments(key);
            if (!seg || seg.libraryId !== sourceLibId) continue;
            if (value === DEFAULT_FIELDS) continue;
            for (const targetId of targetLibIds) {
                if (targetId === sourceLibId) continue;
                const newKey = key.replace(
                    `${seg.userId}-${seg.libraryId}-`,
                    `${seg.userId}-${targetId}-`
                );
                localStorage.setItem(newKey, value);
                count++;
            }
        }
        return count;
    }

    // ===================== UI 渲染 =====================

    const INJECT_ID = 'emby-fields-copy-btn';
    const OVERLAY_ID = 'emby-fields-copy-overlay';

    function initCopyUI() {
        // 监听 .btnViewSettings 出现,在旁边注入按钮
        const observer = new MutationObserver(() => {
            const settingsBtns = document.querySelectorAll('.btnViewSettings');
            for (const settingsBtn of settingsBtns) {
                if (settingsBtn.parentElement.querySelector('#' + INJECT_ID)) continue;
                injectCopyButton(settingsBtn);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    function injectCopyButton(settingsBtn) {
        const btn = document.createElement('button');
        btn.id = INJECT_ID;
        btn.type = 'button';
        btn.title = t('title');
        btn.className = 'fab fab-mini paper-icon-button-light';
        btn.innerHTML = '<i class="md-icon button-icon">content_copy</i>';
        settingsBtn.after(btn);

        btn.addEventListener('click', () => {
            openOverlay().catch(e =>
                console.warn('[Fields Fix] 面板加载失败:', e)
            );
        });
    }

    async function openOverlay() {
        // 切换:已有则关闭
        const existing = document.getElementById(OVERLAY_ID);
        if (existing) { existing.remove(); return; }

        const nameMap = await fetchLibraryNames();
        const allApiIds = Object.keys(nameMap);
        const sourceIds = getUniqueLibraryIds().filter(id => nameMap[id]);
        const targetIds = allApiIds;
        const getName = (id) => nameMap[id];

        // 遮罩
        const overlay = document.createElement('div');
        overlay.id = OVERLAY_ID;
        Object.assign(overlay.style, {
            position: 'fixed',
            inset: '0',
            background: 'rgba(0,0,0,0.6)',
            zIndex: '9999999',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
        });
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) overlay.remove();
        });

        const panel = document.createElement('div');
        Object.assign(panel.style, {
            background: '#1c1c1e',
            borderRadius: '12px',
            padding: '24px',
            minWidth: '340px',
            maxWidth: '420px',
            color: '#e0e0e0',
            fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
            boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
        });

        // 标题
        const title = document.createElement('h3');
        title.textContent = t('title');
        Object.assign(title.style, { margin: '0 0 16px 0', fontSize: '16px', fontWeight: '600' });
        panel.appendChild(title);

        // 来源选择
        const srcLabel = document.createElement('div');
        srcLabel.textContent = t('source');
        Object.assign(srcLabel.style, { fontSize: '13px', color: '#aaa', marginBottom: '6px' });
        panel.appendChild(srcLabel);

        const srcSelect = document.createElement('select');
        Object.assign(srcSelect.style, {
            width: '100%',
            padding: '8px',
            borderRadius: '6px',
            border: '1px solid #444',
            background: '#2c2c2e',
            color: '#e0e0e0',
            fontSize: '14px',
            marginBottom: '16px',
            outline: 'none',
        });
        const placeholderOpt = document.createElement('option');
        placeholderOpt.value = '';
        placeholderOpt.textContent = '—';
        srcSelect.appendChild(placeholderOpt);
        for (const id of sourceIds) {
            const opt = document.createElement('option');
            opt.value = id;
            opt.textContent = getName(id);
            srcSelect.appendChild(opt);
        }
        panel.appendChild(srcSelect);

        // 目标选择
        const tgtLabel = document.createElement('div');
        tgtLabel.textContent = t('target');
        Object.assign(tgtLabel.style, { fontSize: '13px', color: '#aaa', marginBottom: '6px' });
        panel.appendChild(tgtLabel);

        const checkboxContainer = document.createElement('div');
        Object.assign(checkboxContainer.style, {
            maxHeight: '180px',
            overflowY: 'auto',
            marginBottom: '16px',
            padding: '4px 0',
        });

        const checkboxes = [];
        for (const id of targetIds) {
            const row = document.createElement('label');
            Object.assign(row.style, {
                display: 'flex',
                alignItems: 'center',
                gap: '8px',
                padding: '6px 8px',
                borderRadius: '4px',
                cursor: 'pointer',
                fontSize: '14px',
            });
            row.addEventListener('mouseenter', () => row.style.background = 'rgba(255,255,255,0.05)');
            row.addEventListener('mouseleave', () => row.style.background = 'transparent');
            const cb = document.createElement('input');
            cb.type = 'checkbox';
            cb.value = id;
            const span = document.createElement('span');
            span.textContent = getName(id);
            row.appendChild(cb);
            row.appendChild(span);
            checkboxContainer.appendChild(row);
            checkboxes.push(cb);
        }
        panel.appendChild(checkboxContainer);

        // 源选择变化时禁用对应目标
        srcSelect.addEventListener('change', () => {
            for (const cb of checkboxes) {
                cb.disabled = cb.value === srcSelect.value;
                if (cb.disabled) cb.checked = false;
            }
        });

        // 按钮区
        const btnRow = document.createElement('div');
        Object.assign(btnRow.style, { display: 'flex', gap: '8px', justifyContent: 'flex-end', flexWrap: 'wrap' });

        const makeBtnStyle = (bg) => ({
            padding: '8px 16px',
            borderRadius: '6px',
            border: 'none',
            background: bg,
            color: '#fff',
            fontSize: '13px',
            cursor: 'pointer',
            fontWeight: '500',
        });

        const applyAllBtn = document.createElement('button');
        applyAllBtn.textContent = t('applyAll');
        Object.assign(applyAllBtn.style, makeBtnStyle('#555'));
        applyAllBtn.addEventListener('click', () => {
            for (const cb of checkboxes) {
                if (!cb.disabled) cb.checked = true;
            }
        });

        const applyBtn = document.createElement('button');
        applyBtn.textContent = t('apply');
        Object.assign(applyBtn.style, makeBtnStyle('#0060df'));

        const closeBtn = document.createElement('button');
        closeBtn.textContent = t('close');
        Object.assign(closeBtn.style, makeBtnStyle('#333'));
        closeBtn.addEventListener('click', () => overlay.remove());

        // 消息提示
        const msg = document.createElement('div');
        Object.assign(msg.style, {
            fontSize: '13px',
            marginTop: '12px',
            minHeight: '20px',
            textAlign: 'center',
        });

        applyBtn.addEventListener('click', () => {
            const sourceId = srcSelect.value;
            if (!sourceId) { msg.textContent = t('noSource'); msg.style.color = '#f90'; return; }
            const selectedIds = checkboxes.filter(cb => cb.checked && !cb.disabled).map(cb => cb.value);
            if (!selectedIds.length) { msg.textContent = t('noTarget'); msg.style.color = '#f90'; return; }
            const count = copyFieldsConfig(sourceId, selectedIds);
            if (count === 0) {
                msg.textContent = t('noConfig');
                msg.style.color = '#f90';
            } else {
                msg.textContent = t('success').replace('{n}', count);
                msg.style.color = '#4caf50';
            }
        });

        btnRow.appendChild(applyAllBtn);
        btnRow.appendChild(applyBtn);
        btnRow.appendChild(closeBtn);
        panel.appendChild(btnRow);
        panel.appendChild(msg);

        overlay.appendChild(panel);
        document.body.appendChild(overlay);
    }
})();