Rehost RED Album & Artist Covers: intelligent quality detection, multi-source artwork picker (Discogs, MusicBrainz, Apple Music, Qobuz, Bandcamp, Deezer, Tidal), smart alt-cover management, and upload to ptpimg/imgbb/catbox/TheSunGod/custom hosts.
// ==UserScript==
// @name CoverUp
// @version 7.0.51
// @description Rehost RED Album & Artist Covers: intelligent quality detection, multi-source artwork picker (Discogs, MusicBrainz, Apple Music, Qobuz, Bandcamp, Deezer, Tidal), smart alt-cover management, and upload to ptpimg/imgbb/catbox/TheSunGod/custom hosts.
// @match https://redacted.sh/torrents.php*
// @match https://redacted.sh/artist.php*
// @match https://ptpimg.me/*.php*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect api.discogs.com
// @connect i.discogs.com
// @connect ptpimg.me
// @connect redacted.sh
// @connect api.imgbb.com
// @connect i.ibb.co
// @connect catbox.moe
// @connect files.catbox.moe
// @connect itunes.apple.com
// @connect is1-ssl.mzstatic.com
// @connect is2-ssl.mzstatic.com
// @connect is3-ssl.mzstatic.com
// @connect is4-ssl.mzstatic.com
// @connect is5-ssl.mzstatic.com
// @connect api.deezer.com
// @connect cdn-images.dzcdn.net
// @connect f4.bcbits.com
// @connect bandcamp.com
// @connect open.spotify.com
// @connect coverartarchive.org
// @connect musicbrainz.org
// @connect amazon.com
// @connect m.media-amazon.com
// @connect api.qobuz.com
// @connect archive.org
// @connect open.qobuz.com
// @connect static.qobuz.com
// @connect thesungod.xyz
// @connect cdn.thesungod.xyz
// @connect *
// @license MIT
// @namespace https://greasyfork.org/users/1568924
// ==/UserScript==
(function() {
'use strict';
// --- CONFIGURATION ---
const MAX_DIMENSION = 3000;
const JPEG_QUALITY = 0.95;
const MIN_RESOLUTION = 500;
const REHOST_TRIGGERS = ['imgur.com', 'ptpimg.me'];
// Domains that are valid permanent rehost destinations — images already here are "done"
const REHOST_DOMAINS = [
'ptpimg.me', 'imgbb.com', 'i.imgbb.com', 'ibb.co',
'catbox.moe', 'files.catbox.moe',
'ra.thesungod.xyz',
];
// Source domains that produce temporary/expiring URLs — rehost recommended even if image loads fine
const SOURCE_DOMAINS = [
'i.discogs.com', 'coverartarchive.org', 'archive.org',
'musicbrainz.org', 'lastfm.freetls.fastly.net', 'last.fm',
'static.qobuz.com', 'mzstatic.com', 'is1-ssl.mzstatic.com',
'i.scdn.co', 'e.snmc.io',
'f4.bcbits.com', 'bcbits.com',
'cdn-images.dzcdn.net',
'm.media-amazon.com',
'resources.tidal.com',
'i.scdn.co', 'spotifycdn.com',
];
function isOnRehostDomain(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return REHOST_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
} catch { return false; }
}
function isOnSourceDomain(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return SOURCE_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
} catch { return false; }
}
const PTPIMG_DEFAULT_KEY = '';
const IMGBB_DEFAULT_KEY = '';
function getImgbbKey() { return GM_getValue('imgbbAPIKey', IMGBB_DEFAULT_KEY); }
function getPtpimgKey() { return GM_getValue('ptpimgAPIKey', PTPIMG_DEFAULT_KEY); }
function getCatboxHash() { return GM_getValue('catboxUserHash', ''); }
function setImgbbKey(key) { GM_setValue('imgbbAPIKey', key); }
function setCatboxHash(h) { GM_setValue('catboxUserHash', h); }
function getSungodKey() { return GM_getValue('sungodAPIKey', ''); }
function setSungodKey(key) { GM_setValue('sungodAPIKey', key); }
function getCustomUploadUrl() { return GM_getValue('customUploadUrl', ''); }
function getCustomFileField() { return GM_getValue('customFileField', 'file'); }
function getCustomResponsePath() { return GM_getValue('customResponsePath', 'plaintext'); }
function setCustomUploadUrl(v) { GM_setValue('customUploadUrl', v); }
function setCustomFileField(v) { GM_setValue('customFileField', v); }
function setCustomResponsePath(v) { GM_setValue('customResponsePath', v); }
function getPreferredFallbackHost() { return GM_getValue('preferredFallbackHost', ''); }
function setPreferredFallbackHost(host) { GM_setValue('preferredFallbackHost', host); }
// Persist rehosted URL mappings across page loads (survives redirects)
function getRehostedUrl(originalUrl) { return GM_getValue('rehosted_' + originalUrl, ''); }
function setRehostedUrl(originalUrl, newUrl) { GM_setValue('rehosted_' + originalUrl, newUrl); }
function chooseFallbackHost(callback) {
const current = getPreferredFallbackHost();
if (current === 'imgbb' || current === 'catbox' || current === 'sungod') {
callback(current);
return;
}
if (current === 'custom' && getCustomUploadUrl()) {
callback('custom');
return;
}
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.92);z-index:100000;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = `
<div style="background:#1a1a1a;padding:30px;border-radius:12px;border:2px solid #444;max-width:560px;width:92%;color:#fff;font-family:sans-serif;">
<h2 style="margin-top:0;color:#4CAF50;">Choose fallback image host</h2>
<p style="color:#ccc;line-height:1.6;margin-bottom:18px;">ptpimg is unavailable or not configured. Which service should this script use for uploads?</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
<button id="choose-imgbb" style="padding:16px;background:#222;border:2px solid #555;border-radius:10px;color:#fff;cursor:pointer;text-align:left;">
<div style="font-weight:bold;font-size:15px;margin-bottom:5px;">imgbb</div>
<div style="font-size:12px;color:#aaa;line-height:1.5;">Requires a free API key. Good if you already use imgbb.</div>
</button>
<button id="choose-catbox" style="padding:16px;background:#222;border:2px solid #555;border-radius:10px;color:#fff;cursor:pointer;text-align:left;">
<div style="font-weight:bold;font-size:15px;margin-bottom:5px;">catbox</div>
<div style="font-size:12px;color:#aaa;line-height:1.5;">No API key required for anonymous uploads. Simpler default.</div>
</button>
<div id="sungod-section" style="background:#222;border:2px solid #555;border-radius:10px;padding:16px;">
<div style="font-weight:bold;font-size:15px;margin-bottom:6px;color:#fff;">TheSunGod ☀️</div>
<div style="font-size:12px;color:#aaa;line-height:1.5;margin-bottom:10px;">Uses the TheSunGod API. Supports direct URL rehosting via <code style="color:#aaa;">/api/image/rehost_new</code>.</div>
<input id="sungod-api-key" type="text" placeholder="Your TheSunGod API key"
value="${getSungodKey()}"
style="width:100%;padding:9px 10px;background:#111;border:1px solid #555;color:#fff;border-radius:6px;font-size:13px;box-sizing:border-box;margin-bottom:10px;">
<button id="choose-sungod" style="width:100%;padding:10px;background:#f59e0b;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">Use TheSunGod</button>
</div>
</div>
<div id="custom-host-section" style="background:#222;border:2px solid #555;border-radius:10px;padding:16px;margin-bottom:16px;">
<div style="font-weight:bold;font-size:15px;margin-bottom:6px;color:#fff;">Custom host</div>
<div style="font-size:12px;color:#aaa;line-height:1.5;margin-bottom:10px;">
Any host that accepts a simple file upload via HTTP POST (e.g. ShareX-compatible services).
Just enter the upload URL — the script will detect the response format automatically.<br>
<span style="color:#ff9800;">⚠ Not all hosts are guaranteed to work.</span>
</div>
<input id="custom-host-url" type="text" placeholder="https://example.com/upload"
value="${getCustomUploadUrl()}"
style="width:100%;padding:9px 10px;background:#111;border:1px solid #555;color:#fff;border-radius:6px;font-size:13px;box-sizing:border-box;margin-bottom:8px;">
<div id="custom-advanced-toggle" style="font-size:12px;color:#4CAF50;cursor:pointer;margin-bottom:0;">▶ Advanced options</div>
<div id="custom-advanced" style="display:none;margin-top:10px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div>
<label style="font-size:11px;color:#aaa;display:block;margin-bottom:4px;">File field name</label>
<input id="custom-file-field" type="text" placeholder="file" value="${getCustomFileField()}"
style="width:100%;padding:7px 9px;background:#111;border:1px solid #555;color:#fff;border-radius:5px;font-size:12px;box-sizing:border-box;">
</div>
<div>
<label style="font-size:11px;color:#aaa;display:block;margin-bottom:4px;">Response URL path</label>
<input id="custom-response-path" type="text" placeholder="plaintext or data.url" value="${getCustomResponsePath()}"
style="width:100%;padding:7px 9px;background:#111;border:1px solid #555;color:#fff;border-radius:5px;font-size:12px;box-sizing:border-box;">
</div>
</div>
<div style="font-size:11px;color:#666;margin-top:6px;">Leave blank to auto-detect. Use dot notation for JSON paths e.g. <code style="color:#aaa;">data.url</code></div>
</div>
<button id="choose-custom" style="margin-top:12px;width:100%;padding:10px;background:#2196F3;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">Use Custom Host</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
<label style="font-size:13px;color:#aaa;display:flex;align-items:center;gap:8px;cursor:pointer;">
<input id="remember-fallback-choice" type="checkbox">
Remember my choice
</label>
<button id="cancel-fallback-choice" style="padding:10px 16px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
// Ensure remember checkbox is unchecked by default
overlay.querySelector('#remember-fallback-choice').checked = false;
// Advanced toggle
overlay.querySelector('#custom-advanced-toggle').onclick = function() {
const adv = overlay.querySelector('#custom-advanced');
const open = adv.style.display !== 'none';
adv.style.display = open ? 'none' : 'block';
this.textContent = (open ? '▶' : '▼') + ' Advanced options';
};
function finish(host) {
const remember = overlay.querySelector('#remember-fallback-choice').checked;
if (remember) setPreferredFallbackHost(host);
document.body.removeChild(overlay);
callback(host);
}
overlay.querySelector('#choose-imgbb').onclick = () => finish('imgbb');
overlay.querySelector('#choose-catbox').onclick = () => finish('catbox');
overlay.querySelector('#choose-sungod').onclick = () => {
const key = overlay.querySelector('#sungod-api-key').value.trim();
if (!key) { alert('Please enter your TheSunGod API key.'); return; }
setSungodKey(key);
finish('sungod');
};
overlay.querySelector('#choose-custom').onclick = () => {
const url = overlay.querySelector('#custom-host-url').value.trim();
if (!url) { alert('Please enter an upload URL.'); return; }
if (!/^https?:\/\//i.test(url)) { alert('URL must start with http:// or https://'); return; }
setCustomUploadUrl(url);
const field = overlay.querySelector('#custom-file-field').value.trim();
if (field) setCustomFileField(field);
const path = overlay.querySelector('#custom-response-path').value.trim();
if (path) setCustomResponsePath(path);
finish('custom');
};
overlay.querySelector('#cancel-fallback-choice').onclick = () => {
document.body.removeChild(overlay);
callback('');
};
}
// ============================================================
// --- IMGBB UPLOAD ---
// ============================================================
function uploadToImgbb(blob, callback) {
const key = getImgbbKey();
if (!key) {
const entered = prompt('Enter your imgbb API key (get one at https://api.imgbb.com):');
if (!entered) { callback(null); return; }
setImgbbKey(entered.trim());
}
const reader = new FileReader();
reader.onload = function() {
const base64 = reader.result.split(',')[1];
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.imgbb.com/1/upload',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `key=${getImgbbKey()}&image=${encodeURIComponent(base64)}`,
onload: function(r) {
try {
const res = JSON.parse(r.responseText);
if (res.success) callback(res.data.url);
else { console.error('imgbb error:', res); callback(null); }
} catch(e) { console.error('imgbb parse error:', e); callback(null); }
},
onerror: function() { callback(null); }
});
};
reader.readAsDataURL(blob);
}
// ============================================================
// --- CATBOX UPLOAD ---
// ============================================================
function uploadToCatbox(blob, callback) {
const fd = new FormData();
fd.append('reqtype', 'fileupload');
const hash = getCatboxHash();
if (hash) fd.append('userhash', hash);
fd.append('fileToUpload', blob, 'cover.jpg');
GM_xmlhttpRequest({
method: 'POST',
url: 'https://catbox.moe/user/api.php',
data: fd,
timeout: 30000,
onload: function(r) {
const url = r.responseText && r.responseText.trim();
if (url && url.startsWith('https://')) callback(url);
else { console.error('Catbox error:', r.responseText); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
}
// ============================================================
// --- SUNGOD UPLOAD ---
// ============================================================
function uploadToSungod(blobOrUrl, callback) {
let key = getSungodKey();
if (!key) {
const entered = prompt('Enter your TheSunGod API key (get one at https://thesungod.xyz/settings/api):');
if (!entered) { callback(null); return; }
setSungodKey(entered.trim());
key = entered.trim();
}
// If we have a raw URL (string), use the faster rehost_new endpoint
if (typeof blobOrUrl === 'string') {
const fd = new FormData();
fd.append('api_key', key);
fd.append('link', blobOrUrl);
GM_xmlhttpRequest({
method: 'POST',
url: 'https://thesungod.xyz/api/image/rehost_new',
data: fd,
timeout: 30000,
onload: function(r) {
try {
const res = JSON.parse(r.responseText);
if (res.link && /^https?:\/\//i.test(res.link)) { callback(res.link); return; }
console.error('[SunGod] rehost_new unexpected response:', r.responseText);
callback(null);
} catch(e) { console.error('[SunGod] rehost_new parse error:', e); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
return;
}
// Blob upload via /api/image/upload
const fd = new FormData();
fd.append('api_key', key);
fd.append('image', blobOrUrl, 'cover.jpg');
GM_xmlhttpRequest({
method: 'POST',
url: 'https://thesungod.xyz/api/image/upload',
data: fd,
timeout: 30000,
onload: function(r) {
try {
const res = JSON.parse(r.responseText);
if (res.links && res.links.length > 0) { callback(res.links[0]); return; }
console.error('[SunGod] upload unexpected response:', r.responseText);
callback(null);
} catch(e) { console.error('[SunGod] upload parse error:', e); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
}
// ============================================================
// --- CUSTOM HOST UPLOAD ---
// ============================================================
function uploadToCustomHost(blob, callback) {
const uploadUrl = getCustomUploadUrl();
if (!uploadUrl) { callback(null); return; }
const fileField = getCustomFileField() || 'file';
const fd = new FormData();
fd.append(fileField, blob, 'cover.jpg');
GM_xmlhttpRequest({
method: 'POST',
url: uploadUrl,
data: fd,
timeout: 30000,
onload: function(r) {
try {
const body = r.responseText && r.responseText.trim();
if (!body) { console.error('Custom host: empty response'); callback(null); return; }
// Strategy 1: plain text response that looks like a URL
if (/^https?:\/\//i.test(body)) {
callback(body);
return;
}
// Strategy 2: stored response path (e.g. 'data.url')
const storedPath = getCustomResponsePath();
if (storedPath && storedPath !== 'plaintext') {
try {
const json = JSON.parse(body);
const val = storedPath.split('.').reduce((o, k) => o && o[k], json);
if (val && /^https?:\/\//i.test(val)) { callback(val); return; }
} catch(e) {}
}
// Strategy 3: try common JSON paths automatically
try {
const json = JSON.parse(body);
const paths = ['url','data.url','image.url','files.0.url','file.url','link','data.link','location'];
for (const path of paths) {
const val = path.split('.').reduce((o, k) => {
if (o === null || o === undefined) return undefined;
// handle numeric indices
return isNaN(k) ? o[k] : o[parseInt(k)];
}, json);
if (val && /^https?:\/\//i.test(val)) {
// Save this path for next time
setCustomResponsePath(path);
callback(val);
return;
}
}
console.error('Custom host: could not find URL in JSON response:', body);
callback(null);
} catch(e) {
console.error('Custom host: unparseable response:', body);
callback(null);
}
} catch(e) {
console.error('Custom host error:', e);
callback(null);
}
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
}
// ============================================================
// --- PTPIMG CANARY CHECK ---
// ============================================================
function ptpimgIsWorking(apiKey, callback) {
const canaryBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==';
const binary = atob(canaryBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: 'image/png' });
const fd = new FormData();
fd.append('file-upload[]', blob, 'canary.png');
fd.append('api_key', apiKey);
GM_xmlhttpRequest({
method: 'POST', url: 'https://ptpimg.me/upload.php', data: fd, timeout: 8000,
onload: function(r) {
try {
const res = JSON.parse(r.responseText)[0];
const url = `https://ptpimg.me/${res.code}.${res.ext}`;
GM_xmlhttpRequest({
method: 'HEAD', url, timeout: 5000,
onload: function(r2) { callback(r2.status === 200); },
onerror: function() { callback(false); },
ontimeout: function() { callback(false); }
});
} catch(e) { callback(false); }
},
onerror: function() { callback(false); },
ontimeout: function() { callback(false); }
});
}
// ============================================================
// --- UNIFIED UPLOAD WITH FALLBACK: ptpimg → imgbb → catbox ---
// ============================================================
function uploadWithFallback(blob, oldUrl, link, callback) {
const apiKey = getPtpimgKey();
function uploadToPreferredHost() {
chooseFallbackHost(function(host) {
if (!host) {
link.textContent = 'Upload cancelled';
link.style.color = 'red';
return;
}
if (host === 'imgbb') {
link.textContent = 'Uploading to imgbb...';
link.style.color = 'orange';
tryImgbb(blob, link, callback);
} else if (host === 'catbox') {
link.textContent = 'Uploading to catbox...';
link.style.color = 'orange';
tryCatbox(blob, link, callback);
} else if (host === 'sungod') {
link.textContent = 'Uploading to TheSunGod...';
link.style.color = 'orange';
trySungod(blob, oldUrl, link, callback);
} else if (host === 'custom') {
const hostLabel = getCustomUploadUrl()
? new URL(getCustomUploadUrl()).hostname
: 'custom host';
link.textContent = `Uploading to ${hostLabel}...`;
link.style.color = 'orange';
uploadToCustomHost(blob, function(url) {
if (url) callback(url);
else {
link.textContent = 'Custom host failed — trying catbox...';
link.style.color = 'orange';
tryCatbox(blob, link, callback);
}
});
}
});
}
if (apiKey) {
ptpimgIsWorking(apiKey, function(working) {
if (working) {
const fd = new FormData();
fd.append('file-upload[]', blob, 'cover.jpg');
fd.append('api_key', apiKey);
GM_xmlhttpRequest({
method: 'POST', url: 'https://ptpimg.me/upload.php', data: fd,
onload: function(r) {
try {
const res = JSON.parse(r.responseText)[0];
callback(`https://ptpimg.me/${res.code}.${res.ext}`);
} catch(e) {
console.warn('ptpimg upload failed, using preferred fallback host');
uploadToPreferredHost();
}
},
onerror: function() { uploadToPreferredHost(); }
});
} else {
console.warn('ptpimg CDN down, using preferred fallback host');
uploadToPreferredHost();
}
});
} else {
uploadToPreferredHost();
}
}
function tryImgbb(blob, link, callback) {
link.textContent = 'Uploading to imgbb...';
link.style.color = 'orange';
uploadToImgbb(blob, function(url) {
if (url) callback(url);
else {
console.warn('imgbb failed, trying catbox');
tryCatbox(blob, link, callback);
}
});
}
function tryCatbox(blob, link, callback) {
link.textContent = 'Uploading to catbox...';
link.style.color = 'orange';
uploadToCatbox(blob, function(url) {
if (url) callback(url);
else { link.textContent = 'Upload failed (all services down)'; link.style.color = 'red'; }
});
}
function trySungod(blob, oldUrl, link, callback) {
link.textContent = 'Uploading to TheSunGod...';
link.style.color = 'orange';
// Prefer rehost_new if we have the original URL (faster, no re-encode)
// but not if it's the noartwork placeholder
const isPlaceholder = oldUrl && typeof oldUrl === 'string' && oldUrl.includes('noartwork');
const target = (!isPlaceholder && oldUrl && typeof oldUrl === 'string' && /^https?:\/\//i.test(oldUrl))
? oldUrl : blob;
uploadToSungod(target, function(url) {
if (url) callback(url);
else {
// If rehost failed and we used a URL, retry with blob
if (typeof target === 'string' && blob instanceof Blob) {
uploadToSungod(blob, function(url2) {
if (url2) callback(url2);
else {
link.textContent = 'TheSunGod failed — trying catbox...';
link.style.color = 'orange';
tryCatbox(blob, link, callback);
}
});
} else {
link.textContent = 'TheSunGod failed — trying catbox...';
link.style.color = 'orange';
tryCatbox(blob, link, callback);
}
}
});
}
// ============================================================
// --- DISCOGS TOKEN MANAGEMENT ---
// ============================================================
function getDiscogsToken() { return GM_getValue('discogsToken', ''); }
function setDiscogsToken(token) { GM_setValue('discogsToken', token); }
function showDiscogsSetup(callback) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.92);z-index:100000;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = `
<div style="background:#1a1a1a;padding:35px;border-radius:12px;border:2px solid #444;max-width:550px;color:#fff;font-family:sans-serif;">
<h2 style="margin-top:0;color:#4CAF50;">🎵 Discogs Setup Required</h2>
<p style="color:#ccc;line-height:1.6;margin-bottom:20px;">To search Discogs for artwork, this script needs your API token.</p>
<div style="background:#252525;padding:20px;border-radius:8px;border-left:4px solid #4CAF50;margin-bottom:20px;">
<h3 style="margin:0 0 10px 0;font-size:16px;color:#4CAF50;">How to get your token:</h3>
<ol style="margin:0;padding-left:20px;color:#aaa;line-height:1.8;">
<li>Click "Open Discogs" below to login</li>
<li>Go to Settings → Developers</li>
<li>Generate a new token</li>
<li>Copy and paste it below</li>
</ol>
</div>
<input type="text" id="discogs-token-input" placeholder="Paste your Discogs token here..."
style="width:100%;padding:12px;background:#222;border:1px solid #555;color:#fff;border-radius:6px;margin-bottom:20px;font-family:monospace;box-sizing:border-box;">
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="open-discogs-btn" style="padding:12px 24px;background:#2196F3;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">Open Discogs</button>
<button id="save-token-btn" style="padding:12px 24px;background:#4CAF50;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">Save Token</button>
<button id="cancel-discogs-setup" style="padding:12px 24px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#discogs-token-input');
input.focus();
overlay.querySelector('#open-discogs-btn').onclick = () => window.open('https://www.discogs.com/settings/developers', '_blank');
overlay.querySelector('#save-token-btn').onclick = () => {
const token = input.value.trim();
if (token) { setDiscogsToken(token); document.body.removeChild(overlay); callback(true); }
else alert('Please enter a valid token');
};
overlay.querySelector('#cancel-discogs-setup').onclick = () => { document.body.removeChild(overlay); callback(false); };
input.addEventListener('keypress', e => { if (e.key === 'Enter') overlay.querySelector('#save-token-btn').click(); });
}
// ============================================================
// --- PTPIMG.ME DASHBOARD LOGIC ---
// ============================================================
if (window.location.hostname.includes('ptpimg.me')) {
const detectKeyAndShowModal = () => {
const pageText = document.body.innerText;
const apiInput = document.querySelector('input[value*="-"]');
const hasApiKey = pageText.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i) ||
(apiInput && apiInput.value.length === 36);
if (hasApiKey && !document.getElementById('rehost-modal-overlay')) {
const overlay = document.createElement('div');
overlay.id = 'rehost-modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = `
<div style="background:#fff;color:#333;padding:40px;border-radius:15px;text-align:center;max-width:500px;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-family:sans-serif;">
<div style="font-size:50px;margin-bottom:20px;">✅</div>
<h2 style="margin:0 0 15px 0;color:#27ae60;">API Key Ready!</h2>
<p style="font-size:16px;line-height:1.5;color:#555;">The script has detected your credentials.<br><br>
<b>Click the button below</b> to close this tab. If a Tampermonkey window appears, click <b>"Always Allow"</b>.</p>
<button id="close-rehost-tab" style="margin-top:25px;padding:15px 30px;background:#27ae60;color:white;border:none;border-radius:8px;font-size:18px;font-weight:bold;cursor:pointer;width:100%;">Close Tab & Continue</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('close-rehost-tab').onclick = () => window.close();
}
};
detectKeyAndShowModal();
setInterval(detectKeyAndShowModal, 1500);
return;
}
// ============================================================
// --- PARSE ALBUM INFO FROM PAGE ---
// ============================================================
function parseAlbumInfo() {
const info = { artist: '', album: '', year: '' };
// Read artist and album from structured DOM elements — immune to other
// userscripts injecting text into the h2's textContent.
const artistLink = document.querySelector('.header h2 a[href*="artist.php"]');
if (artistLink) info.artist = artistLink.textContent.trim();
// Gazelle wraps the album title in <span dir="ltr">
const albumSpan = document.querySelector('.header h2 span[dir="ltr"]');
if (albumSpan) {
info.album = albumSpan.textContent.trim();
} else {
// Fallback: strip artist prefix from raw h2 text
const header = document.querySelector('.header h2');
if (header) {
const text = header.textContent.trim();
const match = text.match(/^(.+?)\s*[-–—]\s*(.+?)$/);
if (match) {
if (!info.artist) info.artist = match[1].trim();
info.album = match[2].trim();
} else { info.album = text; }
}
}
// Year from the edition/group info block
if (!info.year) {
const groupInfo = document.querySelector('.group_info');
if (groupInfo) {
const yearMatch = groupInfo.textContent.match(/\b(19|20)\d{2}\b/);
if (yearMatch) info.year = yearMatch[0];
}
}
// Strip metadata tags like [2001], [Anthology], [Deluxe Edition] from album title
info.album = info.album
.replace(/\s*\[[^\]]*\]\s*/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
return info;
}
// ============================================================
// --- MULTI-SOURCE IMAGE EXTRACTION ---
// ============================================================
function getPageSourceLinks() {
const links = [];
const seen = new Set();
document.querySelectorAll('a[href]').forEach(a => {
const href = a.href;
if (!href || seen.has(href)) return;
seen.add(href);
const label = a.textContent.trim() || new URL(href).hostname;
if (/discogs\.com\/(?:[^/]+\/)?(?:release|master)\/\d+/i.test(href))
links.push({ href, source: 'Discogs Direct', label });
else if (/musicbrainz\.org\/release\/[a-f0-9-]{36}/i.test(href))
links.push({ href, source: 'MusicBrainz', label });
else if (/(?:open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\//i.test(href))
links.push({ href, source: 'Qobuz', label });
else if (/music\.apple\.com.*\/album/i.test(href) || /itunes\.apple\.com.*\/album/i.test(href))
links.push({ href, source: 'Apple Music', label });
else if (/bandcamp\.com\/(album|music)/i.test(href) || /\.bandcamp\.com/i.test(href))
links.push({ href, source: 'Bandcamp', label });
else if (/deezer\.com\/(?:\w+\/)?album\/\d+/i.test(href))
links.push({ href, source: 'Deezer', label });
else if (/tidal\.com\/(album|browse\/album)/i.test(href))
links.push({ href, source: 'Tidal', label });
else if (/open\.spotify\.com\/album/i.test(href))
links.push({ href, source: 'Spotify', label });
else if (/amazon\.(com|co\.uk|de|fr).*\/(dp|gp\/product)/i.test(href))
links.push({ href, source: 'Amazon', label });
});
return links;
}
function resolveSourceImage(linkObj, callback) {
const { href, source } = linkObj;
if (source === 'Discogs Direct') resolveDiscogsRelease(href, callback);
else if (source === 'MusicBrainz') resolveMusicBrainz(href, callback);
else if (source === 'Apple Music') resolveAppleMusic(href, callback);
else if (source === 'Qobuz') resolveQobuz(href, callback);
else if (source === 'Deezer') resolveDeezer(href, callback);
else if (source === 'Bandcamp') resolveBandcamp(href, callback);
else if (source === 'Tidal') resolveViaOgImage(href, source, callback);
else if (source === 'Spotify') resolveViaOgImage(href, source, callback);
else if (source === 'Amazon') resolveViaOgImage(href, source, callback);
else callback(null);
}
// --- Discogs: resolve release or master via API, return all images ---
function resolveDiscogsRelease(href, callback) {
const token = getDiscogsToken();
const releaseMatch = href.match(/discogs\.com\/(?:[^/]+\/)?release\/(\d+)/i);
const masterMatch = href.match(/discogs\.com\/(?:[^/]+\/)?master\/(\d+)/i);
let resourceUrl = null;
let isMaster = false;
let masterId = null;
if (releaseMatch) { resourceUrl = `https://api.discogs.com/releases/${releaseMatch[1]}`; }
else if (masterMatch) { masterId = masterMatch[1]; resourceUrl = `https://api.discogs.com/masters/${masterId}`; isMaster = true; }
else { callback(null); return; }
const headers = { 'User-Agent': 'CoverUp/6.47' };
if (token) headers['Authorization'] = `Discogs token=${token}`;
function releaseSummary(rel) {
const parts = [];
if (rel.year || rel.released) parts.push(rel.year || rel.released);
if (rel.country) parts.push(rel.country);
const fmt = Array.isArray(rel.format) ? rel.format : [];
if (fmt.length) parts.push(fmt.join(', '));
const lbl = Array.isArray(rel.label) ? rel.label : [];
if (lbl.length) parts.push(lbl.slice(0, 2).join(', '));
return parts.join(' \u2022 ') || 'Discogs release';
}
function emitImages(data, pageHref, prefix) {
const images = data.images || [];
if (images.length === 0) {
if (data.thumb) callback({ imageUrl: data.thumb, displayUrl: pageHref, source: 'Discogs Direct', label: prefix ? `${prefix} \u2014 Thumb` : 'Thumb' });
return;
}
const sorted = [...images.filter(i => i.type === 'primary'), ...images.filter(i => i.type !== 'primary')];
sorted.forEach((img, idx) => callback({
imageUrl: img.uri,
displayUrl: pageHref,
source: 'Discogs Direct',
label: prefix ? `${prefix} \u2014 ${idx === 0 ? 'Primary' : 'Image ' + (idx + 1)}` : (idx === 0 ? 'Primary' : 'Image ' + (idx + 1))
}));
}
GM_xmlhttpRequest({
method: 'GET', url: resourceUrl, headers, timeout: 10000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
emitImages(data, href, isMaster ? 'Master' : 'Release');
if (!isMaster) {
callback({
source: 'Discogs Releases', type: 'release_list', displayUrl: href,
releases: [{
id: data.id,
title: data.title || 'Release',
year: data.year || '',
country: data.country || '',
format: data.formats ? data.formats.map(f => f.name) : [],
label: data.labels ? data.labels.map(l => l.name) : [],
thumb: data.thumb || '',
coverImage: (data.images || []).find(i => i.type === 'primary')?.uri || data.thumb || '',
webUrl: data.uri || `https://www.discogs.com/release/${data.id}`,
summary: releaseSummary({ year: data.year, country: data.country,
format: data.formats ? data.formats.map(f => f.name) : [],
label: data.labels ? data.labels.map(l => l.name) : [] })
}]
});
}
} catch(e) { console.warn('[CoverUp] Discogs resolve error:', e); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
if (isMaster && masterId) {
const allVersions = [];
function emitAllVersions() {
if (!allVersions.length) return;
callback({
source: 'Discogs Releases', type: 'release_list', displayUrl: href,
releases: allVersions.map(rel => ({
id: rel.id,
title: rel.title || 'Release',
year: rel.released || rel.year || '',
country: rel.country || '',
format: Array.isArray(rel.format) ? rel.format : [],
label: Array.isArray(rel.label) ? rel.label : [],
thumb: rel.thumb || '',
coverImage: rel.thumb || '',
webUrl: `https://www.discogs.com/release/${rel.id}`,
summary: releaseSummary(rel)
}))
});
}
function fetchVersionsPage(page) {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.discogs.com/masters/${masterId}/versions?per_page=100&page=${page}&sort=released&sort_order=asc`,
headers, timeout: 15000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const versions = Array.isArray(data.versions) ? data.versions : [];
versions.forEach(v => allVersions.push(v));
const pagination = data.pagination || {};
const totalPages = pagination.pages || 1;
const currentPage = pagination.page || page;
if (currentPage < totalPages) {
setTimeout(() => fetchVersionsPage(currentPage + 1), 250);
} else {
emitAllVersions();
}
} catch(e) { console.warn('[CoverUp] Discogs versions page error:', e); }
},
onerror: function() { console.warn('[CoverUp] Discogs versions fetch failed page ' + page); },
ontimeout: function() { console.warn('[CoverUp] Discogs versions timeout page ' + page); }
});
}
fetchVersionsPage(1);
}
}
// --- MusicBrainz: Cover Art Archive API — all images for a release ---
function resolveMusicBrainz(href, callback) {
const mbidMatch = href.match(/musicbrainz\.org\/release\/([a-f0-9-]{36})/i);
if (!mbidMatch) { callback(null); return; }
GM_xmlhttpRequest({
method: 'GET',
url: `https://coverartarchive.org/release/${mbidMatch[1]}`,
headers: { 'User-Agent': 'RehostREDCovers/6.17 ( https://greasyfork.org/users/1568924 )' },
timeout: 10000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const images = data.images || [];
if (images.length === 0) { callback(null); return; }
const sorted = [
...images.filter(img => img.front),
...images.filter(img => !img.front)
];
sorted.forEach((img, i) => {
const imageUrl = (img.thumbnails && img.thumbnails['1200'])
? img.thumbnails['1200']
: (img.thumbnails && img.thumbnails.large)
? img.thumbnails.large
: img.image;
const types = img.types && img.types.length
? img.types.join(', ')
: (img.front ? 'Front' : 'Image');
callback({
imageUrl,
displayUrl: href,
source: 'MusicBrainz',
label: i === 0 ? types : `${types} ${i + 1}`
});
});
} catch(e) { console.warn('MusicBrainz CAA error:', e); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
}
// --- Apple Music: iTunes lookup API at max resolution ---
function resolveAppleMusic(href, callback) {
const idMatch = href.match(/\/album\/(?:[^/]+\/)?(\d+)/);
if (!idMatch) { callback(null); return; }
GM_xmlhttpRequest({
method: 'GET',
url: `https://itunes.apple.com/lookup?id=${idMatch[1]}&entity=album`,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const result = data.results && data.results[0];
if (result && result.artworkUrl100) {
const imageUrl = result.artworkUrl100
.replace(/\d+x\d+bb\.jpg$/, '10000x10000bb.jpg')
.replace(/\d+x\d+\.jpg$/, '10000x10000.jpg');
callback({ imageUrl, displayUrl: href, source: 'Apple Music' });
} else callback(null);
} catch(e) { console.warn('Apple Music lookup error:', e); callback(null); }
},
onerror: function() { callback(null); }
});
}
// --- Qobuz: all regional variants and open.qobuz.com ---
// Patterns: qobuz.com/gb-en/album/title/ID | open.qobuz.com/album/ID
function resolveQobuz(href, callback) {
const idMatch = href.match(/\/album\/(?:[^/]+\/)?([0-9a-zA-Z]+)\/?(?:[?#].*)?$/);
if (!idMatch) { resolveViaOgImage(href, 'Qobuz', callback); return; }
const albumId = idMatch[1];
const level1 = albumId.slice(-2) || '00'; // last 2 chars
const level2 = albumId.slice(-4,-2) || '00'; // chars before that
const cdnUrl = `https://static.qobuz.com/images/covers/${level1}/${level2}/${albumId}_org.jpg`;
const cdnUrl600 = `https://static.qobuz.com/images/covers/${level1}/${level2}/${albumId}_600.jpg`;
// Extract search query from URL slug — more reliable than album ID search
// e.g. /album/animal-style-wynona-bleach/g47k7vpgx1mgp -> "animal style wynona bleach"
const slugMatch = href.match(/\/album\/([^/]+)\/[^/]+$/);
const slugQuery = slugMatch ? slugMatch[1].replace(/-/g, ' ') : '';
const searchQuery = slugQuery || albumId;
function trySlugSearch() {
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.qobuz.com/api.json/0.2/album/search?query=${encodeURIComponent(searchQuery)}&limit=5`,
headers: { 'X-App-Id': '285473059', 'User-Agent': 'Mozilla/5.0' },
timeout: 8000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const items = (data.albums && data.albums.items) || [];
const match = items.find(it => String(it.id) === albumId) || items[0];
const img = match && match.image && (match.image.mega || match.image.large);
if (img) callback({ imageUrl: img, displayUrl: href, source: 'Qobuz' });
else callback(null);
} catch(e) { callback(null); }
},
onerror: function() { callback(null); }
});
}
// Try CDN URL via Image probe first (fast path for older/established releases)
// Fall back to slug search if CDN 404s or errors
const probe = new Image();
let settled = false;
const timer = setTimeout(() => {
if (!settled) { settled = true; trySlugSearch(); }
}, 4000);
probe.onload = () => {
if (settled) return; settled = true; clearTimeout(timer);
callback({ imageUrl: cdnUrl, displayUrl: href, source: 'Qobuz' });
};
probe.onerror = () => {
if (settled) return; settled = true; clearTimeout(timer);
trySlugSearch();
};
probe.src = cdnUrl;
// If _org 404s, also try _600
probe.onerror = () => {
const probe2 = new Image();
probe2.onload = () => {
if (settled) return; settled = true; clearTimeout(timer);
callback({ imageUrl: cdnUrl600, displayUrl: href, source: 'Qobuz' });
};
probe2.onerror = () => {
if (settled) return; settled = true; clearTimeout(timer);
trySlugSearch();
};
probe2.src = cdnUrl600;
};
}
// --- Deezer: public API returns cover_xl ---
function resolveDeezer(href, callback) {
const idMatch = href.match(/\/album\/(\d+)/);
if (!idMatch) { callback(null); return; }
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.deezer.com/album/${idMatch[1]}`,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
// Standard cover fields (may be empty for some regions)
let imageUrl = data.cover_xl || data.cover_big || data.cover_medium || data.cover;
// Fallback: construct URL from md5_image which is always present
if (!imageUrl && data.md5_image) {
imageUrl = `https://cdn-images.dzcdn.net/images/cover/${data.md5_image}/1000x1000-000000-80-0-0.jpg`;
}
if (imageUrl) callback({ imageUrl, displayUrl: href, source: 'Deezer' });
else callback(null);
} catch(e) { console.warn('Deezer API error:', e); callback(null); }
},
onerror: function() { callback(null); }
});
}
// --- Generic: fetch page HTML and extract og:image / twitter:image ---
// --- Bandcamp: extract art_id from embedded JSON for true original resolution ---
function resolveBandcamp(href, callback) {
GM_xmlhttpRequest({
method: 'GET', url: href, timeout: 10000,
onload: function(r) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(r.responseText, 'text/html');
// Bandcamp embeds release data in a <script data-tralbum> JSON blob
// which contains art_id — use that to construct the full-res URL
const tralbum = doc.querySelector('script[data-tralbum]');
if (tralbum) {
const data = JSON.parse(tralbum.dataset.tralbum);
const artId = data.art_id || (data.current && data.current.art_id);
if (artId) {
// _0 suffix = original resolution on Bandcamp's CDN
const imageUrl = `https://f4.bcbits.com/img/a${artId}_0.jpg`;
callback({ imageUrl, displayUrl: href, source: 'Bandcamp' });
return;
}
}
// Fallback: og:image with _7 → _0 bump
const og = doc.querySelector('meta[property="og:image"]') ||
doc.querySelector('meta[name="twitter:image"]') ||
doc.querySelector('meta[property="og:image:secure_url"]');
if (og && og.content) {
const imageUrl = og.content.replace(/_7\.(jpg|jpeg|png)/i, '_0.$1');
callback({ imageUrl, displayUrl: href, source: 'Bandcamp' });
} else callback(null);
} catch(e) { console.warn('Bandcamp parse error:', e); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
}
// --- Generic: fetch page HTML and extract og:image / twitter:image ---
function resolveViaOgImage(href, source, callback) {
GM_xmlhttpRequest({
method: 'GET', url: href, timeout: 10000,
onload: function(r) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(r.responseText, 'text/html');
const og = doc.querySelector('meta[property="og:image"]') ||
doc.querySelector('meta[name="twitter:image"]') ||
doc.querySelector('meta[property="og:image:secure_url"]');
if (og && og.content) callback({ imageUrl: og.content, displayUrl: href, source });
else callback(null);
} catch(e) { console.warn(`og:image parse error for ${source}:`, e); callback(null); }
},
onerror: function() { callback(null); },
ontimeout: function() { callback(null); }
});
}
// ============================================================
// --- DISCOGS KEYWORD SEARCH ---
// ============================================================
// Search Discogs using multiple query strategies, then fetch full release
// images for any result that has an empty cover_image in the search index.
function searchDiscogs(query, callback, albumInfo) {
const token = getDiscogsToken();
if (!token) { callback([]); return; }
function stripPunct(s) {
return (s || '').replace(/[?!&–—]/g, ' ').replace(/\s{2,}/g, ' ').trim();
}
function buildStrategies(rawQuery, info) {
const cleaned = stripPunct(rawQuery);
const artist = stripPunct(info && info.artist ? info.artist : '');
const album = stripPunct(info && info.album ? info.album : '');
// Structured field queries first (most precise), then freetext fallback
const C = (artist && album)
? 'artist=' + encodeURIComponent(artist) + '&release_title=' + encodeURIComponent(album) + '&type=release&per_page=10'
: null;
const D = album
? 'release_title=' + encodeURIComponent(album) + '&type=release&per_page=10'
: null;
// Freetext fallback — catches cases where field search returns nothing
const E = (artist && album)
? 'q=' + encodeURIComponent(artist + ' ' + album) + '&type=release&per_page=10'
: null;
const seen = new Set();
return [C, D, E].filter(s => s && !seen.has(s) && seen.add(s));
}
function fetchReleaseImages(releaseId, cb) {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.discogs.com/releases/' + releaseId + '?token=' + token,
headers: { 'User-Agent': 'REDCoverRehost/6.21' },
timeout: 15000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const images = (data.images || []).filter(function(img) { return img.uri && img.uri.startsWith('http'); });
cb(images);
} catch(e) { cb([]); }
},
onerror: function() { cb([]); },
ontimeout: function() { cb([]); }
});
}
const strategies = buildStrategies(query, albumInfo || {});
let strategyIndex = 0;
function tryNextStrategy() {
if (strategyIndex >= strategies.length) {
console.warn('[Rehost] All Discogs strategies exhausted — no results found.');
callback([]); return;
}
const params = strategies[strategyIndex++];
const fullUrl = 'https://api.discogs.com/database/search?' + params + '&token=' + token;
console.log('[Rehost] Discogs strategy', strategyIndex, ':', fullUrl.replace(token, 'TOKEN'));
GM_xmlhttpRequest({
method: 'GET',
url: fullUrl,
headers: { 'User-Agent': 'REDCoverRehost/6.21' },
timeout: 15000,
onload: function(r) {
console.log('[Rehost] Discogs response status:', r.status, '— body length:', r.responseText.length);
try {
const parsed = JSON.parse(r.responseText);
const results = parsed.results || [];
console.log('[Rehost] Discogs results count:', results.length,
results.map(function(x){ return x.id + ':' + (x.cover_image ? 'has_cover' : 'no_cover'); }));
// Filter results for relevance before accepting
const artistLow = (albumInfo && albumInfo.artist || '').toLowerCase();
const albumLow = (albumInfo && albumInfo.album || '').toLowerCase();
const relevant = results.filter(r => {
const t = (r.title || '').toLowerCase();
// Discogs title format is "Artist - Album"
// Check artist words first (most discriminating)
const artistWords = artistLow.split(/\s+/).filter(w => w.length > 2);
if (artistWords.length && !artistWords.some(w => t.includes(w))) return false;
// If no artist words matched nothing or artist is short/common,
// also check album words
const albumWords = albumLow.split(/\s+/).filter(w => w.length > 3);
if (!artistWords.length && albumWords.length && !albumWords.some(w => t.includes(w))) return false;
return true;
});
console.log('[Rehost] Discogs relevant after filter:', relevant.length,
'from', results.length, 'artistLow:', artistLow,
'titles:', results.slice(0,3).map(r => r.title));
if (relevant.length === 0) { tryNextStrategy(); return; }
const results_filtered = relevant;
// Re-assign for downstream use
results.length = 0; results_filtered.forEach(r => results.push(r));
if (results.length === 0) { tryNextStrategy(); return; }
const withoutCover = results.filter(function(rel) {
return !rel.cover_image || rel.cover_image.includes('spacer.gif');
});
console.log('[Rehost] Results needing release fetch:', withoutCover.length);
if (withoutCover.length === 0) { callback(results); return; }
const toFetch = withoutCover.slice(0, 5);
let fetched = 0;
toFetch.forEach(function(rel) {
fetchReleaseImages(rel.id, function(images) {
console.log('[Rehost] Release', rel.id, 'fetch returned', images.length, 'images');
if (images.length > 0) {
const primary = images.find(function(i) { return i.type === 'primary'; }) || images[0];
rel.cover_image = primary.uri;
rel._allImages = images;
}
fetched++;
if (fetched === toFetch.length) {
console.log('[Rehost] All release fetches done — calling back with', results.length, 'results');
callback(results);
}
});
});
} catch(e) { console.error('[Rehost] Discogs parse error:', e); tryNextStrategy(); }
},
onerror: function(e) { console.error('[Rehost] Discogs XHR error:', e); tryNextStrategy(); },
ontimeout: function() { console.warn('[Rehost] Discogs XHR timeout'); tryNextStrategy(); }
});
}
tryNextStrategy();
}
// ============================================================
// --- UNIFIED MULTI-SOURCE IMAGE PICKER OVERLAY ---
// ============================================================
const SOURCE_BADGE_COLORS = {
'Discogs Direct': '#333',
'MusicBrainz': '#ba478f',
'Apple Music': '#fc3c44',
'Qobuz': '#0070c9',
'Deezer': '#a238ff',
'Bandcamp': '#1da0c3',
'Tidal': '#222',
'Spotify': '#1DB954',
'Amazon': '#ff9900',
};
// ============================================================
// --- DEEZER SEARCH ---
// ============================================================
function searchDeezer(albumInfo, callback) {
const artist = (albumInfo.artist || '').trim();
const album = (albumInfo.album || '').trim();
if (!artist && !album) { callback([]); return; }
const q = [artist, album].filter(Boolean).join(' ');
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.deezer.com/search/album?q=${encodeURIComponent(q)}&limit=10`,
timeout: 10000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const items = data.data || [];
callback(items);
} catch(e) { callback([]); }
},
onerror: function() { callback([]); },
ontimeout: function() { callback([]); }
});
}
// ============================================================
// --- MUSICBRAINZ SEARCH ---
// ============================================================
function searchMusicBrainz(albumInfo, callback) {
const artist = (albumInfo.artist || '').trim();
const album = (albumInfo.album || '').trim();
if (!artist && !album) { callback([]); return; }
// MusicBrainz Lucene query: artist + release fields
let luceneQuery;
if (artist && album) {
luceneQuery = `artist:"${artist}" AND release:"${album}"`;
} else if (album) {
luceneQuery = `release:"${album}"`;
} else {
luceneQuery = `artist:"${artist}"`;
}
GM_xmlhttpRequest({
method: 'GET',
url: `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(luceneQuery)}&fmt=json&limit=8`,
headers: { 'User-Agent': 'CoverUp/6.37 ( https://greasyfork.org/users/1568924 )' },
timeout: 12000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const releases = (data.releases || []).filter(rel => rel.id);
if (releases.length === 0) { callback([]); return; }
const results = [];
let pending = releases.length;
releases.forEach(rel => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://coverartarchive.org/release/${rel.id}`,
headers: { 'User-Agent': 'CoverUp/6.37 ( https://greasyfork.org/users/1568924 )' },
timeout: 8000,
onload: function(r2) {
try {
const caa = JSON.parse(r2.responseText);
if (caa.images && caa.images.length > 0) {
const sorted = [
...caa.images.filter(i => i.front),
...caa.images.filter(i => !i.front)
];
sorted.forEach(img => {
const imageUrl = (img.thumbnails && img.thumbnails['1200'])
|| (img.thumbnails && img.thumbnails.large)
|| img.image;
results.push({
imageUrl,
title: rel.title,
artist: rel['artist-credit'] && rel['artist-credit'][0]
? rel['artist-credit'][0].name : '',
date: rel.date || rel['release-group'] && rel['release-group']['first-release-date'] || '',
label: img.front ? 'Front' : (img.types && img.types[0] || 'Image'),
mbid: rel.id
});
});
}
} catch(e) {}
if (--pending === 0) callback(results);
},
onerror: function() { if (--pending === 0) callback(results); },
ontimeout: function() { if (--pending === 0) callback(results); }
});
});
} catch(e) { callback([]); }
},
onerror: function() { callback([]); },
ontimeout: function() { callback([]); }
});
}
// ============================================================
// --- QOBUZ SEARCH ---
// ============================================================
function searchQobuz(albumInfo, callback) {
const artist = (albumInfo.artist || '').trim();
const album = (albumInfo.album || '').trim();
if (!artist && !album) { callback([]); return; }
// Qobuz public search — artist+album gives much better results than freetext
const q = [artist, album].filter(Boolean).join(' ');
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.qobuz.com/api.json/0.2/album/search?query=${encodeURIComponent(q)}&limit=10`,
headers: {
'X-App-Id': '285473059',
'User-Agent': 'Mozilla/5.0'
},
timeout: 12000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const items = (data.albums && data.albums.items) || [];
// Boost exact-match items to the top
const albumLower = album.toLowerCase();
const artistLower = artist.toLowerCase();
items.sort((a, b) => {
const aMatch = (a.title || '').toLowerCase().includes(albumLower) &&
(a.artist && a.artist.name || '').toLowerCase().includes(artistLower);
const bMatch = (b.title || '').toLowerCase().includes(albumLower) &&
(b.artist && b.artist.name || '').toLowerCase().includes(artistLower);
return (bMatch ? 1 : 0) - (aMatch ? 1 : 0);
});
callback(items);
} catch(e) { callback([]); }
},
onerror: function() { callback([]); },
ontimeout: function() { callback([]); }
});
}
// ============================================================
// --- BANDCAMP SEARCH ---
// ============================================================
function searchBandcamp(albumInfo, callback) {
const artist = (albumInfo.artist || '').trim();
const album = (albumInfo.album || '').trim();
if (!album) { callback([]); return; }
// Skip artist for VA/unknown releases — "Unknown Artist(s)" / "Various Artists"
// are RED-specific labels that won't match anything on Bandcamp
const VA_PATTERNS = /^(unknown artist|various artists?|va|various|multiple artists?)$/i;
const useArtist = artist && !VA_PATTERNS.test(artist);
// Also try a shorter query using only the part before a colon in the album title
// e.g. "The Weevil Series: Pupa" → try "The Weevil Series Pupa" and "Pupa"
const albumShort = album.replace(/\s*:.*$/, '').trim();
const q = [useArtist ? artist : '', album].filter(Boolean).join(' ').trim()
|| album;
function parseResults(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const results = [];
doc.querySelectorAll('.result-items li').forEach(el => {
// Accept album-type items (filter by itemtype div or class)
const itemType = el.querySelector('.itemtype');
const typeText = itemType ? itemType.textContent.trim().toUpperCase() : '';
if (typeText && typeText !== 'ALBUM') return;
const thumb = el.querySelector('img');
const headingEl = el.querySelector('.heading a');
const subheadEl = el.querySelector('.subhead');
if (!thumb || !headingEl) return;
// Must use getAttribute('src') not .src — DOMParser doesn't
// load resources so .src returns a wrong origin-prefixed URL
const src = thumb.getAttribute('data-src') || thumb.getAttribute('src') || '';
if (!src || !src.includes('bcbits.com')) return; // skip if no valid CDN URL
const imageUrl = src.replace(/_\d+\.(jpg|jpeg|png)/i, '_0.$1');
const href = headingEl.href;
const title = headingEl.textContent.trim();
const artist = (subheadEl ? subheadEl.textContent : '').replace(/^by\s*/i, '').trim();
if (imageUrl && href) results.push({ imageUrl, href, title, artist });
});
return results;
}
function doSearch(query, fallbackQuery) {
GM_xmlhttpRequest({
method: 'GET',
url: `https://bandcamp.com/search?q=${encodeURIComponent(query)}&item_type=a`,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
timeout: 12000,
onload: function(r) {
try {
const results = parseResults(r.responseText);
if (results.length === 0 && fallbackQuery && fallbackQuery !== query) {
// Retry with shorter/simpler query
doSearch(fallbackQuery, null);
} else {
callback(results);
}
} catch(e) { console.warn('Bandcamp search error:', e); callback([]); }
},
onerror: function() { callback([]); },
ontimeout: function() { callback([]); }
});
}
// Try full query first; fall back to album title only (short form before colon)
const fallback = albumShort !== album ? albumShort : (useArtist ? album : null);
doSearch(q, fallback);
}
// ============================================================
// --- ITUNES SEARCH ---
// ============================================================
function searchItunes(albumInfo, callback) {
const artist = (albumInfo.artist || '').trim();
const album = (albumInfo.album || '').trim();
if (!artist && !album) { callback([]); return; }
// iTunes Search API: term = "Artist Album", entity=album, media=music
const term = [artist, album].filter(Boolean).join(' ');
GM_xmlhttpRequest({
method: 'GET',
url: `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&entity=album&media=music&limit=12`,
timeout: 10000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const items = (data.results || []).filter(a => a.artworkUrl100);
// Boost exact artist+album matches to top
const aLow = artist.toLowerCase();
const bLow = album.toLowerCase();
items.sort((x, y) => {
const xMatch = (x.collectionName || '').toLowerCase().includes(bLow) &&
(x.artistName || '').toLowerCase().includes(aLow);
const yMatch = (y.collectionName || '').toLowerCase().includes(bLow) &&
(y.artistName || '').toLowerCase().includes(aLow);
return (yMatch ? 1 : 0) - (xMatch ? 1 : 0);
});
callback(items);
} catch(e) { callback([]); }
},
onerror: function() { callback([]); },
ontimeout: function() { callback([]); }
});
}
// ============================================================
// --- AMAZON SEARCH ---
// ============================================================
function searchAmazon(albumInfo, callback) {
const artist = (albumInfo.artist || '').trim();
const album = (albumInfo.album || '').trim();
if (!artist && !album) { callback([]); return; }
// Amazon Music search via their open search endpoint
// Returns product pages — we extract the ASIN and build the image URL directly
const query = [artist, album].filter(Boolean).join(' ');
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.amazon.com/s?k=${encodeURIComponent(query)}&i=popular&search-type=ss`,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'en-US,en;q=0.9'
},
timeout: 12000,
onload: function(r) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(r.responseText, 'text/html');
const results = [];
const seen = new Set();
// Extract ASINs from search results
doc.querySelectorAll('[data-asin]').forEach(el => {
const asin = el.getAttribute('data-asin');
if (!asin || asin.length < 10 || seen.has(asin)) return;
seen.add(asin);
// Try to get the image from the result card
const img = el.querySelector('img.s-image');
let imageUrl = img && img.getAttribute('src');
// Upgrade to high-res: replace the size suffix
if (imageUrl) {
imageUrl = imageUrl.replace(/\._[A-Z0-9_,]+_\./, '.');
}
const titleEl = el.querySelector('h2 span, .a-text-normal');
const title = titleEl ? titleEl.textContent.trim() : asin;
if (imageUrl && imageUrl.startsWith('https://')) {
results.push({ asin, imageUrl, title });
}
});
callback(results.slice(0, 10));
} catch(e) { callback([]); }
},
onerror: function() { callback([]); },
ontimeout: function() { callback([]); }
});
}
function createImagePickerOverlay(albumInfo, callback) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.92);z-index:100000;display:flex;align-items:center;justify-content:center;overflow:auto;';
const container = document.createElement('div');
container.style.cssText = 'background:#1a1a1a;padding:30px;border-radius:12px;border:2px solid #444;max-width:960px;width:92%;color:#fff;font-family:sans-serif;max-height:92vh;overflow-y:auto;';
container.innerHTML = `
<h2 style="margin-top:0;color:#4CAF50;">🎵 Select Artwork</h2>
<p style="color:#ccc;margin-bottom:16px;">
<strong>${albumInfo.artist}${albumInfo.album ? ' – ' + albumInfo.album : ''}${albumInfo.year ? ' (' + albumInfo.year + ')' : ''}</strong>
</p>
<div id="picker-tabs" style="display:flex;gap:6px;margin-bottom:20px;flex-wrap:wrap;">
<button class="picker-tab active" data-tab="sources"
style="padding:7px 16px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:bold;background:#4CAF50;color:#fff;">
📄 Page Sources <span id="sources-count" style="opacity:0.7;">(scanning…)</span>
</button>
<button class="picker-tab" data-tab="discogs"
style="padding:7px 16px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:bold;background:#333;color:#aaa;">
🔍 Discogs Search <span id="discogs-count" style="opacity:0.7;">(loading…)</span>
</button>
<button class="picker-tab" data-tab="streaming"
style="padding:7px 16px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:bold;background:#333;color:#aaa;">
🎵 Deezer / Qobuz / MB / BC <span id="streaming-count" style="opacity:0.7;">(loading…)</span>
</button>
<button class="picker-tab" data-tab="retail"
style="padding:7px 16px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:bold;background:#333;color:#aaa;">
🛒 iTunes / Amazon <span id="retail-count" style="opacity:0.7;">(loading…)</span>
</button>
</div>
<div id="panel-sources">
<div id="sources-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">Scanning page for source links…</div>
</div>
</div>
<div id="panel-discogs" style="display:none;">
<div id="discogs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">
<div style="font-size:32px;margin-bottom:8px;">🔍</div>Searching Discogs…
</div>
</div>
</div>
<div id="panel-streaming" style="display:none;">
<div id="streaming-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">
<div style="font-size:32px;margin-bottom:8px;">🎵</div>Searching Deezer, Qobuz & MusicBrainz…
</div>
</div>
</div>
<div id="panel-retail" style="display:none;">
<div id="retail-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;">
<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">
<div style="font-size:32px;margin-bottom:8px;">🛒</div>Searching iTunes & Amazon…
</div>
</div>
</div>
<div style="margin-top:22px;border-top:1px solid #444;padding-top:18px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<div style="width:100%;font-size:11px;color:#888;margin-bottom:2px;">
Paste any direct image URL — or a Spotify track, album, or playlist URL and the artwork will be resolved automatically.
</div>
<input type="text" id="custom-url-input" placeholder="Or paste any image URL…"
style="flex:1;min-width:200px;padding:9px 12px;background:#222;border:1px solid #555;color:#fff;border-radius:4px;font-size:13px;">
<button id="use-custom-url" style="padding:9px 18px;background:#2196F3;color:#fff;border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">Use URL</button>
<button id="upload-from-disk" style="padding:9px 18px;background:#7c3aed;color:#fff;border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">📁 Upload from computer</button>
<input type="file" id="disk-file-input" accept="image/*" style="display:none;">
<button id="skip-picker" style="padding:9px 18px;background:#ff9800;color:#fff;border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">Use Original</button>
<button id="cancel-picker" style="padding:9px 18px;background:#555;color:#fff;border:none;border-radius:4px;cursor:pointer;">Cancel</button>
${!getDiscogsToken() ? '<button id="setup-discogs" style="padding:9px 18px;background:#7B1FA2;color:#fff;border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">⚙ Set Discogs Token</button>' : ''}
</div>
<div id="url-preview" style="display:none;margin-top:12px;padding:12px;background:#1a1a1a;border:1px solid #444;border-radius:6px;align-items:center;gap:14px;">
<img id="url-preview-img" src="" alt="preview" style="width:80px;height:80px;object-fit:contain;border-radius:4px;background:#111;">
<div id="url-preview-info" style="font-size:12px;color:#aaa;line-height:1.6;"></div>
</div>
`;
overlay.appendChild(container);
document.body.appendChild(overlay);
// --- Tab switching ---
container.querySelectorAll('.picker-tab').forEach(btn => {
btn.addEventListener('click', () => {
container.querySelectorAll('.picker-tab').forEach(b => { b.style.background = '#333'; b.style.color = '#aaa'; });
btn.style.background = '#4CAF50'; btn.style.color = '#fff';
container.querySelector('#panel-sources').style.display = btn.dataset.tab === 'sources' ? '' : 'none';
container.querySelector('#panel-discogs').style.display = btn.dataset.tab === 'discogs' ? '' : 'none';
container.querySelector('#panel-streaming').style.display = btn.dataset.tab === 'streaming' ? '' : 'none';
container.querySelector('#panel-retail').style.display = btn.dataset.tab === 'retail' ? '' : 'none';
});
});
// --- Footer actions ---
container.querySelector('#use-custom-url').onclick = () => {
const url = container.querySelector('#custom-url-input').value.trim();
if (url) { document.body.removeChild(overlay); callback(url); }
else alert('Please enter a URL');
};
container.querySelector('#skip-picker').onclick = () => { document.body.removeChild(overlay); callback('SKIP'); };
container.querySelector('#cancel-picker').onclick = () => { document.body.removeChild(overlay); callback(''); };
container.querySelector('#upload-from-disk').onclick = () => {
container.querySelector('#disk-file-input').click();
};
container.querySelector('#disk-file-input').onchange = function() {
const file = this.files && this.files[0];
if (!file) return;
document.body.removeChild(overlay);
callback('__localfile__:' + URL.createObjectURL(file));
};
container.querySelector('#custom-url-input').addEventListener('keypress', e => {
if (e.key === 'Enter') container.querySelector('#use-custom-url').click();
});
// Live URL preview — debounced, shows thumbnail + dimensions on paste/type
let urlPreviewTimer = null;
const urlPreviewDiv = container.querySelector('#url-preview');
const urlPreviewImg = container.querySelector('#url-preview-img');
const urlPreviewInfo = container.querySelector('#url-preview-info');
function showUrlPreview(url) {
if (!url) { urlPreviewDiv.style.display = 'none'; return; }
urlPreviewInfo.textContent = 'Loading…';
urlPreviewDiv.style.display = 'flex';
urlPreviewImg.src = '';
// For Spotify track/album/playlist URLs, resolve via oEmbed first
// to get a real i.scdn.co image URL — then preview that
if (/open\.spotify\.com\/(track|album|playlist)\//i.test(url)) {
urlPreviewInfo.textContent = 'Resolving Spotify artwork…';
GM_xmlhttpRequest({
method: 'GET',
url: `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`,
headers: { 'Accept': 'application/json' },
timeout: 8000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const imageUrl = data.thumbnail_url;
if (imageUrl) {
// Update the input field to the resolved image URL
// so "Use URL" submits the actual image, not the Spotify page URL
container.querySelector('#custom-url-input').value = imageUrl;
showUrlPreview(imageUrl);
} else {
showPreviewError(url, 'Spotify oEmbed returned no image');
}
} catch(e) { showPreviewError(url, 'Could not resolve Spotify URL'); }
},
onerror: function() { showPreviewError(url, 'Could not reach Spotify'); },
ontimeout: function() { showPreviewError(url, 'Spotify request timed out'); }
});
return;
}
function showPreviewError(u, msg) {
urlPreviewImg.src = '';
urlPreviewInfo.innerHTML =
`<span style="color:#ccc;word-break:break-all;">${u.length > 80 ? u.slice(0,77)+'…' : u}</span><br>` +
`<span style="color:#f44;">${msg}</span>`;
}
const probe = new Image();
probe.onload = () => {
urlPreviewImg.src = url;
const w = probe.naturalWidth, h = probe.naturalHeight;
const sizeOk = w >= 500 && h >= 500;
urlPreviewInfo.innerHTML =
`<span style="color:#ccc;word-break:break-all;">${url.length > 80 ? url.slice(0,77)+'…' : url}</span><br>` +
`<span style="color:${sizeOk ? '#4CAF50' : '#ff9800'};">${w} × ${h}px</span>` +
(sizeOk ? '' : ' <span style="color:#ff9800;">— may be too small</span>');
};
probe.onerror = () => showPreviewError(url, 'Could not load image — URL may not be a direct image link');
probe.src = url;
}
container.querySelector('#custom-url-input').addEventListener('input', e => {
clearTimeout(urlPreviewTimer);
const val = e.target.value.trim();
if (!val) { urlPreviewDiv.style.display = 'none'; return; }
urlPreviewTimer = setTimeout(() => showUrlPreview(val), 600);
});
// Also trigger immediately on paste
container.querySelector('#custom-url-input').addEventListener('paste', e => {
clearTimeout(urlPreviewTimer);
// Use setTimeout to let paste complete before reading value
urlPreviewTimer = setTimeout(() => {
const val = e.target.value.trim();
if (val) showUrlPreview(val);
}, 100);
});
const setupBtn = container.querySelector('#setup-discogs');
if (setupBtn) {
setupBtn.onclick = () => {
document.body.removeChild(overlay);
showDiscogsSetup(success => {
if (success) createImagePickerOverlay(albumInfo, callback);
else callback('');
});
};
}
// --- Card factory ---
function makeCard(imageUrl, title, subtitle, badgeText, badgeColor, onClickUrl, detailLines) {
const card = document.createElement('div');
card.style.cssText = 'background:#222;border-radius:8px;padding:8px;cursor:pointer;transition:all 0.15s;border:2px solid transparent;position:relative;';
card.innerHTML = `
<img src="${imageUrl}" loading="lazy"
style="width:100%;height:160px;object-fit:cover;border-radius:5px;display:block;background:#333;">
<div style="margin-top:8px;font-size:11px;">
<div style="font-weight:bold;color:#4CAF50;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${title}">${title}</div>
${subtitle ? `<div style="color:#888;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${subtitle}">${subtitle}</div>` : ''}
${detailLines && detailLines.length ? detailLines.map(l => `<div style="color:#aaa;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:10px;" title="${l}">${l}</div>`).join('') : ''}
</div>
<div style="position:absolute;top:12px;left:12px;background:${badgeColor};color:#fff;padding:2px 7px;border-radius:3px;font-size:10px;font-weight:bold;border:1px solid rgba(255,255,255,0.15);">${badgeText}</div>
<div class="res-badge" style="position:absolute;top:12px;right:12px;background:rgba(0,0,0,0.75);color:#fff;padding:2px 7px;border-radius:3px;font-size:10px;font-weight:bold;">…</div>
`;
card.onmouseenter = () => { card.style.borderColor = '#4CAF50'; card.style.transform = 'scale(1.03)'; };
card.onmouseleave = () => { card.style.borderColor = 'transparent'; card.style.transform = 'scale(1)'; };
card.onclick = () => { document.body.removeChild(overlay); callback(onClickUrl); };
const probe = new Image();
probe.onload = () => {
const badge = card.querySelector('.res-badge');
badge.textContent = `${probe.width}×${probe.height}`;
badge.style.background = (probe.width < MIN_RESOLUTION || probe.height < MIN_RESOLUTION)
? 'rgba(244,67,54,0.85)' : 'rgba(76,175,80,0.85)';
};
probe.onerror = () => { card.querySelector('.res-badge').textContent = '?'; };
probe.src = imageUrl;
return card;
}
// --- Page Sources ---
const sourcesGrid = container.querySelector('#sources-grid');
const sourcesCountEl = container.querySelector('#sources-count');
const sourceLinks = getPageSourceLinks();
if (sourceLinks.length === 0) {
sourcesGrid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">No recognised source links found on this page.<br><small style="opacity:0.6;">Try the Discogs Search tab or paste a URL below.</small></div>';
sourcesCountEl.textContent = '(0)';
} else {
sourcesGrid.innerHTML = '';
sourcesCountEl.textContent = `(0/${sourceLinks.length})`;
let resolved = 0, found = 0;
const seenReleaseLists = new Set();
sourceLinks.forEach(linkObj => {
let firstCall = true;
resolveSourceImage(linkObj, result => {
if (firstCall) { resolved++; firstCall = false; }
if (result && result.imageUrl) {
found++;
sourcesCountEl.textContent = `(${found})`;
const color = SOURCE_BADGE_COLORS[result.source] || '#555';
sourcesGrid.appendChild(makeCard(
result.imageUrl,
result.source,
result.label || linkObj.label,
result.label || result.source,
color,
result.imageUrl
));
} else if (result && result.type === 'release_list' && Array.isArray(result.releases) && result.releases.length) {
// Render each release version directly as a card in the grid
result.releases.forEach(rel => {
const thumb = rel.coverImage || rel.thumb;
if (!thumb) return;
found++;
sourcesCountEl.textContent = `(${found})`;
const card = makeCard(
thumb,
rel.title || 'Release',
[rel.country, rel.year].filter(Boolean).join(' · '),
'Discogs',
SOURCE_BADGE_COLORS['Discogs Direct'] || '#e74c3c',
thumb,
[[rel.format && rel.format.join(', '), rel.label && rel.label.slice(0,2).join(', ')].filter(Boolean).join(' · ')]
);
// On click, fetch full-res image first
card.onclick = null;
card.addEventListener('click', () => {
const token = getDiscogsToken();
const headers = { 'User-Agent': 'CoverUp/7.0' };
if (token) headers['Authorization'] = `Discogs token=${token}`;
if (rel.country && rel.year && img) img.dataset.coverupSummary = [rel.country, rel.year].filter(Boolean).join(', ');
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.discogs.com/releases/${rel.id}`,
headers, timeout: 10000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const primary = (data.images || []).find(i => i.type === 'primary') || data.images && data.images[0];
const srcUrl = (primary && primary.uri) || thumb;
document.body.removeChild(overlay);
callback(srcUrl);
} catch(e) { document.body.removeChild(overlay); callback(thumb); }
},
onerror: function() { document.body.removeChild(overlay); callback(thumb); }
});
});
sourcesGrid.appendChild(card);
});
}
if (resolved === sourceLinks.length && found === 0) {
sourcesGrid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">Source links found but no artwork could be extracted.<br><small style="opacity:0.6;">Try the Discogs Search tab or paste a URL below.</small></div>';
sourcesCountEl.textContent = '(0)';
}
});
});
}
// --- Discogs keyword search ---
// Helpers to format Discogs release details matching the Discogs UI layout
function buildDiscogsSubtitle(rel) {
const parts = [];
if (rel.format && rel.format.length) parts.push(rel.format.join(', '));
return parts.join(' · ') || '';
}
function buildDiscogsDetailLines(rel) {
const lines = [];
// Label – Catalog Number
const labels = (rel.label || []).slice(0, 2).join(', ');
const catnos = (rel.catno || '').trim();
if (labels || catnos) lines.push([labels, catnos].filter(Boolean).join(' – '));
// Country Year
const country = rel.country || '';
const year = rel.year || '';
if (country || year) lines.push([country, year].filter(Boolean).join(' · '));
return lines;
}
const discogsGrid = container.querySelector('#discogs-grid');
const discogsCountEl = container.querySelector('#discogs-count');
if (!getDiscogsToken()) {
discogsGrid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;">No Discogs token set.<br><small>Click "⚙ Set Discogs Token" below to enable.</small></div>';
discogsCountEl.textContent = '(no token)';
} else {
const searchQuery = `${albumInfo.artist} ${albumInfo.album} ${albumInfo.year}`.trim();
console.log('[Rehost] albumInfo:', JSON.stringify(albumInfo));
console.log('[Rehost] searchQuery:', searchQuery);
searchDiscogs(searchQuery, function(results) {
discogsGrid.innerHTML = '';
const filtered = results.filter(r => (r.cover_image && !r.cover_image.includes('spacer.gif')) || (r._allImages && r._allImages.length > 0));
discogsCountEl.textContent = `(${filtered.length})`;
if (filtered.length === 0) {
discogsGrid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;"><div style="font-size:32px;margin-bottom:8px;">❌</div>No Discogs results found.</div>';
return;
}
filtered.forEach(release => {
if (release._allImages && release._allImages.length > 1) {
release._allImages.forEach(function(img, idx) {
const typeLabel = img.type === 'primary' ? 'Primary' : ('Image ' + (idx + 1));
discogsGrid.appendChild(makeCard(
img.uri,
release.title + ' — ' + typeLabel,
buildDiscogsSubtitle(release),
'Discogs',
SOURCE_BADGE_COLORS['Discogs Direct'] || '#e74c3c',
img.uri,
buildDiscogsDetailLines(release)
));
});
return;
}
discogsGrid.appendChild(makeCard(
release.cover_image,
release.title,
buildDiscogsSubtitle(release),
'Discogs',
'#e74c3c',
release.cover_image,
buildDiscogsDetailLines(release)
));
});
}, albumInfo);
}
// --- Deezer / Qobuz / MusicBrainz search ---
const streamingGrid = container.querySelector('#streaming-grid');
const streamingCountEl = container.querySelector('#streaming-count');
let streamingFound = 0;
let streamingDone = 0;
const STREAMING_SOURCES = 4;
streamingGrid.innerHTML = '';
// Relevance filter — artist must match unless VA/unknown
const VA_PATTERN = /^(unknown artist|various artists?|va|various|multiple artists?)$/i;
const searchArtist = (albumInfo.artist || '').trim().toLowerCase();
const searchAlbum = (albumInfo.album || '').trim().toLowerCase();
const isVA = !searchArtist || VA_PATTERN.test(searchArtist);
function wordsOf(s) {
return (s || '').toLowerCase().split(/[\s\-&,]+/).filter(w => w.length > 2);
}
function anyWordMatches(needle, haystack) {
const nw = wordsOf(needle);
if (!nw.length) return true;
const hw = haystack.toLowerCase();
return nw.some(w => hw.includes(w));
}
function isRelevant(resultTitle, resultArtist) {
const ra = (resultArtist || '').toLowerCase();
const rt = (resultTitle || '').toLowerCase();
if (!isVA && searchArtist) {
if (ra) {
// Have artist field — it must match
if (!anyWordMatches(searchArtist, ra)) return false;
} else {
// No artist field — require album title to match instead
if (searchAlbum && !anyWordMatches(searchAlbum, rt)) return false;
}
}
// Album title check — always required when we have an album name
if (searchAlbum) {
const albumWords = wordsOf(searchAlbum);
const distinctiveWords = albumWords.filter(w => w.length > 4);
if (distinctiveWords.length > 0) {
// Album has distinctive long words — require at least one to match
if (!distinctiveWords.some(w => rt.includes(w))) return false;
} else {
// Short album title (e.g. "Love", "Help!", "1") — require
// the full title to appear as a word boundary match in the result
const escaped = searchAlbum.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp('(?:^|[\\s\\-–—:])' + escaped + '(?:$|[\\s\\-–—:(])', 'i');
if (!pattern.test(rt)) return false;
}
}
return true;
}
function onStreamingDone() {
streamingDone++;
if (streamingFound === 0 && streamingDone === STREAMING_SOURCES) {
streamingGrid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;"><div style="font-size:32px;margin-bottom:8px;">❌</div>No results found on Deezer, Qobuz, MusicBrainz, or Bandcamp.</div>';
streamingCountEl.textContent = '(0)';
}
}
// Deezer
searchDeezer(albumInfo, function(items) {
items.filter(a => isRelevant(a.title, a.artist && a.artist.name)).forEach(a => {
let imageUrl = a.cover_xl || a.cover_big || a.cover_medium || a.cover;
if (!imageUrl && a.md5_image) {
imageUrl = `https://cdn-images.dzcdn.net/images/cover/${a.md5_image}/1000x1000-000000-80-0-0.jpg`;
}
if (!imageUrl) return;
streamingGrid.appendChild(makeCard(
imageUrl,
a.title || 'Unknown',
a.artist && a.artist.name ? a.artist.name : '',
'Deezer',
'#a238ff',
imageUrl
));
streamingFound++;
streamingCountEl.textContent = `(${streamingFound})`;
});
onStreamingDone();
});
// Qobuz
searchQobuz(albumInfo, function(items) {
items.filter(a => isRelevant(a.title, (a.artist && (a.artist.name || a.artist)) || '')).forEach(a => {
// Prefer API image fields; fall back to two-level CDN URL from ID
const qImg = a.image && (a.image.mega || a.image.large || a.image.small);
let imageUrl = (qImg && qImg.trim()) || '';
if (!imageUrl && a.id) {
const aid = String(a.id);
const l1 = aid.slice(-2);
const l2 = aid.slice(-4, -2);
imageUrl = `https://static.qobuz.com/images/covers/${l1}/${l2}/${aid}_org.jpg`;
}
if (!imageUrl) return;
streamingGrid.appendChild(makeCard(
imageUrl,
a.title || 'Unknown',
a.artist && a.artist.name ? a.artist.name : '',
'Qobuz',
'#1e90ff',
imageUrl
));
streamingFound++;
streamingCountEl.textContent = `(${streamingFound})`;
});
onStreamingDone();
});
// MusicBrainz
searchMusicBrainz(albumInfo, function(results) {
results.filter(r => isRelevant(r.title, r.artist)).forEach(r => {
streamingGrid.appendChild(makeCard(
r.imageUrl,
r.title || 'Unknown',
[r.artist, r.date].filter(Boolean).join(' · '),
'MB ' + r.label,
'#eb743b',
r.imageUrl
));
streamingFound++;
streamingCountEl.textContent = `(${streamingFound})`;
});
onStreamingDone();
});
// Bandcamp
searchBandcamp(albumInfo, function(items) {
items.filter(a => isRelevant(a.title, a.artist)).forEach(a => {
streamingGrid.appendChild(makeCard(
a.imageUrl,
a.title || 'Unknown',
a.artist || '',
'Bandcamp',
'#1da0c3',
a.imageUrl
));
streamingFound++;
streamingCountEl.textContent = `(${streamingFound})`;
});
onStreamingDone();
});
// --- iTunes / Amazon search ---
const retailGrid = container.querySelector('#retail-grid');
const retailCountEl = container.querySelector('#retail-count');
let retailFound = 0;
let retailDone = 0;
const RETAIL_SOURCES = 2;
retailGrid.innerHTML = '';
function onRetailDone() {
retailDone++;
if (retailFound === 0 && retailDone === RETAIL_SOURCES) {
retailGrid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:30px;color:#888;"><div style="font-size:32px;margin-bottom:8px;">❌</div>No results found on iTunes or Amazon.</div>';
retailCountEl.textContent = '(0)';
}
}
// iTunes
searchItunes(albumInfo, function(items) {
items.filter(a => isRelevant(a.collectionName, a.artistName)).forEach(a => {
const imageUrl = a.artworkUrl100
.replace(/\d+x\d+bb\.jpg$/, '10000x10000bb.jpg')
.replace(/\d+x\d+\.jpg$/, '10000x10000.jpg');
retailGrid.appendChild(makeCard(
imageUrl,
a.collectionName || 'Unknown',
a.artistName || '',
'iTunes',
'#fc3c44',
imageUrl
));
retailFound++;
retailCountEl.textContent = `(${retailFound})`;
});
onRetailDone();
});
// Amazon
searchAmazon(albumInfo, function(items) {
items.filter(a => isRelevant(a.title, '')).forEach(a => {
retailGrid.appendChild(makeCard(
a.imageUrl,
a.title || 'Unknown',
'Amazon',
'Amazon',
'#ff9900',
a.imageUrl
));
retailFound++;
retailCountEl.textContent = `(${retailFound})`;
});
onRetailDone();
});
}
// ============================================================
// --- COVER QUALITY VALIDATION ---
// ============================================================
const coverSelectors = ['.box_image_albumart #covers img', '.artist_profile img'];
function checkImageQuality(img) {
return new Promise((resolve) => {
// Always use the real URL — Gazelle lazy-loads alt covers via data-gazelle-temp-src
// so img.src may be blank until cover_art.js sets it.
const actualUrl = getActualImageUrl(img);
if (!actualUrl || actualUrl.includes('/static/common/noartwork/')) {
resolve({ needsRehost: true, resizeOnly: false, reasons: ['No artwork'] });
return;
}
// Check host against bad/trigger domains using the real URL
let badHost = false;
try {
const urlObj = new URL(actualUrl);
if (REHOST_TRIGGERS.some(h => urlObj.hostname.includes(h))) {
badHost = true;
}
} catch(e) {}
function resolveFromDimensions(w, h, broken) {
if (broken) {
resolve({ needsRehost: true, resizeOnly: false, reasons: ['Broken image'] });
return;
}
const issues = [];
if (badHost) issues.push(`Hosted on ${new URL(actualUrl).hostname} — rehost recommended`);
if (w > 0 && h > 0) {
if (w < MIN_RESOLUTION || h < MIN_RESOLUTION)
issues.push(`Low resolution (${w}×${h})`);
if (w > MAX_DIMENSION || h > MAX_DIMENSION)
issues.push(`Too large (${w}×${h})`);
}
const isOversized = w > MAX_DIMENSION || h > MAX_DIMENSION;
const isLowRes = w < MIN_RESOLUTION || h < MIN_RESOLUTION;
const resizeOnly = isOversized && !isLowRes && !badHost;
resolve({ needsRehost: issues.length > 0, resizeOnly, reasons: issues });
}
// If the img element has already loaded with real dimensions, use them directly
if (img.complete && img.naturalWidth > 0) {
resolveFromDimensions(img.naturalWidth, img.naturalHeight, false);
return;
}
// img.src is blank or not yet loaded — probe the actual URL via a fresh Image
const probe = new Image();
const timer = setTimeout(() => {
probe.onload = probe.onerror = null;
resolveFromDimensions(0, 0, true); // timeout = treat as broken
}, 6000);
probe.onload = () => {
clearTimeout(timer);
resolveFromDimensions(probe.naturalWidth, probe.naturalHeight, false);
};
probe.onerror = () => {
clearTimeout(timer);
resolveFromDimensions(0, 0, true);
};
probe.src = actualUrl;
});
}
// ============================================================
// --- ACTUAL IMAGE URL HELPER ---
// ============================================================
// Gazelle lazy-loads covers: the real URL lives in data-gazelle-temp-src
// before img.src settles. Always prefer that over img.src to avoid
// rehosting the torrent page URL instead of the actual image.
function getActualImageUrl(img) {
const badPrefix = window.location.origin + window.location.pathname + '?';
const candidates = [
img.getAttribute('data-gazelle-temp-src'),
img.getAttribute('data-src'),
img.currentSrc && !img.currentSrc.startsWith(badPrefix) ? img.currentSrc : null,
img.src && !img.src.startsWith(badPrefix) ? img.src : null
].filter(u => u && u.trim());
return candidates[0] || img.src || '';
}
// ============================================================
// --- ATTACH REHOST / RESIZE LINKS ---
// ============================================================
// ============================================================
// --- DISCOGS RELEASE PICKER ---
// ============================================================
function escHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function openDiscogsReleasePicker(releases, linkEl, img, callback) {
const prev = document.getElementById('coverup-discogs-picker');
if (prev) prev.remove();
const box = document.createElement('div');
box.id = 'coverup-discogs-picker';
Object.assign(box.style, {
position: 'fixed', top: '60px', right: '20px', zIndex: '99999',
width: '420px', maxHeight: '72vh', overflowY: 'auto',
background: '#1e1e1e', color: '#ddd',
border: '1px solid #555', borderRadius: '8px',
padding: '12px 14px', boxShadow: '0 8px 28px rgba(0,0,0,0.55)',
fontFamily: 'sans-serif', fontSize: '13px'
});
const hdr = document.createElement('div');
hdr.style.cssText = 'font-weight:bold;font-size:14px;margin-bottom:10px;padding-right:50px;';
hdr.textContent = `Discogs releases (${releases.length})`;
box.appendChild(hdr);
const closeBtn = document.createElement('a');
closeBtn.href = '#';
closeBtn.textContent = '✕ close';
closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;color:#999;text-decoration:none;';
closeBtn.addEventListener('click', e => { e.preventDefault(); box.remove(); });
box.appendChild(closeBtn);
releases.forEach(rel => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:10px;padding:8px 0;border-top:1px solid #2d2d2d;align-items:flex-start;';
if (rel.coverImage || rel.thumb) {
const thumb = document.createElement('img');
thumb.src = rel.coverImage || rel.thumb;
thumb.alt = '';
thumb.style.cssText = 'width:54px;height:54px;object-fit:cover;flex:0 0 54px;background:#333;border-radius:3px;';
row.appendChild(thumb);
} else {
const ph = document.createElement('div');
ph.style.cssText = 'width:54px;height:54px;flex:0 0 54px;background:#333;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#666;font-size:20px;';
ph.textContent = '🎵';
row.appendChild(ph);
}
const meta = document.createElement('div');
meta.style.cssText = 'flex:1 1 auto;min-width:0;';
meta.innerHTML = `<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(rel.title)}</div>
<div style="color:#aaa;font-size:0.87em;margin:3px 0 6px;">${escHtml(rel.summary)}</div>`;
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:10px;flex-wrap:wrap;';
const openLink = document.createElement('a');
openLink.href = rel.webUrl || '#';
openLink.target = '_blank';
openLink.rel = 'noopener noreferrer';
openLink.textContent = '[open]';
openLink.style.color = '#7ab8ff';
btnRow.appendChild(openLink);
if (rel.coverImage || rel.thumb) {
const useBtn = document.createElement('a');
useBtn.href = '#';
useBtn.textContent = '[use this cover]';
useBtn.style.color = '#7ddd5c';
useBtn.addEventListener('click', function(e) {
e.preventDefault();
box.remove();
// Build summary tag from release metadata e.g. "UK, 1970"
const relParts = [rel.country, rel.year].filter(Boolean);
const relSummary = relParts.join(', ');
if (img && relSummary) img.dataset.coverupSummary = relSummary;
// Fetch the full release to get the proper full-res image URI
// (master version list only has thumbnails)
const token = getDiscogsToken();
const headers = { 'User-Agent': 'CoverUp/7.0' };
if (token) headers['Authorization'] = `Discogs token=${token}`;
useBtn.textContent = '[loading…]';
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.discogs.com/releases/${rel.id}`,
headers,
timeout: 10000,
onload: function(r) {
try {
const data = JSON.parse(r.responseText);
const primary = (data.images || []).find(i => i.type === 'primary') || data.images && data.images[0];
const srcUrl = (primary && primary.uri) || rel.coverImage || rel.thumb;
if (typeof callback === 'function') {
callback(srcUrl);
} else if (img) {
handleUploadSuccess(srcUrl, linkEl && linkEl.dataset && linkEl.dataset.rehostOldUrl || srcUrl, linkEl, img);
}
} catch(e) {
// Fallback to thumb if fetch fails
const srcUrl = rel.coverImage || rel.thumb;
if (typeof callback === 'function') callback(srcUrl);
else if (img) handleUploadSuccess(srcUrl, srcUrl, linkEl, img);
}
},
onerror: function() {
const srcUrl = rel.coverImage || rel.thumb;
if (typeof callback === 'function') callback(srcUrl);
else if (img) handleUploadSuccess(srcUrl, srcUrl, linkEl, img);
}
});
});
btnRow.appendChild(useBtn);
}
meta.appendChild(btnRow);
row.appendChild(meta);
box.appendChild(row);
});
document.body.appendChild(box);
}
function attachRehostLink(img) {
const parentP = img.closest('p') || img.parentNode;
if (!parentP || img.dataset.rehostAttached) return;
img.dataset.rehostOldUrl = getActualImageUrl(img);
// Restore persisted rehosted state across page loads.
// Use the actual image URL (not img.src which may be blank for lazy-loaded alt covers).
// Only restore if the stored value points to a different URL (i.e. this was a source
// that got rehosted elsewhere). Don't restore 'self' entries — those are rehost
// *destinations* stored to prevent double-uploading, not source tracking.
const actualUrlForLookup = getActualImageUrl(img);
const persistedRehost = getRehostedUrl(actualUrlForLookup);
if (persistedRehost && persistedRehost !== 'self') {
img.dataset.rehostedUrl = persistedRehost;
}
const linkDiv = document.createElement('div');
linkDiv.className = 'rehost-link-wrapper';
linkDiv.style.cssText = 'text-align:center;margin-bottom:5px;';
const link = document.createElement('a');
link.href = 'javascript:void(0)';
link.textContent = '[checking...]';
link.style.cssText = 'cursor:pointer;font-weight:bold;';
linkDiv.appendChild(link);
// Insert the rehost link OUTSIDE the cover_div so Gazelle's cover_art.js jQuery
// delegation never sees the click event — insertBefore puts it just before the cover_div.
const coverDivAncestor = img.closest('[id^="cover_div_"]');
if (coverDivAncestor && coverDivAncestor.parentNode) {
coverDivAncestor.parentNode.insertBefore(linkDiv, coverDivAncestor);
} else {
parentP.insertBefore(linkDiv, parentP.firstChild);
}
checkImageQuality(img).then(result => {
const currentUrl = getActualImageUrl(img);
const isNoArtwork = result.reasons && result.reasons.includes('No artwork');
if (isNoArtwork) {
link.textContent = '[no artwork — search for some?]';
link.style.color = '#aaa';
link.addEventListener('click', (e) => { e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault(); rehostImage(img, link); });
img.dataset.rehostAttached = 'true';
return;
}
const isBroken = result.reasons && result.reasons.includes('Broken image');
const rehostedByCoverup = !isBroken && (link.dataset.rehosted || img.dataset.rehostedUrl);
const alreadyOnRehostDomain = !isBroken && !rehostedByCoverup && isOnRehostDomain(currentUrl);
if (rehostedByCoverup || alreadyOnRehostDomain) {
link.textContent = rehostedByCoverup ? '[rehosted via CoverUp — redo?]' : '[already on rehost domain — redo?]';
link.style.color = '#888';
link.addEventListener('click', (e) => { e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault(); rehostImage(img, link); });
img.dataset.rehostAttached = 'true';
return;
}
if (result.resizeOnly) {
link.textContent = '[resize & rehost]';
link.style.color = 'darkorange';
const reasonSpan = document.createElement('span');
reasonSpan.textContent = ' - ' + result.reasons.join(', ');
reasonSpan.style.cssText = 'color:#ffaa44;font-size:0.9em;font-weight:normal;';
linkDiv.appendChild(reasonSpan);
link.addEventListener('click', (e) => {
e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault();
if (!getPtpimgKey() && !getImgbbKey()) { showApiKeySetup(img, link); return; }
processImage(getActualImageUrl(img), getActualImageUrl(img), link, img);
});
} else if (result.needsRehost) {
const isBroken = result.reasons.includes('Broken image');
if (isBroken) {
const realUrl = getActualImageUrl(img);
link.textContent = '[broken image — rehost?]';
link.style.color = '#ff4444';
// Show the actual broken image URL, not the torrent page URL
const urlSpan = document.createElement('span');
urlSpan.style.cssText = 'display:block;font-size:0.78em;font-weight:normal;color:#aaa;word-break:break-all;margin-top:3px;user-select:all;';
urlSpan.title = 'Broken image URL';
urlSpan.textContent = realUrl;
linkDiv.appendChild(urlSpan);
} else {
link.textContent = '[rehost]';
link.style.color = 'red';
if (result.reasons.length > 0) {
const reasonSpan = document.createElement('span');
reasonSpan.textContent = ' - ' + result.reasons.join(', ');
reasonSpan.style.cssText = 'color:#ff6666;font-size:0.9em;font-weight:normal;';
linkDiv.appendChild(reasonSpan);
}
}
link.addEventListener('click', (e) => {
e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault();
rehostImage(img, link);
});
} else {
if (img.dataset.rehostedUrl) {
link.textContent = '[rehosted via CoverUp — redo?]';
link.style.color = '#888';
} else if (isOnRehostDomain(currentUrl)) {
link.textContent = '[already on rehost domain — redo?]';
link.style.color = '#888';
} else if (isOnSourceDomain(currentUrl)) {
let sourceName = '';
try {
// Turn e.g. "i.discogs.com" → "Discogs", "coverartarchive.org" → "Cover Art Archive"
const h = new URL(currentUrl).hostname.replace(/^(i|images?|static|media|cdn)\./, '');
const base = h.split('.')[0];
const friendly = {
'discogs': 'Discogs', 'coverartarchive': 'Cover Art Archive',
'musicbrainz': 'MusicBrainz', 'lastfm': 'Last.fm',
'qobuz': 'Qobuz', 'mzstatic': 'Apple Music',
'archive': 'Archive.org', 'last': 'Last.fm', 'scdn': 'Spotify',
'dzcdn': 'Deezer', 'resources': 'Tidal', 'bcbits': 'Bandcamp',
};
sourceName = friendly[base] || h;
} catch(e) {}
link.textContent = sourceName ? `[rehost recommended — hosted on ${sourceName}]` : '[rehost recommended]';
link.style.color = 'darkorange';
} else {
link.textContent = '[image ok — rehost anyway?]';
link.style.color = 'green';
}
link.addEventListener('click', (e) => { e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault(); rehostImage(img, link); });
}
});
img.dataset.rehostAttached = 'true';
}
coverSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(img => attachRehostLink(img));
});
// Alt cover rehost links: hide by default, show a summary nudge instead.
// Reveal everything when the user clicks "Show all" (Gazelle's own button).
(function setupAltCoverLinks() {
const firstAltCoverDiv = document.getElementById('cover_div_1');
if (!firstAltCoverDiv) return;
const coversContainer = firstAltCoverDiv.parentNode;
// Collect all rehost-link-wrappers that belong to alt covers (not cover_div_0)
const altWrappers = Array.from(coversContainer.querySelectorAll('.rehost-link-wrapper'))
.filter(w => {
const cd = w.nextElementSibling || w.previousElementSibling;
return cd && cd.id !== 'cover_div_0';
});
if (!altWrappers.length) return;
// Hide all alt cover rehost links initially
altWrappers.forEach(w => { w.dataset.coverupHidden = '1'; w.style.display = 'none'; });
// Count how many need attention (anything that isn't "already rehosted")
function countNeedingAttention() {
return altWrappers.filter(w => {
const a = w.querySelector('a');
if (!a) return false;
const t = a.textContent;
return t !== '[rehosted via CoverUp — redo?]' && t !== '[already on rehost domain — redo?]' && t !== '[checking...]';
}).length;
}
// Build the summary label
const summaryDiv = document.createElement('div');
summaryDiv.id = 'coverup-alt-summary';
summaryDiv.style.cssText = 'text-align:center;font-size:0.85em;color:#aaa;margin:6px 0 4px;';
function revealAll() {
altWrappers.forEach(w => { w.style.display = ''; });
summaryDiv.style.display = 'none';
}
function updateSummary() {
const total = altWrappers.length;
const needsWork = countNeedingAttention();
summaryDiv.innerHTML = '';
if (needsWork > 0) {
const msg = document.createElement('span');
msg.style.color = '#ffaa44';
msg.textContent = `${needsWork} of ${total} alternate cover${total > 1 ? 's' : ''} may need rehosting `;
summaryDiv.appendChild(msg);
} else {
const msg = document.createElement('span');
msg.textContent = `${total} alternate cover${total > 1 ? 's' : ''} `;
summaryDiv.appendChild(msg);
}
const showLink = document.createElement('a');
showLink.href = '#';
showLink.textContent = '[Show all]';
showLink.style.cssText = 'color:#7ab8ff;font-weight:bold;';
showLink.addEventListener('click', e => { e.preventDefault(); revealAll(); });
summaryDiv.appendChild(showLink);
}
// Show a placeholder while image probes run, then update with real count
summaryDiv.textContent = 'Checking alternate covers…';
setTimeout(updateSummary, 7000);
// Insert summary before the first alt rehost wrapper (or firstAltCoverDiv)
const firstWrapper = altWrappers[0];
coversContainer.insertBefore(summaryDiv, firstWrapper);
// Also hook Gazelle's own "Show all" button so both paths work
document.querySelectorAll('.show_all_covers').forEach(btn => {
btn.addEventListener('click', revealAll, { once: true });
});
})();
// ============================================================
// --- AUTO-RESUME ---
// ============================================================
const pendingImgSrc = sessionStorage.getItem('rehost_pending_src');
if (pendingImgSrc && getPtpimgKey()) {
setTimeout(() => {
const allImgs = document.querySelectorAll(coverSelectors.join(','));
const targetImg = Array.from(allImgs).find(img => img.src === pendingImgSrc);
if (targetImg) {
const cont = targetImg.closest('p') || targetImg.parentNode;
const link = cont.querySelector('.rehost-link-wrapper a');
if (link) { sessionStorage.removeItem('rehost_pending_src'); rehostImage(targetImg, link); }
}
}, 1000);
}
// ============================================================
// --- AUTO-SUBMIT ON EDIT PAGE ---
// ============================================================
if (window.location.href.includes('action=editgroup')) {
const oldUrl = sessionStorage.getItem('red_rehost_old_url');
const newUrl = sessionStorage.getItem('red_rehost_new_url');
if (newUrl) {
sessionStorage.removeItem('red_rehost_old_url');
sessionStorage.removeItem('red_rehost_new_url');
// Fill the primary cover image input
const imageInput = document.querySelector('input[name="image"]');
const textarea = document.querySelector('textarea[name="body"]') || document.querySelector('textarea#body');
if (imageInput) { imageInput.value = newUrl; imageInput.style.backgroundColor = '#ffffcc'; }
if (textarea && oldUrl && textarea.value.includes(oldUrl)) {
textarea.value = textarea.value.replace(
new RegExp(oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newUrl
);
textarea.style.backgroundColor = '#ffffcc';
}
setTimeout(function() {
const summaryInput = document.querySelector('input[name="summary"]');
if (summaryInput) summaryInput.value = 'Rehosted cover image';
const form = document.querySelector('form[name="torrent_group"]') || document.querySelector('form');
if (form) form.submit();
}, 400);
}
}
// ============================================================
// --- API SETUP PROMPT ---
// ============================================================
function showApiKeySetup(img, link) {
const imgbbKey = prompt('No image hosting API key found.\n\nEnter your imgbb API key (get one free at https://api.imgbb.com):\n\n(Leave blank to use catbox.moe anonymously, or choose TheSunGod/custom host when prompted)');
if (imgbbKey && imgbbKey.trim()) setImgbbKey(imgbbKey.trim());
rehostImage(img, link);
}
// ============================================================
// --- CORE REHOST LOGIC ---
// ============================================================
function rehostImage(img, link) {
const oldUrl = img.src;
const albumInfo = parseAlbumInfo();
createImagePickerOverlay(albumInfo, (selectedUrl) => {
if (!selectedUrl) {
link.textContent = '[rehost]';
link.style.color = 'red';
} else if (selectedUrl === 'SKIP') {
checkAndProcess(img, oldUrl, link);
} else if (selectedUrl.startsWith('__localfile__:')) {
const objectUrl = selectedUrl.slice('__localfile__:'.length);
link.textContent = 'Processing...';
link.style.color = 'orange';
fetch(objectUrl)
.then(r => r.blob())
.then(blob => {
URL.revokeObjectURL(objectUrl);
const tempImg = new Image();
const blobUrl = URL.createObjectURL(blob);
tempImg.onload = function() {
let w = tempImg.width, h = tempImg.height;
if (w > MAX_DIMENSION || h > MAX_DIMENSION) {
const ratio = Math.min(MAX_DIMENSION / w, MAX_DIMENSION / h);
w = Math.round(w * ratio); h = Math.round(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(tempImg, 0, 0, w, h);
URL.revokeObjectURL(blobUrl);
canvas.toBlob(jpegBlob => {
link.textContent = 'Uploading...';
link.style.color = 'orange';
uploadWithFallback(jpegBlob, oldUrl, link, newUrl => handleUploadSuccess(newUrl, oldUrl, link, img));
}, 'image/jpeg', JPEG_QUALITY);
};
tempImg.src = blobUrl;
})
.catch(() => { link.textContent = 'File read failed'; link.style.color = 'red'; });
} else {
// If the URL is a direct image link, use it as-is without re-uploading.
// Only re-upload if it comes from a source that needs processing
// (streaming services, page URLs, Apple Music, etc.)
const needsReupload = /apple\.com|mzstatic\.com|spotify\.com|scdn\.co|spotifycdn\.com|deezer\.com|dzcdn\.net|tidal\.com|resources\.tidal\.com|bandcamp\.com|bcbits\.com|amazon\.|m\.media-amazon\.com|qobuz\.com|i\.discogs\.com|coverartarchive\.org|musicbrainz\.org/i.test(selectedUrl);
if (!needsReupload) {
link.textContent = 'Saving…';
link.style.color = 'orange';
handleUploadSuccess(selectedUrl, oldUrl, link, img);
} else {
processImage(selectedUrl, oldUrl, link, img);
}
}
});
}
function checkAndProcess(img, oldUrl, link) {
const needsProcessing = oldUrl.includes('apple.com') || oldUrl.includes('mzstatic.com') ||
!oldUrl.toLowerCase().match(/\.jpe?g/) || img.naturalWidth > MAX_DIMENSION;
if (needsProcessing) processImage(oldUrl, oldUrl, link, img);
else fetchAndUpload(oldUrl, oldUrl, link, img);
}
function processImage(imageUrl, oldUrl, link, img) {
link.textContent = 'Processing...';
link.style.color = 'orange';
// Some CDNs (e.g. Spotify) serve images without a file extension.
// Append a hint so the fetch is treated as an image blob.
const fetchUrl = imageUrl;
GM_xmlhttpRequest({
method: 'GET', url: fetchUrl, responseType: 'blob',
headers: { 'Accept': 'image/jpeg,image/png,image/*,*/*' },
onload: function(response) {
const tempImg = new Image();
const objectUrl = URL.createObjectURL(response.response);
tempImg.onload = function() {
let w = tempImg.width, h = tempImg.height;
if (w > MAX_DIMENSION || h > MAX_DIMENSION) {
const ratio = Math.min(MAX_DIMENSION / w, MAX_DIMENSION / h);
w = Math.round(w * ratio); h = Math.round(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(tempImg, 0, 0, w, h);
canvas.toBlob(blob => {
URL.revokeObjectURL(objectUrl);
link.textContent = 'Uploading...';
link.style.color = 'orange';
uploadWithFallback(blob, oldUrl, link, newUrl => handleUploadSuccess(newUrl, oldUrl, link, img));
}, 'image/jpeg', JPEG_QUALITY);
};
tempImg.src = objectUrl;
},
onerror: function() { link.textContent = 'Fetch failed'; link.style.color = 'red'; }
});
}
function fetchAndUpload(imageUrl, oldUrl, link, img) {
link.textContent = 'Uploading...';
link.style.color = 'orange';
GM_xmlhttpRequest({
method: 'GET', url: imageUrl, responseType: 'blob',
onload: function(response) {
uploadWithFallback(response.response, oldUrl, link, newUrl => handleUploadSuccess(newUrl, oldUrl, link, img));
},
onerror: function() { link.textContent = 'Fetch failed'; link.style.color = 'red'; }
});
}
// ============================================================
// --- COVER TYPE DETECTION ---
// ============================================================
// Returns { isPrimary, coverArtId, groupId }
// isPrimary = true → lives in #cover_div_0 (the main cover, edit via editgroup)
// isPrimary = false → alternative cover; coverArtId is extracted from its remove link
function getCoverInfo(img) {
const coverDiv = img.closest('[id^="cover_div_"]');
if (!coverDiv) return { isPrimary: true, coverArtId: null, groupId: null };
const isPrimary = coverDiv.id === 'cover_div_0';
if (isPrimary) return { isPrimary: true, coverArtId: null, groupId: null };
// Extract coverArtId and groupId from the remove link in this div
// The remove link has href="#" but the action URL is inside the onclick attribute:
// onclick="...ajax.get('torrents.php?action=remove_cover_art&auth=...&id=3973&groupid=3061')..."
let coverArtId = null, groupId = null;
const allLinks = coverDiv.querySelectorAll('a');
let removeOnclick = '';
allLinks.forEach(function(a) {
const oc = a.getAttribute('onclick') || '';
if (oc.includes('remove_cover_art')) removeOnclick = oc;
});
let authToken = null;
if (removeOnclick) {
const idMatch = removeOnclick.match(/[?&]id=(\d+)/);
const groupIdMatch = removeOnclick.match(/[?&]groupid=(\d+)/);
const authMatch = removeOnclick.match(/[?&]auth=([a-f0-9]+)/i);
if (idMatch) coverArtId = idMatch[1];
if (groupIdMatch) groupId = groupIdMatch[1];
if (authMatch) authToken = authMatch[1];
}
// Fallback: groupId from page URL
if (!groupId) {
const urlMatch = window.location.href.match(/[?&]id=(\d+)/);
if (urlMatch) groupId = urlMatch[1];
}
return { isPrimary: false, coverArtId, groupId, authToken };
}
function handleUploadSuccess(newUrl, oldUrl, link, img) {
GM_setClipboard(newUrl);
link.dataset.rehosted = '1';
setRehostedUrl(oldUrl, newUrl);
setRehostedUrl(newUrl, 'self');
document.querySelectorAll(coverSelectors.join(',')).forEach(function(el) {
if (el.src === oldUrl || el.dataset.rehostOldUrl === oldUrl) {
el.dataset.rehostedUrl = newUrl;
}
});
const coverInfo = img ? getCoverInfo(img) : { isPrimary: true };
const groupIdMatch = window.location.href.match(/[?&]id=(\d+)/);
const groupId = groupIdMatch ? groupIdMatch[1] : null;
if (!groupId) return;
if (coverInfo.isPrimary) {
// Primary cover: must use editgroup form (no AJAX endpoint to update it)
sessionStorage.setItem('red_rehost_old_url', oldUrl);
sessionStorage.setItem('red_rehost_new_url', newUrl);
link.textContent = 'Saving…';
link.style.color = 'orange';
window.location.href = 'torrents.php?action=editgroup&groupid=' + groupId;
return;
}
// Alt cover: use AJAX add + remove, no page redirect needed
if (!coverInfo.coverArtId || !coverInfo.authToken) {
// Fallback: try to get auth from page-level JS var
const pageAuth = (typeof authkey !== 'undefined') ? authkey : null;
if (!pageAuth) {
alert('Cover uploaded!\nCould not detect auth token — paste manually:\n\n' + newUrl);
return;
}
coverInfo.authToken = pageAuth;
}
link.textContent = 'Saving…';
link.style.color = 'orange';
const coverDiv = img ? img.closest('[id^="cover_div_"]') : null;
// Use release metadata summary if set by Discogs picker (e.g. "UK, 1970"),
// otherwise fall back to the existing img.alt attribute
const coverTitle = (img && img.dataset && img.dataset.coverupSummary)
? img.dataset.coverupSummary
: (img && img.alt) ? img.alt : '';
if (img && img.dataset) delete img.dataset.coverupSummary; // consume it
// Step 1: Fetch the live page to find the real input field names for add_cover_art.
// Gazelle's cover_art.js adds image/summary fields dynamically — we need their names
// from the live DOM, not from a POST response.
GM_xmlhttpRequest({
method: 'GET',
url: 'torrents.php?id=' + (coverInfo.groupId || groupId),
onload: function(pageResp) {
const parser = new DOMParser();
const livePage = parser.parseFromString(pageResp.responseText, 'text/html');
// Find image and summary field names from the live page
// They sit near the add_cover div — look for text/url inputs adjacent to it
let imageFieldName = 'image';
let summaryFieldName = 'summary';
const addCoverDiv = livePage.getElementById('add_cover');
if (addCoverDiv) {
// Walk up to find sibling/parent inputs
const container = addCoverDiv.closest('form') || addCoverDiv.parentNode;
if (container) {
const textInputs = container.querySelectorAll('input[type="text"], input:not([type])');
textInputs.forEach(inp => {
const n = (inp.name || '').toLowerCase();
const p = (inp.placeholder || '').toLowerCase();
if (n.includes('image') || n.includes('url') || p.includes('image') || p.includes('url') || n === 'image') {
imageFieldName = inp.name;
}
if (n.includes('summary') || n.includes('title') || n.includes('caption') || p.includes('summary')) {
summaryFieldName = inp.name;
}
});
}
}
// Field names confirmed by inspecting addCoverField() output:
// Redacted uses array notation: image[] and summary[]
// Send as multipart FormData — Redacted uses image[]/summary[] array fields
const addFormData = new FormData();
addFormData.append('action', 'add_cover_art');
addFormData.append('auth', coverInfo.authToken);
addFormData.append('groupid', coverInfo.groupId || groupId);
addFormData.append('image[]', newUrl);
addFormData.append('summary[]', coverTitle || 'Rehosted cover');
// Step 2: GET remove_cover_art to delete the old entry FIRST.
const removeUrl = 'torrents.php?action=remove_cover_art'
+ '&auth=' + encodeURIComponent(coverInfo.authToken)
+ '&id=' + encodeURIComponent(coverInfo.coverArtId)
+ '&groupid=' + encodeURIComponent(coverInfo.groupId || groupId);
GM_xmlhttpRequest({
method: 'GET',
url: removeUrl,
onload: function(removeResp) {
console.log('[CoverUp] remove_cover_art status:', removeResp.status);
// Step 3: POST add_cover_art as multipart FormData
GM_xmlhttpRequest({
method: 'POST',
url: 'torrents.php',
data: addFormData,
onload: function(addResp) {
console.log('[CoverUp] add_cover_art status:', addResp.status);
console.log('[CoverUp] add_cover_art finalUrl:', addResp.finalUrl);
console.log('[CoverUp] add_cover_art response length:', addResp.responseText.length);
console.log('[CoverUp] add_cover_art newUrl present in response:', addResp.responseText.includes(newUrl));
console.log('[CoverUp] add_cover_art response (first 500 chars):', addResp.responseText.slice(0, 500));
// Parse the response page to find the add_cover_art form fields
// and any error messages — this tells us what Gazelle actually expects
try {
const parser = new DOMParser();
const doc = parser.parseFromString(addResp.responseText, 'text/html');
// Find any error notices
const notices = doc.querySelectorAll('.error, .notice, #notice, .alertbar');
notices.forEach(n => console.log('[CoverUp] Page notice:', n.textContent.trim().slice(0, 200)));
// Find the add cover art form and log all its fields
const forms = doc.querySelectorAll('form');
forms.forEach(form => {
const action = form.getAttribute('action') || '';
const inputs = Array.from(form.querySelectorAll('input, textarea, select'));
const hasAddCover = inputs.some(i => (i.value || '').includes('add_cover')) ||
action.includes('add_cover') ||
form.innerHTML.includes('add_cover_art');
if (hasAddCover || form.innerHTML.includes('cover')) {
console.log('[CoverUp] Cover form action:', action);
console.log('[CoverUp] Cover form HTML:', form.innerHTML.slice(0, 1200));
inputs.forEach(i => {
console.log('[CoverUp] Form field:', i.type, '|', i.name || '(no name)', '=', (i.value || '').slice(0, 100));
});
}
});
} catch(e) { console.log('[CoverUp] Form parse error:', e.message); }
console.log('[CoverUp] Alt cover rehosted successfully:', newUrl);
if (addResp.status < 200 || addResp.status >= 400) {
link.textContent = '[add failed (HTTP ' + addResp.status + ') — old entry already removed!]';
link.style.color = '#f44';
console.error('[CoverUp] add_cover_art returned HTTP', addResp.status);
return;
}
link.textContent = '✓ Saved — reloading…';
link.style.color = '#4CAF50';
setTimeout(function() { location.reload(); }, 800);
},
onerror: function() {
link.textContent = '[add failed — old entry already removed!]';
link.style.color = '#f44';
console.error('[CoverUp] add_cover_art POST failed');
}
});
},
onerror: function() {
link.textContent = '[remove failed — add not attempted, old entry kept]';
link.style.color = '#f44';
console.error('[CoverUp] remove_cover_art GET failed');
}
});
},
onerror: function() {
link.textContent = '[could not load page to detect form fields]';
link.style.color = '#f44';
console.error('[CoverUp] live page fetch failed');
}
});
}
})();