Compare commits
2 Commits
ec8d5a6124
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bed1f4265a | |||
| 343dfbaae1 |
45
app.py
45
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
<div style="margin-top: 16px; display: flex; gap: 8px;">
|
||||
<button class="btn btn-sm btn-secondary" onclick="showPage('download')">Votify (Spotify)</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="showPage('monochrome')">Monochrome (Tidal)</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="showPage('artwork')">Artwork</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,6 +281,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ARTWORK PAGE -->
|
||||
<div id="page-artwork" class="page">
|
||||
<div class="card">
|
||||
<h2>Artwork</h2>
|
||||
<div class="form-group">
|
||||
<label for="artwork-url">Spotify URL (track, album, or playlist)</label>
|
||||
<textarea id="artwork-url" placeholder="https://open.spotify.com/album/..." style="min-height:60px"></textarea>
|
||||
</div>
|
||||
<button class="btn" id="btn-artwork" onclick="fetchArtwork()">Get Artwork</button>
|
||||
<div id="artwork-result" style="margin-top:20px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOBS PAGE -->
|
||||
<div id="page-jobs" class="page">
|
||||
<div class="card">
|
||||
@@ -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));
|
||||
@@ -986,7 +1000,7 @@
|
||||
<span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
|
||||
<div class="file-actions">
|
||||
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">⬇</button>
|
||||
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path='+encodeURIComponent(this.dataset.p)" data-p="${safePath}" title="Download as ZIP">⬇</button>
|
||||
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">🗑</button>
|
||||
</div>
|
||||
</li>`;
|
||||
@@ -994,7 +1008,7 @@
|
||||
const size = formatSize(f.size);
|
||||
return `<li class="file-item">
|
||||
<span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
|
||||
<span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span>
|
||||
<span class="file-name" onclick="window.location='/api/files/download?path='+encodeURIComponent(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
|
||||
<span class="file-size">${size}</span>
|
||||
<div class="file-actions">
|
||||
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, false)" data-p="${safePath}" title="Delete file">🗑</button>
|
||||
@@ -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 = '<span style="color:var(--text2)">Fetching artwork...</span>';
|
||||
|
||||
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 = '<span style="color:var(--danger)">' + escapeHtml(data.error || 'No artwork found') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = '<span style="color:var(--danger)">Failed to fetch artwork</span>';
|
||||
}
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user