您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Emby 封面查看&下载助手:在详情页与操作菜单添加查看/下载封面按钮,自动识别语言,性能优化、防重复注入。
// ==UserScript== // @name Emby Cover Art Helper // @namespace https://github.com/kumu-ze/Emby-Cover-Art-Helper // @version 1.0.1 // @description Emby 封面查看&下载助手:在详情页与操作菜单添加查看/下载封面按钮,自动识别语言,性能优化、防重复注入。 // @description:en View & download Emby cover images on detail pages and action sheet; i18n + perf optimized. // @author kumuze (orig idea & maintenance); contributors: Gemini, GitHub Copilot // @license MIT // @icon https://github.githubassets.com/pinned-octocat.svg // @match *://*/web/index.html* // @run-at document-idle // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_download // ==/UserScript== (function () { 'use strict'; /********************** 环境探测与兼容层 ************************/ const HAS_GM_ADDSTYLE = typeof GM_addStyle === 'function'; const HAS_GM_XHR = typeof GM_xmlhttpRequest === 'function'; const HAS_GM_DOWNLOAD = typeof GM_download === 'function'; function injectStyleFallback(css) { if (document.getElementById('cover-helper-style')) return; const style = document.createElement('style'); style.id = 'cover-helper-style'; style.textContent = css; document.head.appendChild(style); } /********************** 样式 ************************/ const STYLE_BLOCK = ` .detailButton.custom-cover-btn { background-color: #525252; color: #fff; margin-left: 10px; transition: background-color .2s; } .detailButton.custom-cover-btn:hover { background-color: #626262 !important; } .custom-cover-inline-icon { vertical-align: middle; } /* ActionSheet 自定义按钮(跟随原主题即可,主要保证结构一致) */ .actionSheetMenuItem.custom-cover-btn-injected .listItemBodyText { font-weight: 500; } `; if (HAS_GM_ADDSTYLE) { GM_addStyle(STYLE_BLOCK); } else { injectStyleFallback(STYLE_BLOCK); } /********************** 配置 / 可调参数 ************************/ const CONFIG = { debounceMs: 120, // MutationObserver 去抖间隔 enableActionSheet: true, // 是否在操作菜单中注入 enableDetailButtons: true, // 是否在详情页注入 preferGMDownload: true, // 如果 GM_download 可用则优先使用 openInNewTab: true // 点击“查看封面”是否新标签打开 }; /********************** 国际化 ************************/ const LANG = (navigator.language || '').toLowerCase(); const isZH = LANG.startsWith('zh'); const I18N = { view: isZH ? '查看封面' : 'View Cover', download: isZH ? '下载封面' : 'Download Cover', downloading: isZH ? '下载中...' : 'Downloading...', failed: isZH ? '下载图片失败,请检查控制台。' : 'Failed to download cover. Check console.' }; /********************** 工具函数 ************************/ function log(...args) { console.debug('[CoverHelper]', ...args); } function safeFileName(name, fallback = 'poster') { const base = (name || fallback).trim().replace(/[\\/:"*?<>|]/g, '-'); return base || fallback; } function guessExtensionFromHeaders(headers) { if (!headers) return 'jpg'; const match = headers.match(/content-type:\s*([^;\n]+)/i); if (match && match[1]) { const subtype = match[1].split('/')[1]; if (subtype) { if (/jpeg/i.test(subtype)) return 'jpg'; return subtype.split('+')[0]; } } return 'jpg'; } function extractHighRes(url) { if (!url) return null; // Emby/Jellyfin 通常参数控制尺寸,去掉查询可获取原图;某些情况可以替换 quality 指示符,这里保持简单 return url.split('?')[0]; } function currentDetailImage() { return document.querySelector('.detailImageContainer-main img.cardImage, .detail-main-items-container-inner img.cardImage'); } function currentTitle() { const h1 = document.querySelector('h1.itemName-primary'); if (h1 && h1.textContent) return h1.textContent.trim(); return undefined; } /********************** 下载逻辑(多环境) ************************/ function downloadImage(url, titleHint) { if (!url) return; const baseName = safeFileName(titleHint || currentTitle()); // 1. 优先 GM_download if (CONFIG.preferGMDownload && HAS_GM_DOWNLOAD) { try { GM_download({ url, name: baseName + '.jpg', saveAs: true, ontimeout: () => log('GM_download timeout') }); return; } catch (e) { log('GM_download error -> fallback', e); } } // 2. GM_xmlhttpRequest 分支 if (HAS_GM_XHR) { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload: (res) => genericDownloadHandler(res.response, guessExtensionFromHeaders(res.responseHeaders || ''), baseName), onerror: (e) => { console.error('Download error', e); alert(I18N.failed); } }); return; } // 3. 纯浏览器 fetch 回退 fetch(url, { credentials: 'include' }) .then(res => { if (!res.ok) throw new Error(res.status + ' ' + res.statusText); const ct = res.headers.get('content-type') || ''; const ext = guessExtensionFromHeaders('content-type: ' + ct); return res.blob().then(blob => genericDownloadHandler(blob, ext, baseName)); }) .catch(err => { console.error('Fetch download failed', err); alert(I18N.failed); }); } function genericDownloadHandler(blob, extension, baseName) { try { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = baseName + '.' + (extension || 'jpg').replace(/[^a-z0-9]/gi, ''); document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 8000); } catch (err) { console.error(err); alert(I18N.failed); } } /********************** 按钮生成 ************************/ function createDetailButton(id, iconName, text) { const btn = document.createElement('button'); btn.id = id; btn.setAttribute('is', 'emby-button'); btn.type = 'button'; btn.className = 'detailButton emby-button button-hoverable raised custom-cover-btn'; btn.innerHTML = `<i class="md-icon button-icon button-icon-left custom-cover-inline-icon">${iconName}</i><span>${text}</span>`; return btn; } function createActionSheetButton(id, iconName, text) { const btn = document.createElement('button'); btn.id = id; btn.className = 'listItem listItem-autoactive itemAction listItemCursor listItem-hoverable actionSheetMenuItem actionSheetMenuItem-iconright custom-cover-btn-injected'; btn.type = 'button'; btn.innerHTML = ` <div class="listItem-content listItem-content-bg listItemContent-touchzoom listItem-border actionsheet-noborderconditional"> <div class="actionSheetItemImageContainer actionSheetItemImageContainer-customsize actionSheetItemImageContainer-transparent listItemImageContainer listItemImageContainer-margin listItemImageContainer-square defaultCardBackground" style="aspect-ratio:1"> <i class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent md-icon listItemIcon autortl">${iconName}</i> </div> <div class="actionsheetListItemBody actionsheetListItemBody-iconright listItemBody listItemBody-1-lines"> <div class="listItemBodyText actionSheetItemText listItemBodyText-nowrap listItemBodyText-lf">${text}</div> </div> </div>`; return btn; } /********************** 注入逻辑:详情页 ************************/ function injectDetailButtons() { if (! CONFIG.enableDetailButtons) return; const container = document.querySelector('.detailButtons.mainDetailButtons'); if (!container) return; // 未到详情页 // 避免重复 if (container.dataset.coverHelperInjected === '1') return; const img = currentDetailImage(); if (!img || !img.src) return; const highRes = extractHighRes(img.src); if (!highRes) return; const viewBtn = createDetailButton('cover-helper-view-btn', 'photo_library', I18N.view); viewBtn.addEventListener('click', () => { if (CONFIG.openInNewTab) window.open(highRes, '_blank'); else window.location.href = highRes; }); const downloadBtn = createDetailButton('cover-helper-download-btn', 'file_download', I18N.download); downloadBtn.addEventListener('click', () => downloadImage(highRes)); container.appendChild(viewBtn); container.appendChild(downloadBtn); container.dataset.coverHelperInjected = '1'; log('Detail buttons injected'); } /********************** 注入逻辑:Action Sheet ************************/ function injectActionSheetButtons() { if (! CONFIG.enableActionSheet) return; const sheet = document.querySelector('div.actionSheet.opened'); if (!sheet) return; if (sheet.dataset.coverHelperInjected === '1') return; const list = sheet.querySelector('.actionsheetScrollSlider'); const bgDiv = sheet.querySelector('.actionsheetItemPreviewImage-bg'); if (!list || !bgDiv || !bgDiv.style.backgroundImage) return; const raw = bgDiv.style.backgroundImage; const url = extractHighRes(raw.slice(5, -2)); // strip url("...") if (!url) return; let titleHint = 'poster'; const titleNode = sheet.querySelector('.actionsheetItemPreviewText-main .actionsheetPreviewTextItem'); if (titleNode && titleNode.textContent) titleHint = titleNode.textContent.trim(); const viewBtn = createActionSheetButton('cover-helper-actionsheet-view-btn', 'photo_library', I18N.view); viewBtn.addEventListener('click', (e) => { e.stopPropagation(); window.open(url, '_blank'); }); const downloadBtn = createActionSheetButton('cover-helper-actionsheet-download-btn', 'file_download', I18N.download); downloadBtn.addEventListener('click', (e) => { e.stopPropagation(); downloadImage(url, titleHint); }); list.prepend(downloadBtn); list.prepend(viewBtn); sheet.dataset.coverHelperInjected = '1'; log('ActionSheet buttons injected'); } /********************** MutationObserver + 去抖 ************************/ let debounceTimer = null; const observer = new MutationObserver(() => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { try { injectDetailButtons(); injectActionSheetButtons(); } catch (err) { console.error('[CoverHelper] inject error', err); } }, CONFIG.debounceMs); }); observer.observe(document.documentElement || document.body, { childList: true, subtree: true }); // 初始尝试 injectDetailButtons(); injectActionSheetButtons(); log('Emby Cover Art Helper loaded.'); })();