// ==UserScript== // @name YouTube Cover Downloader // @namespace https://tampermonkey.net/ // @version 1.0.0 // @description Add a button on YouTube to download the highest-quality available video thumbnail. // @author Codex // @match https://www.youtube.com/* // @match https://m.youtube.com/* // @run-at document-idle // @grant GM_addStyle // @grant GM_download // @connect i.ytimg.com // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/574045/YouTube%20Cover%20Downloader.user.js // @updateURL https://update.greasyfork.icu/scripts/574045/YouTube%20Cover%20Downloader.meta.js // ==/UserScript== (function () { 'use strict'; const BUTTON_ID = 'yt-max-thumb-download-btn'; const SLOT_ID = 'yt-max-thumb-download-slot'; const OWNER_ACTION_ROW_SELECTORS = [ 'ytd-watch-metadata #owner #actions', 'ytd-watch-metadata #actions' ]; const OWNER_ACTION_ANCHOR_SELECTORS = [ 'ytd-watch-metadata #owner #subscribe-button', 'ytd-watch-metadata #owner ytd-subscription-notification-toggle-button-renderer', 'ytd-watch-metadata #owner #notification-preference-button', 'ytd-watch-metadata #owner #sponsor-button', 'ytd-watch-metadata #owner ytd-subscribe-button-renderer' ]; const QUALITY_RANK = { maxresdefault: 5, sddefault: 4, hqdefault: 3, mqdefault: 2, default: 1 }; const FORMAT_RANK = { jpg: 2, webp: 1 }; const TEXT = { download: '\u5c01\u9762', downloadTitle: '\u4e0b\u8f7d\u5f53\u524d\u89c6\u9891\u7684\u6700\u9ad8\u753b\u8d28\u5c01\u9762', notVideo: '\u4e0d\u5728\u89c6\u9891\u9875', notVideoTitle: '\u5f53\u524d\u9875\u9762\u6ca1\u6709\u53ef\u4e0b\u8f7d\u7684 YouTube \u89c6\u9891\u5c01\u9762', resolving: '\u89e3\u6790\u4e2d', resolvingTitle: '\u6b63\u5728\u67e5\u627e\u6700\u9ad8\u753b\u8d28\u5c01\u9762', unavailable: '\u65e0\u5c01\u9762', unavailableTitle: '\u6ca1\u6709\u627e\u5230\u53ef\u4e0b\u8f7d\u7684\u5c01\u9762\u56fe', downloading: '\u4e0b\u8f7d\u4e2d', downloaded: '\u5df2\u4e0b\u8f7d', opened: '\u5df2\u6253\u5f00\u539f\u56fe', openedTitle: '\u6d4f\u89c8\u5668\u672a\u80fd\u76f4\u63a5\u4e0b\u8f7d\uff0c\u5df2\u5728\u65b0\u6807\u7b7e\u9875\u6253\u5f00\u5c01\u9762\u539f\u56fe', failed: '\u5931\u8d25', failedTitle: '\u5c01\u9762\u4e0b\u8f7d\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5' }; const thumbnailCache = new Map(); let button = null; let slot = null; let refreshTimer = 0; let currentUrl = ''; let isBusy = false; let busyVideoId = ''; injectStyles(` #${BUTTON_ID} { display: flex; align-items: center; justify-content: center; min-width: 60px; height: 36px; padding: 0 12px; border: 1px solid var(--yt-spec-10-percent-layer, rgba(0, 0, 0, 0.12)); border-radius: 18px; background: var(--yt-spec-badge-chip-background, #f2f2f2); color: var(--yt-spec-text-primary, #0f0f0f); font-size: 13px; font-weight: 600; line-height: 1; flex-shrink: 0; cursor: pointer; transition: transform 0.18s ease, opacity 0.18s ease, background 0.18s ease; box-shadow: none; z-index: 2147483647; white-space: nowrap; } #${BUTTON_ID}.is-floating { position: fixed; right: 24px; bottom: 88px; border-color: transparent; background: #ff0033; color: #ffffff; box-shadow: 0 8px 20px rgba(255, 0, 51, 0.26); } #${BUTTON_ID}.is-embedded { position: static; right: auto; bottom: auto; min-width: 60px; height: 36px; box-shadow: none; } #${BUTTON_ID}:hover { transform: translateY(-1px); background: var(--yt-spec-badge-chip-background, #e9e9e9); } #${BUTTON_ID}.is-floating:hover { background: #e1002d; } #${BUTTON_ID}:disabled { opacity: 0.65; cursor: wait; transform: none; } #${BUTTON_ID}.is-hidden { display: none !important; } #${SLOT_ID} { display: inline-flex; align-items: center; justify-content: center; width: auto; margin-left: 8px; padding: 0; flex: 0 0 auto; box-sizing: border-box; } #${SLOT_ID}.is-hidden { display: none !important; } @media (max-width: 768px) { #${BUTTON_ID}.is-floating { right: 16px; bottom: 76px; } #${SLOT_ID} { margin-left: 6px; } } `); function injectStyles(cssText) { if (typeof GM_addStyle === 'function') { GM_addStyle(cssText); return; } const style = document.createElement('style'); style.textContent = cssText; document.head.appendChild(style); } function getVideoId(urlString = window.location.href) { try { const url = new URL(urlString, window.location.origin); const pathParts = url.pathname.split('/').filter(Boolean); if (url.pathname === '/watch') { return url.searchParams.get('v') || ''; } if (pathParts.length >= 2 && ['shorts', 'live', 'embed'].includes(pathParts[0])) { return pathParts[1]; } } catch (error) { console.warn('[YT Thumbnail Downloader] Failed to parse URL:', error); } return ''; } function isVideoPage() { return Boolean(getVideoId()); } function sanitizeFileName(name) { const safeName = (name || '') .replace(/[<>:"/\\|?*\u0000-\u001F]/g, ' ') .replace(/\s+/g, ' ') .trim(); return safeName.slice(0, 120) || 'youtube-thumbnail'; } function getVideoTitle() { const selectors = [ 'ytd-watch-metadata h1 yt-formatted-string', 'ytd-watch-metadata h1', '#title h1', 'h1.title' ]; for (const selector of selectors) { const element = document.querySelector(selector); const text = element && element.textContent ? element.textContent.trim() : ''; if (text) { return text; } } const ogTitle = document.querySelector('meta[property="og:title"]'); const metaTitle = ogTitle ? ogTitle.getAttribute('content') : ''; if (metaTitle) { return metaTitle.trim(); } return document.title.replace(/\s*-\s*YouTube\s*$/i, '').trim(); } function getChannelName() { const selectors = [ 'ytd-watch-metadata #owner #channel-name a', 'ytd-watch-metadata #owner #channel-name yt-formatted-string', 'ytd-watch-metadata #owner #owner-name a', '#upload-info #channel-name a', '#owner-name a' ]; for (const selector of selectors) { const element = document.querySelector(selector); const text = element && element.textContent ? element.textContent.trim() : ''; if (text) { return text; } } const authorMeta = document.querySelector('meta[itemprop="author"]'); const metaName = authorMeta ? authorMeta.getAttribute('content') : ''; if (metaName) { return metaName.trim(); } return ''; } function buildThumbnailCandidates(videoId) { const qualityLevels = ['maxresdefault', 'sddefault', 'hqdefault', 'mqdefault', 'default']; const formats = [ { type: 'jpg', buildUrl: (quality) => `https://i.ytimg.com/vi/${videoId}/${quality}.jpg` }, { type: 'webp', buildUrl: (quality) => `https://i.ytimg.com/vi_webp/${videoId}/${quality}.webp` } ]; const candidates = []; qualityLevels.forEach((quality, qualityIndex) => { formats.forEach((format, formatIndex) => { candidates.push({ quality, format: format.type, url: format.buildUrl(quality), tieBreaker: qualityIndex * 10 + formatIndex }); }); }); return candidates; } function loadImageInfo(candidate) { return new Promise((resolve, reject) => { const image = new Image(); let settled = false; const finish = (callback, value) => { if (settled) { return; } settled = true; window.clearTimeout(timeoutId); callback(value); }; const timeoutId = window.setTimeout(() => { finish(reject, new Error(`Timeout loading image: ${candidate.url}`)); }, 5000); image.referrerPolicy = 'no-referrer'; image.decoding = 'async'; image.onload = () => { finish(resolve, { ...candidate, width: image.naturalWidth || 0, height: image.naturalHeight || 0 }); }; image.onerror = () => { finish(reject, new Error(`Image not available: ${candidate.url}`)); }; image.src = candidate.url; }); } function chooseBestThumbnail(candidates) { const sortedCandidates = [...candidates].sort((left, right) => { const pixelDiff = right.width * right.height - left.width * left.height; if (pixelDiff !== 0) { return pixelDiff; } const qualityDiff = (QUALITY_RANK[right.quality] || 0) - (QUALITY_RANK[left.quality] || 0); if (qualityDiff !== 0) { return qualityDiff; } const formatDiff = (FORMAT_RANK[right.format] || 0) - (FORMAT_RANK[left.format] || 0); if (formatDiff !== 0) { return formatDiff; } return left.tieBreaker - right.tieBreaker; }); return sortedCandidates[0] || null; } async function resolveBestThumbnail(videoId) { if (!videoId) { return null; } if (!thumbnailCache.has(videoId)) { const resolver = (async () => { const candidates = buildThumbnailCandidates(videoId); const results = await Promise.allSettled(candidates.map(loadImageInfo)); const availableImages = results .filter((result) => result.status === 'fulfilled') .map((result) => result.value) .filter((image) => image.width > 0 && image.height > 0); const bestThumbnail = chooseBestThumbnail(availableImages); if (!bestThumbnail) { thumbnailCache.delete(videoId); } return bestThumbnail; })().catch((error) => { thumbnailCache.delete(videoId); throw error; }); thumbnailCache.set(videoId, resolver); } return thumbnailCache.get(videoId); } function ensureSlot() { if (slot && document.contains(slot)) { return slot; } slot = document.createElement('div'); slot.id = SLOT_ID; slot.classList.add('is-hidden'); return slot; } function ensureButton() { if (button && document.contains(button)) { return button; } button = document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; button.classList.add('is-floating'); button.textContent = TEXT.download; button.title = TEXT.downloadTitle; button.addEventListener('click', handleDownloadClick); document.body.appendChild(button); return button; } function getOwnerActionPlacement() { for (const selector of OWNER_ACTION_ANCHOR_SELECTORS) { const matchedNode = document.querySelector(selector); if (!matchedNode) { continue; } const anchor = matchedNode.closest('#subscribe-button') || matchedNode.closest('ytd-subscription-notification-toggle-button-renderer') || matchedNode.closest('ytd-button-renderer') || matchedNode; if (anchor.parentElement) { return { anchor, container: anchor.parentElement }; } } for (const selector of OWNER_ACTION_ROW_SELECTORS) { const container = document.querySelector(selector); if (container) { return { anchor: container.lastElementChild, container }; } } return null; } function setButtonState(label, disabled, title) { const downloadButton = ensureButton(); downloadButton.textContent = label; downloadButton.disabled = disabled; if (title) { downloadButton.title = title; } } function embedButtonIntoOwnerActions() { const downloadButton = ensureButton(); const downloadSlot = ensureSlot(); const placement = getOwnerActionPlacement(); if (!placement || !placement.container || window.location.hostname === 'm.youtube.com') { return false; } const { anchor, container } = placement; if (downloadSlot.parentElement !== container || downloadSlot.previousElementSibling !== anchor) { container.insertBefore(downloadSlot, anchor ? anchor.nextSibling : null); } if (downloadButton.parentElement !== downloadSlot) { downloadSlot.appendChild(downloadButton); } downloadSlot.classList.remove('is-hidden'); downloadButton.classList.remove('is-floating'); downloadButton.classList.add('is-embedded'); return true; } function floatButtonOnPage() { const downloadButton = ensureButton(); const downloadSlot = ensureSlot(); downloadSlot.classList.add('is-hidden'); if (downloadButton.parentElement !== document.body) { document.body.appendChild(downloadButton); } downloadButton.classList.remove('is-embedded'); downloadButton.classList.add('is-floating'); } function updateButtonPlacement() { const downloadButton = ensureButton(); const downloadSlot = ensureSlot(); const videoId = getVideoId(); if (!videoId) { downloadButton.classList.add('is-hidden'); downloadSlot.classList.add('is-hidden'); return; } downloadButton.classList.remove('is-hidden'); if (embedButtonIntoOwnerActions()) { return; } floatButtonOnPage(); } function triggerBrowserDownload(url, filename) { return fetch(url) .then((response) => { if (!response.ok) { throw new Error(`Failed to fetch thumbnail: ${response.status}`); } return response.blob(); }) .then((blob) => { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); }); } function openThumbnailInNewTab(url) { const openedWindow = window.open(url, '_blank', 'noopener'); if (!openedWindow) { throw new Error('Unable to open thumbnail preview in a new tab.'); } } function downloadWithTampermonkey(url, filename) { return new Promise((resolve, reject) => { if (typeof GM_download === 'function') { GM_download({ url, name: filename, saveAs: false, onload: resolve, onerror: reject, ontimeout: () => reject(new Error('Download timed out.')) }); return; } if (typeof GM !== 'undefined' && GM && typeof GM.download === 'function') { Promise.resolve(GM.download({ url, name: filename, saveAs: false })) .then(resolve) .catch(reject); return; } if (typeof GM_download !== 'function') { reject(new Error('GM_download is not available.')); return; } }); } function setBusyState(active, videoId = '') { isBusy = active; busyVideoId = active ? videoId : ''; } async function handleDownloadClick() { const videoId = getVideoId(); if (!videoId) { setButtonState(TEXT.notVideo, false, TEXT.notVideoTitle); return; } setBusyState(true, videoId); setButtonState(TEXT.resolving, true, TEXT.resolvingTitle); try { const bestThumbnail = await resolveBestThumbnail(videoId); if (!bestThumbnail) { setBusyState(false); setButtonState(TEXT.unavailable, false, TEXT.unavailableTitle); return; } const title = sanitizeFileName(getVideoTitle()); const channelName = sanitizeFileName(getChannelName()); const filenameBase = channelName ? `${channelName}_${title}` : title; const filename = `${filenameBase}.${bestThumbnail.format}`; let openedInNewTab = false; setButtonState(TEXT.downloading, true, `\u6b63\u5728\u4e0b\u8f7d ${bestThumbnail.width}x${bestThumbnail.height} \u5c01\u9762`); try { await downloadWithTampermonkey(bestThumbnail.url, filename); } catch (tampermonkeyError) { console.warn('[YT Thumbnail Downloader] GM_download failed, falling back to fetch:', tampermonkeyError); try { await triggerBrowserDownload(bestThumbnail.url, filename); } catch (browserError) { console.warn('[YT Thumbnail Downloader] Fetch download failed, opening thumbnail in new tab:', browserError); openThumbnailInNewTab(bestThumbnail.url); openedInNewTab = true; } } setBusyState(false); if (openedInNewTab) { setButtonState(TEXT.opened, false, TEXT.openedTitle); } else { setButtonState(TEXT.downloaded, false, `\u5df2\u4e0b\u8f7d\u6700\u9ad8\u753b\u8d28\u5c01\u9762: ${bestThumbnail.width}x${bestThumbnail.height}`); } window.setTimeout(() => { if (getVideoId() === videoId) { setButtonState(TEXT.download, false, TEXT.downloadTitle); } }, 1800); } catch (error) { console.error('[YT Thumbnail Downloader] Download failed:', error); setBusyState(false); setButtonState(TEXT.failed, false, TEXT.failedTitle); } } function warmThumbnailCache() { const videoId = getVideoId(); if (!videoId) { return; } resolveBestThumbnail(videoId) .then((bestThumbnail) => { if (!bestThumbnail || getVideoId() !== videoId || (isBusy && busyVideoId === videoId)) { return; } setButtonState( TEXT.download, false, `\u5f53\u524d\u53ef\u7528\u6700\u9ad8\u753b\u8d28: ${bestThumbnail.width}x${bestThumbnail.height}` ); }) .catch((error) => { console.warn('[YT Thumbnail Downloader] Thumbnail probing failed:', error); }); } function refreshButton() { updateButtonPlacement(); if (!isVideoPage()) { return; } warmThumbnailCache(); } function scheduleRefresh() { window.clearTimeout(refreshTimer); refreshTimer = window.setTimeout(() => { if (window.location.href !== currentUrl) { currentUrl = window.location.href; } refreshButton(); }, 120); } function setupObservers() { const observer = new MutationObserver(() => { scheduleRefresh(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); window.addEventListener('yt-navigate-finish', scheduleRefresh, true); window.addEventListener('yt-page-data-updated', scheduleRefresh, true); window.addEventListener('popstate', scheduleRefresh, true); } function init() { currentUrl = window.location.href; ensureButton(); refreshButton(); setupObservers(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();