Emby Cover Art Helper

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.');
})();