Greasy Fork is available in English.
Add a button on YouTube to download the highest-quality available video thumbnail.
// ==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
// ==/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();
}
})();