diff --git a/api/bridge.py b/api/bridge.py index e150bff..19be6cd 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -20,6 +20,7 @@ from core.downloader import ( is_stopped as download_is_stopped, ) from core.spotify_client import get_access_token, resolve_spotify_url +from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS from core.upgrader import ( upgrade_folder, request_stop as request_upgrade_stop, @@ -134,22 +135,43 @@ class Api: return {"ok": True} def check_update(self) -> dict: - """Controlla aggiornamenti chiamando version.json remoto.""" - import urllib.request + """Controlla aggiornamenti interrogando l'API GitHub Releases.""" + import platform + import requests - UPDATE_URL = "https://www.djluza.com/musicdownload/version.json" + API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest" try: - req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"}) - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode()) - remote = data.get("version", "") + resp = requests.get(API_URL, headers={ + "User-Agent": "MusicDownload", + "Accept": "application/vnd.github+json", + }, timeout=10) + resp.raise_for_status() + data = resp.json() + + remote = data.get("tag_name", "") + notes = data.get("body", "") or "" + html_url = data.get("html_url", "") + + # Trova l'asset giusto per la piattaforma + assets = data.get("assets", []) or [] + is_macos = platform.system() == "Darwin" + keyword = "macos" if is_macos else "windows" + download_url = "" + for a in assets: + name = (a.get("name") or "").lower() + if keyword in name and name.endswith(".zip"): + download_url = a.get("browser_download_url", "") + break + if not download_url: + download_url = html_url # fallback: pagina release + return { "ok": True, "current": VERSION, "remote": remote, "is_new": bool(remote and remote != VERSION), - "download_url": data.get("download_url", ""), - "notes": data.get("notes", ""), + "download_url": download_url, + "notes": notes[:500] if notes else "", # tronca per UI } except Exception as e: return {"ok": False, "error": str(e), "current": VERSION} @@ -221,6 +243,45 @@ class Api: def open_external_url(self, url: str) -> None: webbrowser.open(url) + # ------------------------------------------------------------------ + # Metadata editor + # ------------------------------------------------------------------ + def pick_audio_file(self) -> str: + return self.browse_file([ + "Audio (*.mp3 *.m4a *.mp4 *.aac *.flac)", + "All files (*.*)", + ]) + + def pick_image_file(self) -> str: + return self.browse_file([ + "Immagini (*.jpg *.jpeg *.png)", + "All files (*.*)", + ]) + + def read_metadata(self, path: str) -> dict: + try: + data = read_metadata(path) + return {"ok": True, "data": data} + except Exception as e: + return {"ok": False, "error": str(e)} + + def save_metadata(self, payload: dict) -> dict: + path = (payload.get("path") or "").strip() + if not path: + return {"ok": False, "error": "Path mancante"} + if not os.path.exists(path): + return {"ok": False, "error": "File non trovato"} + + data = payload.get("data") or {} + cover_path = (payload.get("cover_path") or "").strip() or None + remove_cover = bool(payload.get("remove_cover", False)) + + try: + write_metadata(path, data, cover_path, remove_cover) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + # ------------------------------------------------------------------ # Audio folder scan (Upgrade tab) # ------------------------------------------------------------------ diff --git a/build_macos.py b/build_macos.py index 3d8c00d..6edc838 100644 --- a/build_macos.py +++ b/build_macos.py @@ -220,6 +220,7 @@ def run_pyinstaller(): "--hidden-import", "webview", "--hidden-import", "webview.platforms.cocoa", "--hidden-import", "requests", + "--collect-all", "mutagen", # Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs) *add_binaries, # Entry point diff --git a/build_windows.py b/build_windows.py index a032605..9894ce8 100644 --- a/build_windows.py +++ b/build_windows.py @@ -116,6 +116,7 @@ def run_pyinstaller(): "--hidden-import", "requests", # Collegamenti completi alle DLL .NET di pywebview/pythonnet "--collect-all", "webview", + "--collect-all", "mutagen", # Binari bundled *add_binaries, # Entry point diff --git a/core/config.py b/core/config.py index ada4987..85c40f8 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.0" +VERSION = "v1.2.2" def _get_config_dir() -> Path: diff --git a/core/metadata.py b/core/metadata.py new file mode 100644 index 0000000..22b7a82 --- /dev/null +++ b/core/metadata.py @@ -0,0 +1,301 @@ +"""Lettura e scrittura metadati audio (ID3 per MP3, MP4 per M4A, Vorbis per FLAC).""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Optional + +from mutagen import File as MutagenFile +from mutagen.id3 import ( + ID3, ID3NoHeaderError, + APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY, +) +from mutagen.mp4 import MP4, MP4Cover +from mutagen.flac import FLAC, Picture + + +SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac") + + +def _text(value) -> str: + """Estrai testo da un frame mutagen.""" + if value is None: + return "" + if hasattr(value, "text"): + t = value.text + if isinstance(t, list) and t: + return str(t[0]) + return str(t) + if isinstance(value, list) and value: + return str(value[0]) + return str(value) + + +def _read_mp3(path: str, result: dict) -> dict: + result["format"] = "MP3" + try: + tags = ID3(path) + except ID3NoHeaderError: + return result + + result["title"] = _text(tags.get("TIT2")) + result["artist"] = _text(tags.get("TPE1")) + result["album_artist"] = _text(tags.get("TPE2")) + result["album"] = _text(tags.get("TALB")) + result["year"] = _text(tags.get("TDRC")) + result["track"] = _text(tags.get("TRCK")) + result["genre"] = _text(tags.get("TCON")) + result["bpm"] = _text(tags.get("TBPM")) + result["key"] = _text(tags.get("TKEY")) + + for k in tags.keys(): + if k.startswith("COMM"): + result["comment"] = _text(tags[k]) + break + + for k in tags.keys(): + if k.startswith("APIC"): + apic = tags[k] + result["cover_base64"] = base64.b64encode(apic.data).decode("ascii") + result["cover_mime"] = apic.mime or "image/jpeg" + break + + return result + + +def _read_mp4(path: str, result: dict) -> dict: + result["format"] = "M4A/MP4" + m = MP4(path) + tags = m.tags or {} + result["title"] = _text(tags.get("\xa9nam")) + result["artist"] = _text(tags.get("\xa9ART")) + result["album_artist"] = _text(tags.get("aART")) + result["album"] = _text(tags.get("\xa9alb")) + result["year"] = _text(tags.get("\xa9day")) + track_pair = tags.get("trkn") + if track_pair and isinstance(track_pair, list) and track_pair: + num, tot = track_pair[0] if isinstance(track_pair[0], tuple) else (track_pair[0], 0) + result["track"] = f"{num}/{tot}" if tot else str(num) + result["genre"] = _text(tags.get("\xa9gen")) + result["comment"] = _text(tags.get("\xa9cmt")) + result["bpm"] = _text(tags.get("tmpo")) + + covers = tags.get("covr") + if covers: + c = covers[0] + result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii") + result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png" + return result + + +def _read_flac(path: str, result: dict) -> dict: + result["format"] = "FLAC" + f = FLAC(path) + g = lambda k: (f.get(k) or [""])[0] if f.get(k) else "" + result["title"] = g("title") + result["artist"] = g("artist") + result["album_artist"] = g("albumartist") + result["album"] = g("album") + result["year"] = g("date") + result["track"] = g("tracknumber") + result["genre"] = g("genre") + result["bpm"] = g("bpm") + result["key"] = g("initialkey") + result["comment"] = g("comment") + if f.pictures: + pic = f.pictures[0] + result["cover_base64"] = base64.b64encode(pic.data).decode("ascii") + result["cover_mime"] = pic.mime or "image/jpeg" + return result + + +def read_metadata(path: str) -> dict: + """Ritorna un dict con tutti i metadati leggibili dal file.""" + p = Path(path) + if not p.exists(): + raise FileNotFoundError(path) + + ext = p.suffix.lower() + if ext not in SUPPORTED_EXTS: + raise ValueError(f"Formato non supportato: {ext}") + + result = { + "path": str(p), + "filename": p.name, + "format": "", + "title": "", "artist": "", "album_artist": "", + "album": "", "year": "", "track": "", + "genre": "", "comment": "", + "bpm": "", "key": "", + "duration": 0, "bitrate": 0, + "cover_base64": "", "cover_mime": "", + } + + audio = MutagenFile(str(p)) + if audio is None: + raise ValueError(f"Impossibile leggere: {ext}") + + info = getattr(audio, "info", None) + if info: + result["duration"] = int(getattr(info, "length", 0) or 0) + result["bitrate"] = int((getattr(info, "bitrate", 0) or 0) // 1000) + + if ext == ".mp3": + return _read_mp3(str(p), result) + if ext in (".m4a", ".mp4", ".aac"): + return _read_mp4(str(p), result) + if ext == ".flac": + return _read_flac(str(p), result) + return result + + +def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None: + try: + tags = ID3(path) + except ID3NoHeaderError: + tags = ID3() + + def set_or_del(key, frame_cls, value): + if value: + tags[key] = frame_cls(encoding=3, text=value) + elif key in tags: + del tags[key] + + set_or_del("TIT2", TIT2, data.get("title", "")) + set_or_del("TPE1", TPE1, data.get("artist", "")) + set_or_del("TPE2", TPE2, data.get("album_artist", "")) + set_or_del("TALB", TALB, data.get("album", "")) + set_or_del("TDRC", TDRC, data.get("year", "")) + set_or_del("TRCK", TRCK, data.get("track", "")) + set_or_del("TCON", TCON, data.get("genre", "")) + set_or_del("TBPM", TBPM, data.get("bpm", "")) + set_or_del("TKEY", TKEY, data.get("key", "")) + + # Rimuovi tutti i COMM esistenti + for k in list(tags.keys()): + if k.startswith("COMM"): + del tags[k] + comment = data.get("comment", "") + if comment: + tags.add(COMM(encoding=3, lang="ita", desc="", text=comment)) + + # Cover + if remove_cover or cover_path: + for k in list(tags.keys()): + if k.startswith("APIC"): + del tags[k] + if cover_path: + cp = Path(cover_path) + if cp.exists(): + ext = cp.suffix.lower() + mime = "image/png" if ext == ".png" else "image/jpeg" + with open(cp, "rb") as f: + cover_data = f.read() + tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data)) + + tags.save(path, v2_version=3) + + +def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None: + m = MP4(path) + + def set_or_del(key, value): + if value: + m[key] = value + elif key in m: + del m[key] + + set_or_del("\xa9nam", data.get("title", "")) + set_or_del("\xa9ART", data.get("artist", "")) + set_or_del("aART", data.get("album_artist", "")) + set_or_del("\xa9alb", data.get("album", "")) + set_or_del("\xa9day", data.get("year", "")) + set_or_del("\xa9gen", data.get("genre", "")) + set_or_del("\xa9cmt", data.get("comment", "")) + + track = data.get("track", "") + if track: + try: + parts = track.split("/") + num = int(parts[0]) + tot = int(parts[1]) if len(parts) > 1 else 0 + m["trkn"] = [(num, tot)] + except (ValueError, IndexError): + pass + elif "trkn" in m: + del m["trkn"] + + bpm = data.get("bpm", "") + if bpm: + try: + m["tmpo"] = [int(bpm)] + except ValueError: + pass + elif "tmpo" in m: + del m["tmpo"] + + if remove_cover or cover_path: + if "covr" in m: + del m["covr"] + if cover_path: + cp = Path(cover_path) + if cp.exists(): + ext = cp.suffix.lower() + fmt = MP4Cover.FORMAT_PNG if ext == ".png" else MP4Cover.FORMAT_JPEG + with open(cp, "rb") as f: + cover_data = f.read() + m["covr"] = [MP4Cover(cover_data, imageformat=fmt)] + + m.save() + + +def _write_flac(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None: + f = FLAC(path) + + def set_or_del(key, value): + if value: + f[key] = value + elif key in f: + del f[key] + + set_or_del("title", data.get("title", "")) + set_or_del("artist", data.get("artist", "")) + set_or_del("albumartist", data.get("album_artist", "")) + set_or_del("album", data.get("album", "")) + set_or_del("date", data.get("year", "")) + set_or_del("tracknumber", data.get("track", "")) + set_or_del("genre", data.get("genre", "")) + set_or_del("bpm", data.get("bpm", "")) + set_or_del("initialkey", data.get("key", "")) + set_or_del("comment", data.get("comment", "")) + + if remove_cover or cover_path: + f.clear_pictures() + if cover_path: + cp = Path(cover_path) + if cp.exists(): + ext = cp.suffix.lower() + pic = Picture() + with open(cp, "rb") as fh: + pic.data = fh.read() + pic.type = 3 + pic.mime = "image/png" if ext == ".png" else "image/jpeg" + f.add_picture(pic) + + f.save() + + +def write_metadata(path: str, data: dict, cover_path: Optional[str] = None, + remove_cover: bool = False) -> None: + """Salva i metadati. Se remove_cover=True rimuove la copertina esistente + senza sostituirla. Se cover_path e fornito, la sostituisce.""" + ext = Path(path).suffix.lower() + if ext == ".mp3": + _write_mp3(path, data, cover_path, remove_cover) + elif ext in (".m4a", ".mp4", ".aac"): + _write_mp4(path, data, cover_path, remove_cover) + elif ext == ".flac": + _write_flac(path, data, cover_path, remove_cover) + else: + raise ValueError(f"Formato non supportato: {ext}") diff --git a/requirements.txt b/requirements.txt index ed571a3..2f340d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ pywebview>=5.0 requests>=2.31.0 yt-dlp>=2024.0.0 +mutagen>=1.47.0 pyobjc-core>=10.0 ; sys_platform == "darwin" pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin" pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin" diff --git a/webui/css/style.css b/webui/css/style.css index aea449f..55848cf 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -30,6 +30,10 @@ --pink-hover: #F472B6; --pink-dim: #831843; + --amber: #F59E0B; + --amber-hover: #FBBF24; + --amber-dim: #78350F; + --red: #E22134; --red-hover: #FF4055; @@ -233,6 +237,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%); } +.hero-amber { + background: linear-gradient(135deg, #F59E0B 0%, #78350F 60%, #3B1E07 100%); +} + .hero-content { position: relative; z-index: 2; max-width: 70%; } .hero-eyebrow { @@ -246,6 +254,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } .hero-eyebrow.blue { color: var(--blue); } .hero-eyebrow.purple { color: var(--purple); } .hero-eyebrow.pink { color: var(--pink-hover); } +.hero-eyebrow.amber { color: var(--amber-hover); } .hero-title { font-size: 36px; @@ -463,6 +472,11 @@ input[type="number"]::-webkit-inner-spin-button { color: #fff; } +.btn-amber { + background: var(--amber); + color: #000; +} + .btn-success { background: #00CEC9; color: #000; @@ -819,3 +833,96 @@ input[type="number"]::-webkit-inner-spin-button { .log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); } .main::-webkit-scrollbar-thumb { border-color: var(--bg); } + +/* =============================== + METADATA EDITOR + =============================== */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 18px; +} + +.form-grid .field { margin-bottom: 0; } +.form-grid .field.full { grid-column: 1 / -1; } + +.textarea { + border-radius: var(--r-md); + padding: 12px 16px; + resize: vertical; + min-height: 70px; + font-family: inherit; + line-height: 1.5; +} + +.meta-info { + display: flex; + flex-wrap: wrap; + gap: 14px 22px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--divider); + color: var(--text-2); + font-size: 12.5px; +} + +.meta-info span strong { + color: var(--text); + font-weight: 700; + margin-right: 6px; +} + +.cover-card { + display: grid; + grid-template-columns: 180px 1fr; + gap: 24px; + align-items: start; +} + +.cover-preview { + width: 180px; + height: 180px; + border-radius: var(--r-md); + background: var(--bg-card-2); + border: 1px dashed var(--bg-input-hover); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.cover-preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.cover-empty { + color: var(--text-3); + font-size: 12px; + text-align: center; + padding: 0 12px; +} + +.cover-actions { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +.cover-hint { + color: var(--text-3); + font-size: 12px; + margin-top: 4px; +} + +.cover-hint.set { color: var(--amber-hover); font-weight: 700; } +.cover-hint.remove { color: var(--red); font-weight: 700; } + +@media (max-width: 720px) { + .form-grid { grid-template-columns: 1fr; } + .cover-card { grid-template-columns: 1fr; } +} diff --git a/webui/index.html b/webui/index.html index bdd8c11..647856b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -33,6 +33,10 @@ ⚡ Upgrade + +
Nessun file selezionato
+ + + + + + +
diff --git a/webui/js/app.js b/webui/js/app.js index e7ff32d..fc731d9 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -15,6 +15,7 @@ const state = { downloading: false, upgrading: false, videoDownloading: false, + meta: { path: "", newCoverPath: "", removeCover: false }, }; // Wait for pywebview ready @@ -391,6 +392,144 @@ $("#stopUpgradeBtn").addEventListener("click", async () => { $("#stopUpgradeBtn").disabled = true; }); +// ============================================================ +// METADATA editor +// ============================================================ +function formatDuration(sec) { + if (!sec) return "—"; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function populateMetaForm(d) { + $("#metaTitle").value = d.title || ""; + $("#metaArtist").value = d.artist || ""; + $("#metaAlbum").value = d.album || ""; + $("#metaAlbumArtist").value = d.album_artist || ""; + $("#metaYear").value = d.year || ""; + $("#metaTrack").value = d.track || ""; + $("#metaGenre").value = d.genre || ""; + $("#metaBpm").value = d.bpm || ""; + $("#metaKey").value = d.key || ""; + $("#metaComment").value = d.comment || ""; + + // Info riga sotto il path + const info = $("#metaInfo"); + info.hidden = false; + info.innerHTML = ` + Formato:${d.format || "—"} + Bitrate:${d.bitrate ? d.bitrate + " kbps" : "—"} + Durata:${formatDuration(d.duration)} + `; + + // Cover + const preview = $("#metaCoverPreview"); + preview.innerHTML = ""; + if (d.cover_base64) { + const img = document.createElement("img"); + img.src = `data:${d.cover_mime || "image/jpeg"};base64,${d.cover_base64}`; + preview.appendChild(img); + } else { + const span = document.createElement("span"); + span.className = "cover-empty"; + span.textContent = "Nessuna copertina"; + preview.appendChild(span); + } + $("#metaCoverHint").textContent = ""; + $("#metaCoverHint").className = "cover-hint"; + + state.meta.newCoverPath = ""; + state.meta.removeCover = false; +} + +async function loadMetaFile(path) { + const res = await window.pywebview.api.read_metadata(path); + if (!res.ok) { + toast("Errore lettura: " + (res.error || ""), "error"); + return; + } + state.meta.path = path; + $("#metaPathDisplay").textContent = path; + $("#metaPathDisplay").classList.remove("empty"); + $("#metaForm").hidden = false; + populateMetaForm(res.data); +} + +$("#metaPickBtn").addEventListener("click", async () => { + const path = await window.pywebview.api.pick_audio_file(); + if (path) await loadMetaFile(path); +}); + +$("#metaCoverBtn").addEventListener("click", async () => { + const path = await window.pywebview.api.pick_image_file(); + if (!path) return; + state.meta.newCoverPath = path; + state.meta.removeCover = false; + + // Mostra anteprima leggendo il file via fetch (file:// non funziona da webview; + // usiamo solo il nome come hint, l'utente vedra il risultato dopo Save) + const hint = $("#metaCoverHint"); + hint.textContent = "Nuova copertina: " + path.split("/").pop(); + hint.className = "cover-hint set"; +}); + +$("#metaCoverRemoveBtn").addEventListener("click", () => { + state.meta.newCoverPath = ""; + state.meta.removeCover = true; + const preview = $("#metaCoverPreview"); + preview.innerHTML = 'Verra rimossa al salvataggio'; + const hint = $("#metaCoverHint"); + hint.textContent = "Copertina marcata per rimozione"; + hint.className = "cover-hint remove"; +}); + +$("#metaReloadBtn").addEventListener("click", async () => { + if (state.meta.path) await loadMetaFile(state.meta.path); +}); + +$("#metaSaveBtn").addEventListener("click", async () => { + if (!state.meta.path) { + toast("Nessun file caricato", "error"); + return; + } + + $("#metaSaveBtn").disabled = true; + const status = $("#metaStatus"); + status.textContent = "Salvataggio…"; + + const payload = { + path: state.meta.path, + data: { + title: $("#metaTitle").value, + artist: $("#metaArtist").value, + album: $("#metaAlbum").value, + album_artist: $("#metaAlbumArtist").value, + year: $("#metaYear").value, + track: $("#metaTrack").value, + genre: $("#metaGenre").value, + bpm: $("#metaBpm").value, + key: $("#metaKey").value, + comment: $("#metaComment").value, + }, + cover_path: state.meta.newCoverPath, + remove_cover: state.meta.removeCover, + }; + + const res = await window.pywebview.api.save_metadata(payload); + $("#metaSaveBtn").disabled = false; + + if (res.ok) { + status.textContent = "✓ Tag salvati"; + toast("Tag salvati", "success"); + // Ricarica per mostrare lo stato aggiornato (es. nuova cover) + await loadMetaFile(state.meta.path); + } else { + status.textContent = ""; + toast("Errore: " + (res.error || ""), "error"); + } +}); + // ============================================================ // SETTINGS tab // ============================================================