diff --git a/app.py b/app.py
index 36d8992..b4abffb 100644
--- a/app.py
+++ b/app.py
@@ -615,6 +615,51 @@ def save_settings():
return jsonify({"ok": True})
+# ---------------------------------------------------------------------------
+# Artwork routes
+# ---------------------------------------------------------------------------
+
+@app.route("/api/artwork", methods=["GET"])
+def get_artwork():
+ from monochrome.spotify_to_ids import parse_spotify_url, fetch_spotify_embed
+ spotify_url = request.args.get("url", "").strip()
+ if not spotify_url:
+ return jsonify({"error": "No URL provided"}), 400
+
+ sp_type, sp_id = parse_spotify_url(spotify_url)
+ if not sp_type:
+ return jsonify({"error": "Invalid Spotify URL"}), 400
+
+ embed_data = fetch_spotify_embed(sp_type, sp_id)
+ if not embed_data:
+ return jsonify({"error": "Could not fetch Spotify metadata"}), 502
+
+ try:
+ entity = embed_data["props"]["pageProps"]["state"]["data"]["entity"]
+ except (KeyError, TypeError):
+ return jsonify({"error": "Unexpected Spotify response format"}), 502
+
+ # entity.visualIdentity.image[] — confirmed structure from Spotify embed page
+ # Each entry: {"url": "https://image-cdn-ak.spotifycdn.com/image/...", "maxWidth": N, "maxHeight": N}
+ images = (entity.get("visualIdentity") or {}).get("image", [])
+ if not images:
+ app.logger.warning("Artwork not found. Entity keys: %s", list(entity.keys()))
+ return jsonify({"error": "No artwork found for this URL"}), 404
+
+ best = max(images, key=lambda img: img.get("maxWidth", 0))
+ url = best["url"]
+
+ # Upscale to 2000×2000 using the same CDN key technique as votify-fix.
+ # Spotify CDN filenames: first 16 hex chars = size key, remainder = image hash.
+ # Source: GladistonXD/votify-fix constants.py COVER_SIZE_X_KEY_MAPPING_SONG
+ EXTRA_LARGE_KEY = "ab67616d000082c1"
+ parts = url.rsplit("/", 1)
+ if len(parts) == 2 and len(parts[1]) >= 16:
+ url = parts[0] + "/" + EXTRA_LARGE_KEY + parts[1][16:]
+
+ return jsonify({"image_url": url})
+
+
# ---------------------------------------------------------------------------
# Job routes
# ---------------------------------------------------------------------------
diff --git a/templates/index.html b/templates/index.html
index f3913a0..d62ec4e 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -179,6 +179,7 @@
+
+
Artwork
+
+
+
+
+
+
+
+
+
@@ -448,8 +462,8 @@
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
- // For hidden pages (download/monochrome), highlight the "Download" tab
- const tabName = (name === 'download' || name === 'monochrome') ? 'unified' : name;
+ // For sub-pages (download/monochrome/artwork), highlight the "Download" tab
+ const tabName = (name === 'download' || name === 'monochrome' || name === 'artwork') ? 'unified' : name;
const matchingTab = document.querySelector(`.tab[onclick="showPage('${tabName}')"]`);
if (matchingTab) matchingTab.classList.add('active');
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === tabName));
@@ -1092,6 +1106,34 @@
}
}
+ async function fetchArtwork() {
+ const btn = document.getElementById('btn-artwork');
+ const url = document.getElementById('artwork-url').value.trim().split('\n')[0].trim();
+ const resultDiv = document.getElementById('artwork-result');
+ if (!url) { showToast('Enter a Spotify URL', 'error'); return; }
+
+ btn.disabled = true;
+ resultDiv.innerHTML = 'Fetching artwork...';
+
+ try {
+ const res = await fetch('/api/artwork?url=' + encodeURIComponent(url));
+ const data = await res.json();
+ if (res.ok && data.image_url) {
+ const img = document.createElement('img');
+ img.src = data.image_url;
+ img.alt = 'Cover artwork';
+ img.style.cssText = 'max-width:100%;border-radius:var(--radius);display:block;';
+ resultDiv.innerHTML = '';
+ resultDiv.appendChild(img);
+ } else {
+ resultDiv.innerHTML = '' + escapeHtml(data.error || 'No artwork found') + '';
+ }
+ } catch (e) {
+ resultDiv.innerHTML = 'Failed to fetch artwork';
+ }
+ btn.disabled = false;
+ }
+
function formatSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
@@ -1137,9 +1179,11 @@
// Drop onto active page's textarea
const monoActive = document.getElementById('page-monochrome').classList.contains('active');
const votifyActive = document.getElementById('page-download').classList.contains('active');
+ const artworkActive = document.getElementById('page-artwork').classList.contains('active');
let taId = 'unified-urls';
if (monoActive) taId = 'mono-url';
else if (votifyActive) taId = 'urls';
+ else if (artworkActive) taId = 'artwork-url';
const ta = document.getElementById(taId);
ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + lines.join('\n');
ta.classList.remove('drop-flash');
@@ -1147,7 +1191,7 @@
ta.classList.add('drop-flash');
setTimeout(() => ta.classList.remove('drop-flash'), 650);
});
- for (const id of ['urls', 'mono-url', 'unified-urls']) {
+ for (const id of ['urls', 'mono-url', 'unified-urls', 'artwork-url']) {
const ta = document.getElementById(id);
ta.addEventListener('dragover', () => ta.classList.add('drag-over'));
ta.addEventListener('dragleave', () => ta.classList.remove('drag-over'));