diff --git a/core/beatport.py b/core/beatport.py index 1d4b8e9..5ce9196 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -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 diff --git a/core/spotify_client.py b/core/spotify_client.py index 34ea1c1..4110ba3 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -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 diff --git a/core/youtube_search.py b/core/youtube_search.py index 30f2b10..b5b7b40 100644 --- a/core/youtube_search.py +++ b/core/youtube_search.py @@ -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 diff --git a/webui/css/style.css b/webui/css/style.css index 323af8a..9f73f45 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -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; } diff --git a/webui/index.html b/webui/index.html index b67d72f..8ae0e40 100644 --- a/webui/index.html +++ b/webui/index.html @@ -891,6 +891,7 @@ + # Artista Titolo (Mix) @@ -951,6 +952,7 @@ + # Artista Titolo @@ -1008,6 +1010,7 @@ + # Titolo video Canale diff --git a/webui/js/app.js b/webui/js/app.js index 90c39b2..02fd1d5 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -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 + ? `` + : ""; tr.innerHTML = ` + ${coverImg} ${i + 1} ${_escape(t.artists)} ${_escape(t.name)} @@ -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 + ? `` + : ""; tr.innerHTML = ` + ${coverImg} ${i + 1} ${_escape(t.title)} ${_escape(t.channel)}