// ==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 = 'content_copy'; 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); } })();