bridge: metadata pipeline per tag ID3 su download search
This commit is contained in:
1 parent
73b4480890
commit
25b646d754
1 file changed
+192
-10
+192
-10
@@ -10,7 +10,7 @@ import time
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ from core.spotify_client import (
|
|||||||
SpotifyAuthRequired,
|
SpotifyAuthRequired,
|
||||||
)
|
)
|
||||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||||
|
from core import tagger
|
||||||
from core.recorder import (
|
from core.recorder import (
|
||||||
list_input_devices,
|
list_input_devices,
|
||||||
start_recording,
|
start_recording,
|
||||||
@@ -516,13 +517,20 @@ class Api:
|
|||||||
def start_tracks_download(self, payload: dict) -> dict:
|
def start_tracks_download(self, payload: dict) -> dict:
|
||||||
"""Avvia il download di una tracklist gia parsata
|
"""Avvia il download di una tracklist gia parsata
|
||||||
(lista di {name, artist}). Se 'subfolder' e presente,
|
(lista di {name, artist}). Se 'subfolder' e presente,
|
||||||
scarica in output_dir/subfolder."""
|
scarica in output_dir/subfolder.
|
||||||
|
|
||||||
|
`payload['metadata']` opzionale: lista parallela a `tracks` con dict
|
||||||
|
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
|
||||||
|
cover_url). Se presente, i file MP3 vengono nominati come
|
||||||
|
"Artista - Titolo.mp3" e taggati post-download.
|
||||||
|
"""
|
||||||
if self._any_job_running():
|
if self._any_job_running():
|
||||||
return {"ok": False, "error": "Un download gia in corso"}
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
tracks = payload.get("tracks") or []
|
tracks = payload.get("tracks") or []
|
||||||
output_dir = (payload.get("output_dir") or "").strip()
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
subfolder = (payload.get("subfolder") or "").strip()
|
subfolder = (payload.get("subfolder") or "").strip()
|
||||||
|
metadata_list = payload.get("metadata") or None
|
||||||
if not tracks:
|
if not tracks:
|
||||||
return {"ok": False, "error": "Nessuna traccia fornita"}
|
return {"ok": False, "error": "Nessuna traccia fornita"}
|
||||||
if not output_dir:
|
if not output_dir:
|
||||||
@@ -541,6 +549,7 @@ class Api:
|
|||||||
self._download_thread = threading.Thread(
|
self._download_thread = threading.Thread(
|
||||||
target=self._tracks_worker,
|
target=self._tracks_worker,
|
||||||
args=(list(tracks), output_dir),
|
args=(list(tracks), output_dir),
|
||||||
|
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
@@ -548,7 +557,13 @@ class Api:
|
|||||||
|
|
||||||
def start_urls_download(self, payload: dict) -> dict:
|
def start_urls_download(self, payload: dict) -> dict:
|
||||||
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||||
(bypass search). Usato dal flow del tab 'YouTube Search'."""
|
(bypass search). Usato dal flow del tab 'YouTube Search'.
|
||||||
|
|
||||||
|
`payload['metadata']` opzionale: lista parallela a `urls` con dict
|
||||||
|
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
|
||||||
|
cover_url). Se presente, i file MP3 vengono nominati come
|
||||||
|
"Artista - Titolo.mp3" e taggati post-download.
|
||||||
|
"""
|
||||||
if self._any_job_running():
|
if self._any_job_running():
|
||||||
return {"ok": False, "error": "Un download gia in corso"}
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
@@ -556,6 +571,7 @@ class Api:
|
|||||||
titles = payload.get("titles") or []
|
titles = payload.get("titles") or []
|
||||||
output_dir = (payload.get("output_dir") or "").strip()
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
subfolder = (payload.get("subfolder") or "").strip()
|
subfolder = (payload.get("subfolder") or "").strip()
|
||||||
|
metadata_list = payload.get("metadata") or None
|
||||||
if not urls:
|
if not urls:
|
||||||
return {"ok": False, "error": "Nessuna URL fornita"}
|
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||||
if len(urls) != len(titles):
|
if len(urls) != len(titles):
|
||||||
@@ -575,6 +591,7 @@ class Api:
|
|||||||
self._download_thread = threading.Thread(
|
self._download_thread = threading.Thread(
|
||||||
target=self._urls_worker,
|
target=self._urls_worker,
|
||||||
args=(list(urls), list(titles), output_dir),
|
args=(list(urls), list(titles), output_dir),
|
||||||
|
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
@@ -585,7 +602,8 @@ class Api:
|
|||||||
self._log("download", "[INFO] Interruzione richiesta...")
|
self._log("download", "[INFO] Interruzione richiesta...")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def _tracks_worker(self, tracks: list, output_dir: str) -> None:
|
def _tracks_worker(self, tracks: list, output_dir: str,
|
||||||
|
metadata_list: Optional[list] = None) -> None:
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
bitrate = cfg.get("bitrate", "320K")
|
bitrate = cfg.get("bitrate", "320K")
|
||||||
@@ -595,6 +613,31 @@ class Api:
|
|||||||
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
||||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||||
|
|
||||||
|
# Pipeline tagging: se metadata_list fornito, calcola i filename
|
||||||
|
# ("Artista - Titolo") e prepara callback per scrivere ID3 tag
|
||||||
|
# + cover art dopo ogni download riuscito.
|
||||||
|
output_filenames: Optional[list] = None
|
||||||
|
post_cb: Optional[Callable] = None
|
||||||
|
if metadata_list:
|
||||||
|
output_filenames = []
|
||||||
|
for md in metadata_list:
|
||||||
|
md = md or {}
|
||||||
|
stem = tagger.build_filename_stem(
|
||||||
|
md.get("artist", ""),
|
||||||
|
md.get("title", ""),
|
||||||
|
)
|
||||||
|
output_filenames.append(stem)
|
||||||
|
|
||||||
|
def post_cb(idx: int, filepath: str,
|
||||||
|
_md_list=metadata_list) -> None:
|
||||||
|
if idx < 0 or idx >= len(_md_list):
|
||||||
|
return
|
||||||
|
md = _md_list[idx] or {}
|
||||||
|
try:
|
||||||
|
tagger.write_tags(filepath, md)
|
||||||
|
except Exception:
|
||||||
|
pass # tagging best-effort, non blocca il download
|
||||||
|
|
||||||
_last = [0.0]
|
_last = [0.0]
|
||||||
_THROTTLE = 0.10
|
_THROTTLE = 0.10
|
||||||
|
|
||||||
@@ -634,10 +677,15 @@ class Api:
|
|||||||
|
|
||||||
self._emit("download:progress", payload_evt)
|
self._emit("download:progress", payload_evt)
|
||||||
|
|
||||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
download_playlist(
|
||||||
|
tracks, output_dir, bitrate, cookies_path, progress_cb,
|
||||||
|
output_filenames=output_filenames,
|
||||||
|
post_download_callback=post_cb,
|
||||||
|
)
|
||||||
self._emit("download:done", {"ok": True})
|
self._emit("download:done", {"ok": True})
|
||||||
|
|
||||||
def _urls_worker(self, urls: list, titles: list, output_dir: str) -> None:
|
def _urls_worker(self, urls: list, titles: list, output_dir: str,
|
||||||
|
metadata_list: Optional[list] = None) -> None:
|
||||||
from core.downloader import download_urls
|
from core.downloader import download_urls
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -648,6 +696,29 @@ class Api:
|
|||||||
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||||
|
|
||||||
|
# Stessa pipeline tagging di _tracks_worker
|
||||||
|
output_filenames: Optional[list] = None
|
||||||
|
post_cb: Optional[Callable] = None
|
||||||
|
if metadata_list:
|
||||||
|
output_filenames = []
|
||||||
|
for md in metadata_list:
|
||||||
|
md = md or {}
|
||||||
|
stem = tagger.build_filename_stem(
|
||||||
|
md.get("artist", ""),
|
||||||
|
md.get("title", ""),
|
||||||
|
)
|
||||||
|
output_filenames.append(stem)
|
||||||
|
|
||||||
|
def post_cb(idx: int, filepath: str,
|
||||||
|
_md_list=metadata_list) -> None:
|
||||||
|
if idx < 0 or idx >= len(_md_list):
|
||||||
|
return
|
||||||
|
md = _md_list[idx] or {}
|
||||||
|
try:
|
||||||
|
tagger.write_tags(filepath, md)
|
||||||
|
except Exception:
|
||||||
|
pass # tagging best-effort, non blocca il download
|
||||||
|
|
||||||
_last = [0.0]
|
_last = [0.0]
|
||||||
_THROTTLE = 0.10
|
_THROTTLE = 0.10
|
||||||
|
|
||||||
@@ -681,7 +752,11 @@ class Api:
|
|||||||
|
|
||||||
self._emit("download:progress", payload_evt)
|
self._emit("download:progress", payload_evt)
|
||||||
|
|
||||||
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb)
|
download_urls(
|
||||||
|
urls, titles, output_dir, bitrate, cookies_path, progress_cb,
|
||||||
|
output_filenames=output_filenames,
|
||||||
|
post_download_callback=post_cb,
|
||||||
|
)
|
||||||
self._emit("download:done", {"ok": True})
|
self._emit("download:done", {"ok": True})
|
||||||
|
|
||||||
def _download_worker(self, urls: list, output_dir: str) -> None:
|
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||||
@@ -1167,7 +1242,13 @@ class Api:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def spotify_search_download(self, tracks: list) -> dict:
|
def spotify_search_download(self, tracks: list) -> dict:
|
||||||
"""Scarica i track Spotify selezionati (name+artist → YouTube search)."""
|
"""Scarica i track Spotify selezionati (name+artist → YouTube search).
|
||||||
|
|
||||||
|
Costruisce anche `metadata` (una entry per track) per il tagging ID3
|
||||||
|
+ cover art post-download. Se le creds Spotify sono presenti,
|
||||||
|
arricchisce con i generi dell'artista (cache locale per artist_id
|
||||||
|
per evitare N+1 chiamate).
|
||||||
|
"""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
out_root = (cfg.get("output_dir") or "").strip()
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
if not out_root:
|
if not out_root:
|
||||||
@@ -1176,7 +1257,20 @@ class Api:
|
|||||||
target = self._music_output_dir(out_root, "Spotify")
|
target = self._music_output_dir(out_root, "Spotify")
|
||||||
subfolder = target.name
|
subfolder = target.name
|
||||||
|
|
||||||
converted = []
|
# Tentativo di token Spotify per fetch generi (best-effort, opzionale)
|
||||||
|
cid = (cfg.get("client_id") or "").strip()
|
||||||
|
secret = (cfg.get("client_secret") or "").strip()
|
||||||
|
token: Optional[str] = None
|
||||||
|
if cid and secret:
|
||||||
|
try:
|
||||||
|
token = spotify_client.get_access_token(cid, secret)
|
||||||
|
except Exception:
|
||||||
|
token = None
|
||||||
|
|
||||||
|
genre_cache: dict = {} # artist_id -> [genres]
|
||||||
|
|
||||||
|
converted: list = []
|
||||||
|
metadata: list = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
title = (t.get("name") or "").strip()
|
title = (t.get("name") or "").strip()
|
||||||
artists = (t.get("artists") or "").strip()
|
artists = (t.get("artists") or "").strip()
|
||||||
@@ -1184,6 +1278,27 @@ class Api:
|
|||||||
continue
|
continue
|
||||||
converted.append({"name": title, "artist": artists})
|
converted.append({"name": title, "artist": artists})
|
||||||
|
|
||||||
|
# Genere: primo genere dell'artista (se disponibile e token OK)
|
||||||
|
genre = ""
|
||||||
|
artist_id = (t.get("artist_id") or "").strip()
|
||||||
|
if token and artist_id:
|
||||||
|
if artist_id not in genre_cache:
|
||||||
|
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
|
||||||
|
genres = genre_cache.get(artist_id) or []
|
||||||
|
if genres:
|
||||||
|
genre = genres[0]
|
||||||
|
|
||||||
|
tn = t.get("track_number") or 0
|
||||||
|
metadata.append({
|
||||||
|
"title": title,
|
||||||
|
"artist": artists,
|
||||||
|
"album": (t.get("album") or "").strip(),
|
||||||
|
"date": (t.get("release_date") or "").strip(),
|
||||||
|
"genre": genre,
|
||||||
|
"tracknumber": str(tn) if tn else "",
|
||||||
|
"cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(),
|
||||||
|
})
|
||||||
|
|
||||||
if not converted:
|
if not converted:
|
||||||
return {"ok": False, "error": "Nessun brano valido"}
|
return {"ok": False, "error": "Nessun brano valido"}
|
||||||
|
|
||||||
@@ -1191,6 +1306,7 @@ class Api:
|
|||||||
"tracks": converted,
|
"tracks": converted,
|
||||||
"output_dir": out_root,
|
"output_dir": out_root,
|
||||||
"subfolder": subfolder,
|
"subfolder": subfolder,
|
||||||
|
"metadata": metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- YouTube ----
|
# ---- YouTube ----
|
||||||
@@ -1242,7 +1358,14 @@ class Api:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def youtube_search_download(self, tracks: list) -> dict:
|
def youtube_search_download(self, tracks: list) -> dict:
|
||||||
"""Scarica direttamente gli URL YouTube selezionati (no re-search)."""
|
"""Scarica direttamente gli URL YouTube selezionati (no re-search).
|
||||||
|
|
||||||
|
Per ogni video prova a dedurre i metadati:
|
||||||
|
1) Se le creds Spotify sono presenti, cerca il titolo su Spotify
|
||||||
|
e usa i tag Spotify se c'è un match (best-effort).
|
||||||
|
2) Altrimenti fallback: parsing "Artista - Titolo" dal titolo del
|
||||||
|
video; uploader come fallback per l'album.
|
||||||
|
"""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
out_root = (cfg.get("output_dir") or "").strip()
|
out_root = (cfg.get("output_dir") or "").strip()
|
||||||
if not out_root:
|
if not out_root:
|
||||||
@@ -1251,8 +1374,21 @@ class Api:
|
|||||||
target = self._music_output_dir(out_root, "YouTube")
|
target = self._music_output_dir(out_root, "YouTube")
|
||||||
subfolder = target.name
|
subfolder = target.name
|
||||||
|
|
||||||
|
# Token Spotify opzionale per enrichment
|
||||||
|
cid = (cfg.get("client_id") or "").strip()
|
||||||
|
secret = (cfg.get("client_secret") or "").strip()
|
||||||
|
token: Optional[str] = None
|
||||||
|
if cid and secret:
|
||||||
|
try:
|
||||||
|
token = spotify_client.get_access_token(cid, secret)
|
||||||
|
except Exception:
|
||||||
|
token = None
|
||||||
|
|
||||||
|
genre_cache: dict = {} # artist_id -> [genres]
|
||||||
|
|
||||||
urls: list = []
|
urls: list = []
|
||||||
titles: list = []
|
titles: list = []
|
||||||
|
metadata: list = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
url = (t.get("url") or "").strip()
|
url = (t.get("url") or "").strip()
|
||||||
title = (t.get("title") or "").strip()
|
title = (t.get("title") or "").strip()
|
||||||
@@ -1261,6 +1397,51 @@ class Api:
|
|||||||
urls.append(url)
|
urls.append(url)
|
||||||
titles.append(title)
|
titles.append(title)
|
||||||
|
|
||||||
|
md: dict = {}
|
||||||
|
# 1) Enrichment via Spotify search (se token)
|
||||||
|
if token:
|
||||||
|
sp = spotify_client.enrich_from_youtube_title(token, title)
|
||||||
|
if sp:
|
||||||
|
# Genere: opzionale
|
||||||
|
genre = ""
|
||||||
|
artist_id = (sp.get("artist_id") or "").strip()
|
||||||
|
if artist_id:
|
||||||
|
if artist_id not in genre_cache:
|
||||||
|
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
|
||||||
|
genres = genre_cache.get(artist_id) or []
|
||||||
|
if genres:
|
||||||
|
genre = genres[0]
|
||||||
|
tn = sp.get("track_number") or 0
|
||||||
|
md = {
|
||||||
|
"title": (sp.get("name") or "").strip(),
|
||||||
|
"artist": (sp.get("artists") or "").strip(),
|
||||||
|
"album": (sp.get("album") or "").strip(),
|
||||||
|
"date": (sp.get("release_date") or "").strip(),
|
||||||
|
"genre": genre,
|
||||||
|
"tracknumber": str(tn) if tn else "",
|
||||||
|
"cover_url": (sp.get("cover_url_large") or sp.get("image_url") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) Fallback: parsing del titolo YouTube
|
||||||
|
if not md.get("title") or not md.get("artist"):
|
||||||
|
parsed = _parse_track_line(title)
|
||||||
|
if parsed:
|
||||||
|
fb_artist = parsed["artist"]
|
||||||
|
fb_title = parsed["name"]
|
||||||
|
else:
|
||||||
|
fb_artist = ""
|
||||||
|
fb_title = title
|
||||||
|
md = {
|
||||||
|
"title": fb_title,
|
||||||
|
"artist": fb_artist,
|
||||||
|
"album": (t.get("channel") or "").strip(),
|
||||||
|
"date": (t.get("release_date") or "").strip(),
|
||||||
|
"genre": "",
|
||||||
|
"tracknumber": "",
|
||||||
|
"cover_url": (t.get("image_url") or "").strip(),
|
||||||
|
}
|
||||||
|
metadata.append(md)
|
||||||
|
|
||||||
if not urls:
|
if not urls:
|
||||||
return {"ok": False, "error": "Nessun URL valido"}
|
return {"ok": False, "error": "Nessun URL valido"}
|
||||||
|
|
||||||
@@ -1269,4 +1450,5 @@ class Api:
|
|||||||
"titles": titles,
|
"titles": titles,
|
||||||
"output_dir": out_root,
|
"output_dir": out_root,
|
||||||
"subfolder": subfolder,
|
"subfolder": subfolder,
|
||||||
|
"metadata": metadata,
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user