ui: cover art in Beatport, Spotify e YouTube search

- core/beatport.py: BeatportTrack.image_url estratto da image.dynamic_uri (95x95)
- core/spotify_client.py: _track_to_dict include image_url (Spotify torna 3 taglie,
  prendo la ~64px). search_artist_discography inietta album.images anche negli
  album-tracks per la cover
- core/youtube_search.py: image_url = i.ytimg.com/vi/<id>/mqdefault.jpg
- webui/index.html: nuova <th class="col-cover"></th> nelle 3 tabelle
- webui/js/app.js: 3 renderTable inseriscono <td class="col-cover"><img>
  con loading=lazy, referrerpolicy=no-referrer e onerror fallback
- webui/css/style.css: .beatport-table .col-cover img { 40x40, border-radius 4px,
  object-fit: cover, bg fallback var(--bg-input) }
This commit is contained in:
luciano committed 2026-07-15 11:45:28 +02:00
1 parent 85f7ec0993
commit f226c3fa8e
6 files changed
+59 -3

No files matched your search

+13
View File
@@ -23,6 +23,7 @@ class BeatportTrack:
artists: str # es. "A, B & C" già formattato
duration_sec: int
beatport_id: int
image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test)
@property
def display(self) -> str:
@@ -150,6 +151,17 @@ def _format_artists(artists_field: object) -> str:
return ", ".join(names[:-1]) + " & " + names[-1]
def _extract_image_url(image_field: object, size: int = 95) -> str:
"""Estrae URL cover art dall'oggetto `image` di Beatport.
Preferisce `dynamic_uri` sostituendo {w}x{h}, fallback a `uri` fisso."""
if not isinstance(image_field, dict):
return ""
dyn = image_field.get("dynamic_uri") or ""
if isinstance(dyn, str) and "{w}" in dyn and "{h}" in dyn:
return dyn.replace("{w}", str(size)).replace("{h}", str(size))
return image_field.get("uri") or ""
def _parse_tracks(data: dict) -> list:
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
raw = _find_tracks_results(data)
@@ -164,6 +176,7 @@ def _parse_tracks(data: dict) -> list:
artists=_format_artists(item.get("artists")),
duration_sec=length_ms // 1000,
beatport_id=int(item.get("id") or 0),
image_url=_extract_image_url(item.get("image")),
)
except (TypeError, ValueError) as e:
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
+11 -3
View File
@@ -308,6 +308,12 @@ def search_tracks(token: str, query: str, limit: int = 50) -> list:
def _track_to_dict(t: dict) -> dict:
"""Mappa il track object Spotify sul nostro schema uniforme."""
images = t.get("album", {}).get("images", []) or []
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
# se disponibile, altrimenti la prima che c'è.
image_url = ""
if images:
image_url = images[-1].get("url", "") or images[0].get("url", "")
return {
"id": t.get("id", ""),
"url": t.get("external_urls", {}).get("spotify", ""),
@@ -315,6 +321,7 @@ def _track_to_dict(t: dict) -> dict:
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
"album": t.get("album", {}).get("name", ""),
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
"image_url": image_url,
}
@@ -372,10 +379,12 @@ def search_artist_discography(token: str, artist_name: str) -> list:
r_alb.raise_for_status()
albums = r_alb.json().get("items", [])
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola)
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
for alb in albums:
alb_id = alb.get("id")
alb_name = alb.get("name", "")
alb_images = alb.get("images", []) or []
if not alb_id:
continue
time.sleep(0.1) # rate limit interno
@@ -388,8 +397,7 @@ def search_artist_discography(token: str, artist_name: str) -> list:
r_at.raise_for_status()
for t in r_at.json().get("items", []):
t = dict(t)
# Album tracks non hanno "album" nested; iniettiamo il nome
t.setdefault("album", {"name": alb_name})
t.setdefault("album", {"name": alb_name, "images": alb_images})
collected.append(_track_to_dict(t))
# 5. Dedupe
+2
View File
@@ -62,11 +62,13 @@ def search_youtube(query: str, limit: int = 50) -> list:
continue
video_id = e.get("id") or ""
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
result.append({
"id": video_id,
"url": url,
"title": e.get("title") or "",
"channel": e.get("uploader") or e.get("channel") or "",
"duration_sec": int(e.get("duration") or 0),
"image_url": image_url,
})
return result
+9
View File
@@ -1667,6 +1667,15 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-table .col-check { width: 36px; }
.beatport-table .col-cover { width: 52px; padding: 4px 8px; }
.beatport-table .col-cover img {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
display: block;
background: var(--bg-input);
}
.beatport-table .col-pos { width: 40px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.beatport-table .col-dur { width: 70px; color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; }
.beatport-table .col-state { width: 130px; font-size: 12px; }
+3
View File
@@ -891,6 +891,7 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
<th class="col-cover"></th>
<th class="col-pos">#</th>
<th>Artista</th>
<th>Titolo (Mix)</th>
@@ -951,6 +952,7 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
<th class="col-cover"></th>
<th class="col-pos">#</th>
<th>Artista</th>
<th>Titolo</th>
@@ -1008,6 +1010,7 @@
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
<th class="col-cover"></th>
<th class="col-pos">#</th>
<th>Titolo video</th>
<th>Canale</th>
+21
View File
@@ -1297,6 +1297,19 @@ const BeatportUI = (function () {
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
const tdCover = document.createElement("td");
tdCover.className = "col-cover";
if (t.image_url) {
const img = document.createElement("img");
img.src = t.image_url;
img.alt = "";
img.loading = "lazy";
img.referrerPolicy = "no-referrer";
img.onerror = () => { img.style.visibility = "hidden"; };
tdCover.appendChild(img);
}
tr.appendChild(tdCover);
const tdPos = document.createElement("td");
tdPos.className = "col-pos";
tdPos.textContent = String(t.position || (i + 1));
@@ -1518,8 +1531,12 @@ const SpotifyUI = (() => {
const tr = document.createElement("tr");
tr.dataset.idx = String(i);
if (already) tr.classList.add("already-downloaded");
const coverImg = t.image_url
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
: "";
tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
<td class="col-cover">${coverImg}</td>
<td class="col-pos">${i + 1}</td>
<td>${_escape(t.artists)}</td>
<td>${_escape(t.name)}</td>
@@ -1752,8 +1769,12 @@ const YoutubeUI = (() => {
const tr = document.createElement("tr");
tr.dataset.idx = String(i);
if (already) tr.classList.add("already-downloaded");
const coverImg = t.image_url
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
: "";
tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
<td class="col-cover">${coverImg}</td>
<td class="col-pos">${i + 1}</td>
<td>${_escape(t.title)}</td>
<td>${_escape(t.channel)}</td>