Initial commit: MusicDownload con UI pywebview
- Frontend HTML/CSS/JS in webui/ (stile Spotify-like, hero gradient, modal blur) - API bridge JS<->Python in api/bridge.py - Core invariato: downloader yt-dlp, upgrader, spotify client, config - Build scripts macOS (WebKit) e Windows (EdgeChromium) - .gitignore esclude credenziali (config.json, cookies.txt) e artefatti build Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
commit
8c06a26ddb
25 files changed
+4316
No files matched your search
@@ -0,0 +1,395 @@
|
||||
"""Download da YouTube via yt-dlp (subprocess per usare la versione piu aggiornata)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir
|
||||
|
||||
|
||||
# Flag globale per interruzione
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
_process_lock = threading.Lock()
|
||||
|
||||
|
||||
def request_stop():
|
||||
"""Richiede l'interruzione del download in corso."""
|
||||
_stop_event.set()
|
||||
with _process_lock:
|
||||
if _current_process and _current_process.poll() is None:
|
||||
_current_process.terminate()
|
||||
|
||||
|
||||
def reset_stop():
|
||||
_stop_event.clear()
|
||||
|
||||
|
||||
def is_stopped() -> bool:
|
||||
return _stop_event.is_set()
|
||||
|
||||
|
||||
def _load_done_set(done_file: Path) -> set[str]:
|
||||
"""Carica l'elenco delle tracce gia scaricate."""
|
||||
if done_file.exists():
|
||||
try:
|
||||
return set(done_file.read_text(encoding="utf-8").splitlines())
|
||||
except OSError:
|
||||
return set()
|
||||
return set()
|
||||
|
||||
|
||||
def _mark_done(done_file: Path, key: str) -> None:
|
||||
"""Aggiunge una traccia al file delle tracce scaricate."""
|
||||
try:
|
||||
with open(done_file, "a", encoding="utf-8") as f:
|
||||
f.write(key + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_STOPWORDS = {
|
||||
"official", "video", "audio", "music", "lyric", "lyrics", "hd", "4k",
|
||||
"remix", "edit", "extended", "feat", "ft", "featuring", "with",
|
||||
"from", "the", "and", "of", "a", "an", "to", "in",
|
||||
}
|
||||
|
||||
|
||||
def _tokenize(s: str) -> set[str]:
|
||||
"""Estrae token alfanumerici significativi da una stringa."""
|
||||
s = s.lower()
|
||||
tokens = re.findall(r"[a-z0-9]+", s)
|
||||
return {t for t in tokens if len(t) >= 2 and t not in _STOPWORDS}
|
||||
|
||||
|
||||
def _scan_existing_files(directory: Path, extensions: tuple[str, ...] = (".mp3", ".m4a", ".opus", ".webm", ".wav", ".flac")) -> list[set[str]]:
|
||||
"""Ritorna lista di set di token dei filename presenti nella directory."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
return [_tokenize(f.stem) for f in directory.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in extensions]
|
||||
|
||||
|
||||
def _file_exists_for_query(query: str, existing_tokens: list[set[str]]) -> bool:
|
||||
"""Verifica se esiste gia un file il cui titolo contiene tutte le parole del query.
|
||||
Match basato sui token: tutte le parole significative del query devono essere
|
||||
presenti nel filename (l'ordine non conta, eventuali parole extra nel filename ok)."""
|
||||
query_tokens = _tokenize(query)
|
||||
if not query_tokens:
|
||||
return False
|
||||
for file_tokens in existing_tokens:
|
||||
if query_tokens.issubset(file_tokens):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str, str]:
|
||||
"""Cerca un video su YouTube. Ritorna (video_url, video_title)."""
|
||||
ytdlp = find_ytdlp()
|
||||
cmd = [
|
||||
ytdlp,
|
||||
f"ytsearch1:{query}",
|
||||
"--dump-json",
|
||||
"--no-download",
|
||||
"--no-warnings",
|
||||
"--flat-playlist",
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
video_id = data.get("id", "")
|
||||
title = data.get("title", query)
|
||||
if not video_id:
|
||||
raise ValueError("Nessun risultato trovato")
|
||||
|
||||
return f"https://www.youtube.com/watch?v={video_id}", title
|
||||
|
||||
|
||||
def download_playlist(
|
||||
tracks: list[dict],
|
||||
output_dir: str,
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica tutti i brani dalla lista di tracce.
|
||||
|
||||
Args:
|
||||
tracks: lista di {"name": ..., "artist": ...}
|
||||
output_dir: directory di destinazione
|
||||
bitrate: qualita audio (es. "320K")
|
||||
cookies_path: percorso file cookies (opzionale)
|
||||
progress_callback: callback(track_index, total, track_name, status, percent)
|
||||
"""
|
||||
reset_stop()
|
||||
total = len(tracks)
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
for i, track in enumerate(tracks):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
query = f"{track['name']} - {track['artist']}"
|
||||
track_only = track['name']
|
||||
|
||||
# Salta brani gia scaricati in precedenza (file di tracking)
|
||||
if query in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if (_file_exists_for_query(query, existing_files)
|
||||
or _file_exists_for_query(track_only, existing_files)):
|
||||
_mark_done(done_file, query)
|
||||
done_set.add(query)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "searching", 0)
|
||||
|
||||
# Cerca il video su YouTube
|
||||
try:
|
||||
video_url, _ = _search_youtube(query, cookies_path)
|
||||
except Exception as e:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, f"error: {e}", 0)
|
||||
continue
|
||||
|
||||
if is_stopped():
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "downloading", 0)
|
||||
|
||||
# Scarica con yt-dlp subprocess
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||
video_url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "stopped", 0)
|
||||
return
|
||||
|
||||
# Parse progress da output yt-dlp
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(i, total, query, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_mark_done(done_file, query)
|
||||
done_set.add(query)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "done", 100)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}", 0)
|
||||
|
||||
except Exception as e:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def download_direct_url(
|
||||
url: str,
|
||||
output_dir: str,
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica direttamente da un URL supportato da yt-dlp (YouTube, SoundCloud, ecc.).
|
||||
|
||||
Supporta sia singoli video che playlist.
|
||||
Callback signature: progress_callback(idx, total, track_name, status, pct)
|
||||
"""
|
||||
global _current_process
|
||||
reset_stop()
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
# 1. Ottieni info tramite yt-dlp --dump-json
|
||||
probe_cmd = [
|
||||
ytdlp,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-download",
|
||||
"--no-warnings",
|
||||
url,
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(result.stderr.strip())
|
||||
except Exception as e:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, url, f"error: {e}", 0)
|
||||
return
|
||||
|
||||
# Ogni riga stdout e un JSON (una entry per playlist, una per singolo)
|
||||
entries = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if line.strip():
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not entries:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, url, "error: Nessun contenuto trovato", 0)
|
||||
return
|
||||
|
||||
total = len(entries)
|
||||
|
||||
# 2. Scarica ogni entry
|
||||
for i, entry in enumerate(entries):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
title = entry.get("title", f"Track {i + 1}")
|
||||
entry_url = entry.get("url") or entry.get("webpage_url") or entry.get("original_url") or url
|
||||
|
||||
# Per singoli video (non flat-playlist), usa l'URL originale
|
||||
if total == 1:
|
||||
entry_url = url
|
||||
|
||||
# Salta se gia scaricato (file di tracking)
|
||||
track_key = entry.get("id") or title
|
||||
if track_key in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if _file_exists_for_query(title, existing_files):
|
||||
_mark_done(done_file, track_key)
|
||||
done_set.add(track_key)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "downloading", 0)
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||
entry_url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "stopped", 0)
|
||||
return
|
||||
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(i, total, title, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_mark_done(done_file, track_key)
|
||||
done_set.add(track_key)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "done", 100)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, f"error: yt-dlp exit {return_code}", 0)
|
||||
|
||||
except Exception as e:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
Reference in new issue
Block a user