From bed1f4265a10bde59a5c8c27164c0416eecaaf02 Mon Sep 17 00:00:00 2001 From: Benjamin Hardy Date: Wed, 11 Mar 2026 08:35:09 +0100 Subject: [PATCH] feat: implemented artwork fetching/downloading --- app.py | 45 +++++++++++++++++++++++++++++++++++++++ templates/index.html | 50 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 3 deletions(-) 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 @@
+
@@ -280,6 +281,19 @@ + +
+
+

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'));