Tab Metadati: editor dei tag audio (MP3, M4A, FLAC)
Nuova sezione 'Metadati' nella sidebar con editor completo dei tag: titolo, artista, album, album artist, anno, traccia, genere, BPM, key, commento, copertina. - core/metadata.py: read/write metadati via mutagen, supporto ID3 (MP3), MP4 atoms (M4A/AAC) e Vorbis comments (FLAC) - api/bridge.py: pick_audio_file, pick_image_file, read_metadata, save_metadata. Cover passata come base64 al frontend per preview - webui: hero ambra, form a griglia 2 colonne, anteprima cover 180x180, bottoni cambia/rimuovi copertina, ricarica file - requirements.txt: aggiunto mutagen>=1.47.0 - build_macos.py / build_windows.py: --collect-all mutagen Modifiche correlate (in attesa di decisione utente sulla distribuzione degli aggiornamenti): - VERSION bumpato a v1.2.2 in core/config.py - check_update ora interroga l'API GitHub Releases invece di djluza.com/version.json (funzionera quando il repo sara pubblico) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8423ec215
commit
946452ef03
9 files changed
+712
-10
No files matched your search
+70
-9
@@ -20,6 +20,7 @@ from core.downloader import (
|
|||||||
is_stopped as download_is_stopped,
|
is_stopped as download_is_stopped,
|
||||||
)
|
)
|
||||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
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 (
|
from core.upgrader import (
|
||||||
upgrade_folder,
|
upgrade_folder,
|
||||||
request_stop as request_upgrade_stop,
|
request_stop as request_upgrade_stop,
|
||||||
@@ -134,22 +135,43 @@ class Api:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def check_update(self) -> dict:
|
def check_update(self) -> dict:
|
||||||
"""Controlla aggiornamenti chiamando version.json remoto."""
|
"""Controlla aggiornamenti interrogando l'API GitHub Releases."""
|
||||||
import urllib.request
|
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:
|
try:
|
||||||
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"})
|
resp = requests.get(API_URL, headers={
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
"User-Agent": "MusicDownload",
|
||||||
data = json.loads(resp.read().decode())
|
"Accept": "application/vnd.github+json",
|
||||||
remote = data.get("version", "")
|
}, 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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"current": VERSION,
|
"current": VERSION,
|
||||||
"remote": remote,
|
"remote": remote,
|
||||||
"is_new": bool(remote and remote != VERSION),
|
"is_new": bool(remote and remote != VERSION),
|
||||||
"download_url": data.get("download_url", ""),
|
"download_url": download_url,
|
||||||
"notes": data.get("notes", ""),
|
"notes": notes[:500] if notes else "", # tronca per UI
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "current": VERSION}
|
return {"ok": False, "error": str(e), "current": VERSION}
|
||||||
@@ -221,6 +243,45 @@ class Api:
|
|||||||
def open_external_url(self, url: str) -> None:
|
def open_external_url(self, url: str) -> None:
|
||||||
webbrowser.open(url)
|
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)
|
# Audio folder scan (Upgrade tab)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ def run_pyinstaller():
|
|||||||
"--hidden-import", "webview",
|
"--hidden-import", "webview",
|
||||||
"--hidden-import", "webview.platforms.cocoa",
|
"--hidden-import", "webview.platforms.cocoa",
|
||||||
"--hidden-import", "requests",
|
"--hidden-import", "requests",
|
||||||
|
"--collect-all", "mutagen",
|
||||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ def run_pyinstaller():
|
|||||||
"--hidden-import", "requests",
|
"--hidden-import", "requests",
|
||||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
||||||
"--collect-all", "webview",
|
"--collect-all", "webview",
|
||||||
|
"--collect-all", "mutagen",
|
||||||
# Binari bundled
|
# Binari bundled
|
||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.0"
|
VERSION = "v1.2.2"
|
||||||
|
|
||||||
|
|
||||||
def _get_config_dir() -> Path:
|
def _get_config_dir() -> Path:
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pywebview>=5.0
|
pywebview>=5.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
yt-dlp>=2024.0.0
|
yt-dlp>=2024.0.0
|
||||||
|
mutagen>=1.47.0
|
||||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
--pink-hover: #F472B6;
|
--pink-hover: #F472B6;
|
||||||
--pink-dim: #831843;
|
--pink-dim: #831843;
|
||||||
|
|
||||||
|
--amber: #F59E0B;
|
||||||
|
--amber-hover: #FBBF24;
|
||||||
|
--amber-dim: #78350F;
|
||||||
|
|
||||||
--red: #E22134;
|
--red: #E22134;
|
||||||
--red-hover: #FF4055;
|
--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%);
|
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-content { position: relative; z-index: 2; max-width: 70%; }
|
||||||
|
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
@@ -246,6 +254,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
.hero-eyebrow.blue { color: var(--blue); }
|
.hero-eyebrow.blue { color: var(--blue); }
|
||||||
.hero-eyebrow.purple { color: var(--purple); }
|
.hero-eyebrow.purple { color: var(--purple); }
|
||||||
.hero-eyebrow.pink { color: var(--pink-hover); }
|
.hero-eyebrow.pink { color: var(--pink-hover); }
|
||||||
|
.hero-eyebrow.amber { color: var(--amber-hover); }
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
@@ -463,6 +472,11 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-amber {
|
||||||
|
background: var(--amber);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: #00CEC9;
|
background: #00CEC9;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -819,3 +833,96 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
|
|
||||||
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||||
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
|
.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; }
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@
|
|||||||
<span class="nav-icon">⚡</span>
|
<span class="nav-icon">⚡</span>
|
||||||
<span>Upgrade</span>
|
<span>Upgrade</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="meta">
|
||||||
|
<span class="nav-icon">🏷</span>
|
||||||
|
<span>Metadati</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-view="settings">
|
<button class="nav-item" data-view="settings">
|
||||||
<span class="nav-icon">⚙</span>
|
<span class="nav-icon">⚙</span>
|
||||||
<span>Impostazioni</span>
|
<span>Impostazioni</span>
|
||||||
@@ -237,6 +241,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== VIEW: METADATI ===== -->
|
||||||
|
<section class="view" id="view-meta">
|
||||||
|
<header class="hero hero-amber">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-eyebrow amber">METADATI</div>
|
||||||
|
<h1 class="hero-title">Modifica tag audio</h1>
|
||||||
|
<p class="hero-subtitle">Titolo · Artista · Album · Anno · Copertina · BPM · Key · MP3/M4A/FLAC</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-deco">🏷</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2 class="section-label">File</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
|
||||||
|
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-info" id="metaInfo" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="metaForm" hidden>
|
||||||
|
<h2 class="section-label">Tag</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label class="field-label">Titolo</label>
|
||||||
|
<input type="text" id="metaTitle" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label class="field-label">Artista</label>
|
||||||
|
<input type="text" id="metaArtist" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Album</label>
|
||||||
|
<input type="text" id="metaAlbum" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Album Artist</label>
|
||||||
|
<input type="text" id="metaAlbumArtist" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Anno</label>
|
||||||
|
<input type="text" id="metaYear" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Traccia</label>
|
||||||
|
<input type="text" id="metaTrack" class="input pill" placeholder="1/12">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Genere</label>
|
||||||
|
<input type="text" id="metaGenre" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">BPM</label>
|
||||||
|
<input type="text" id="metaBpm" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Key</label>
|
||||||
|
<input type="text" id="metaKey" class="input pill">
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label class="field-label">Commento</label>
|
||||||
|
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Copertina</h2>
|
||||||
|
<div class="card cover-card">
|
||||||
|
<div class="cover-preview" id="metaCoverPreview">
|
||||||
|
<span class="cover-empty">Nessuna copertina</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-actions">
|
||||||
|
<button class="btn btn-ghost pill" id="metaCoverBtn">📂 Cambia copertina</button>
|
||||||
|
<button class="btn btn-ghost pill" id="metaCoverRemoveBtn">✕ Rimuovi</button>
|
||||||
|
<div class="cover-hint" id="metaCoverHint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card actions">
|
||||||
|
<button class="btn btn-amber pill btn-lg" id="metaSaveBtn">💾 Salva tag</button>
|
||||||
|
<button class="btn btn-ghost pill btn-lg" id="metaReloadBtn">↺ Ricarica</button>
|
||||||
|
<div class="counter" id="metaStatus"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ===== VIEW: SETTINGS ===== -->
|
<!-- ===== VIEW: SETTINGS ===== -->
|
||||||
<section class="view" id="view-settings">
|
<section class="view" id="view-settings">
|
||||||
<header class="hero hero-purple">
|
<header class="hero hero-purple">
|
||||||
|
|||||||
+139
@@ -15,6 +15,7 @@ const state = {
|
|||||||
downloading: false,
|
downloading: false,
|
||||||
upgrading: false,
|
upgrading: false,
|
||||||
videoDownloading: false,
|
videoDownloading: false,
|
||||||
|
meta: { path: "", newCoverPath: "", removeCover: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for pywebview ready
|
// Wait for pywebview ready
|
||||||
@@ -391,6 +392,144 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
|
|||||||
$("#stopUpgradeBtn").disabled = true;
|
$("#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 = `
|
||||||
|
<span><strong>Formato:</strong>${d.format || "—"}</span>
|
||||||
|
<span><strong>Bitrate:</strong>${d.bitrate ? d.bitrate + " kbps" : "—"}</span>
|
||||||
|
<span><strong>Durata:</strong>${formatDuration(d.duration)}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = '<span class="cover-empty">Verra rimossa al salvataggio</span>';
|
||||||
|
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
|
// SETTINGS tab
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user