Compare commits

...
5 Commits
Author SHA1 Message Date
luciano f1437e8e24 config: bump VERSION v1.8.3 2026-07-15 11:46:50 +02:00
luciano acbd9c0b1f ui: hero sticky in cima al pannello scrollabile di ogni tab 2026-07-15 11:45:43 +02:00
luciano f226c3fa8e 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) }
2026-07-15 11:45:28 +02:00
luciano 85f7ec0993 config: bump VERSION v1.8.2 2026-07-14 22:15:02 +02:00
luciano 9dbdd74b2f ui: fix light mode — colori brand come testo + hover/focus adattivi
- Introdotte varianti --*-text (green/blue/purple/pink/amber/indigo/rec-red)
  scure in light mode per contrasto AA su bg bianco
- Convertite le rule che usano brand colors come foreground (log, badge,
  quota, footer, percent, info-row, update-status, save-status, toast,
  beatport-status, colonna "già scaricato")
- Nav-item hover da #1A1A1A hardcoded → var(--bg-card-2)
- Input focus da #333 hardcoded → var(--bg-input-hover)
- Scrollbar thumb da #404040/#606060 hardcoded → var(--text-3)/var(--text-2)
- .hint code bg da rgba(255,255,255,0.08) → var(--bg-input)
- Table hover da rgba(99,102,241,0.06) → var(--bg-input) (visibile in light)
- .log .l-warn da #FDCB6E hardcoded → var(--amber-text)
2026-07-14 22:12:50 +02:00
7 changed files with 106 additions and 29 deletions

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
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.8.1"
VERSION = "v1.8.3"
APP_NAME = "MusicTools"
+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
+55 -25
View File
@@ -49,6 +49,16 @@
--text-2: #B3B3B3;
--text-3: #727272;
/* Varianti brand per uso come TESTO (foreground). In dark restano vibranti
come le base; in light diventano scure per contrasto AA su sfondo bianco. */
--green-text: var(--green);
--blue-text: var(--blue);
--purple-text: var(--purple);
--pink-text: var(--pink);
--amber-text: var(--amber);
--indigo-text: var(--indigo);
--rec-red-text: var(--rec-red);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 14px;
@@ -84,6 +94,15 @@ html[data-theme="light"] {
--rec-red-dim: #FECACA;
--indigo-dim: #C7D2FE;
/* Varianti brand per testo: scurite per contrasto AA su fondo bianco */
--green-text: #0F7A38;
--blue-text: #1D6BD3;
--purple-text: #7E22CE;
--pink-text: #BE185D;
--amber-text: #B45309;
--indigo-text: #4338CA;
--rec-red-text: #B91C1C;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
@@ -192,13 +211,13 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
}
.nav-item:hover {
background: #1A1A1A;
background: var(--bg-card-2);
color: var(--text);
}
.nav-item.active {
background: #1F1F1F;
color: var(--green);
background: var(--bg-card-2);
color: var(--green-text);
}
.nav-icon {
@@ -214,7 +233,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
}
.footer-by {
color: var(--green);
color: var(--green-text);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.4px;
@@ -254,7 +273,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
.quota-plan {
font-size: 11px;
color: var(--green);
color: var(--green-text);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
@@ -314,7 +333,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
HERO
=============================== */
.hero {
position: relative;
position: sticky;
top: 0;
z-index: 5;
border-radius: var(--r-lg);
padding: 28px 32px;
margin-bottom: 24px;
@@ -469,7 +490,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.badge {
background: rgba(29, 185, 84, 0.15);
color: var(--green);
color: var(--green-text);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
@@ -495,8 +516,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.input::placeholder { color: var(--text-3); }
.input:focus {
background: #333;
border-color: rgba(29, 185, 84, 0.4);
background: var(--bg-input-hover);
border-color: var(--green);
}
.input-sm { max-width: 200px; }
@@ -663,8 +684,8 @@ input[type="number"]::-webkit-inner-spin-button {
font-weight: 800;
}
.percent.green { color: var(--green); }
.percent.blue { color: var(--blue); }
.percent.green { color: var(--green-text); }
.percent.blue { color: var(--blue-text); }
.percent.pink { color: var(--pink-hover); }
.progress {
@@ -711,10 +732,10 @@ input[type="number"]::-webkit-inner-spin-button {
line-height: 1.6;
}
.log .l-ok { color: var(--green); }
.log .l-ok { color: var(--green-text); }
.log .l-err { color: var(--red); }
.log .l-info { color: var(--blue); }
.log .l-warn { color: #FDCB6E; }
.log .l-info { color: var(--blue-text); }
.log .l-warn { color: var(--amber-text); }
.log .l-muted { color: var(--text-3); }
/* ===============================
@@ -774,7 +795,7 @@ input[type="number"]::-webkit-inner-spin-button {
.hint strong { color: var(--text); font-weight: 700; }
.hint code {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-input);
padding: 1px 6px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
@@ -838,7 +859,7 @@ input[type="number"]::-webkit-inner-spin-button {
INFO ROW (Upgrade tab)
=============================== */
.info-row {
color: var(--green);
color: var(--green-text);
font-size: 13px;
font-weight: 700;
margin-bottom: 14px;
@@ -862,7 +883,7 @@ input[type="number"]::-webkit-inner-spin-button {
min-height: 18px;
}
.update-status.ok { color: var(--green); }
.update-status.ok { color: var(--green-text); }
.update-status.warn { color: #FDCB6E; }
.update-status.err { color: var(--red); }
@@ -893,7 +914,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.save-status {
color: var(--green);
color: var(--green-text);
font-weight: 700;
font-size: 13px;
opacity: 0;
@@ -1003,7 +1024,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.toast.toast-error { border-color: var(--red); color: var(--red); }
.toast.toast-success { border-color: var(--green); color: var(--green); }
.toast.toast-success { border-color: var(--green); color: var(--green-text); }
/* ===============================
SCROLLBARS (WebKit)
@@ -1011,11 +1032,11 @@ input[type="number"]::-webkit-inner-spin-button {
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: #404040;
background: var(--text-3);
border-radius: 5px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: #606060; }
::-webkit-scrollbar-thumb:hover { background: var(--text-2); }
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
@@ -1581,7 +1602,7 @@ input[type="number"]::-webkit-inner-spin-button {
.beatport-status.loading { color: var(--text-3); font-style: italic; }
.beatport-status.error { color: var(--red); font-weight: 600; }
.beatport-status.ok { color: var(--green); font-weight: 600; }
.beatport-status.ok { color: var(--green-text); font-weight: 600; }
.beatport-output-info {
margin-top: 6px;
@@ -1592,7 +1613,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-output-info.warn {
color: var(--amber-hover);
color: var(--amber-text);
font-family: inherit;
}
@@ -1635,7 +1656,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-table tbody tr:hover {
background: rgba(99, 102, 241, 0.06);
background: var(--bg-input);
}
.beatport-table tbody tr.already-downloaded td {
@@ -1643,11 +1664,20 @@ input[type="number"]::-webkit-inner-spin-button {
}
.beatport-table tbody tr.already-downloaded .col-state {
color: var(--green);
color: var(--green-text);
font-weight: 600;
}
.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>