downloader+bridge: supporto download da URL diretti (YouTube search flow)
Aggiunge download_urls() in core/downloader.py che scarica direttamente da URL YouTube gia noti (bypassa lo step di ricerca). Usa gli stessi flag di download_playlist (mp3, embed thumbnail/metadata, output template, cookies, ffmpeg location) e il parsing progress via stdout. In api/bridge.py aggiunge start_urls_download() e _urls_worker() speculari a start_tracks_download/_tracks_worker. Consumati dal tab "YouTube Search" dopo che l'utente ha selezionato i risultati.
This commit is contained in:
1 parent
0949df031a
commit
67e48e54f7
2 files changed
+200
No files matched your search
@@ -544,6 +544,40 @@ class Api:
|
|||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
def start_urls_download(self, payload: dict) -> dict:
|
||||||
|
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||||
|
(bypass search). Usato dal flow del tab 'YouTube Search'."""
|
||||||
|
if self._any_job_running():
|
||||||
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
|
urls = payload.get("urls") or []
|
||||||
|
titles = payload.get("titles") or []
|
||||||
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
|
subfolder = (payload.get("subfolder") or "").strip()
|
||||||
|
if not urls:
|
||||||
|
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||||
|
if len(urls) != len(titles):
|
||||||
|
return {"ok": False, "error": "urls e titles devono avere stessa lunghezza"}
|
||||||
|
if not output_dir:
|
||||||
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
if subfolder:
|
||||||
|
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||||
|
if safe:
|
||||||
|
output_dir = os.path.join(output_dir, safe)
|
||||||
|
|
||||||
|
gate = self._gate("audio")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
|
self._download_thread = threading.Thread(
|
||||||
|
target=self._urls_worker,
|
||||||
|
args=(list(urls), list(titles), output_dir),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._download_thread.start()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
def stop_download(self) -> dict:
|
def stop_download(self) -> dict:
|
||||||
request_download_stop()
|
request_download_stop()
|
||||||
self._log("download", "[INFO] Interruzione richiesta...")
|
self._log("download", "[INFO] Interruzione richiesta...")
|
||||||
@@ -601,6 +635,53 @@ class Api:
|
|||||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_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:
|
||||||
|
from core.downloader import download_urls
|
||||||
|
reset_download_stop()
|
||||||
|
cfg = load_config()
|
||||||
|
bitrate = cfg.get("bitrate", "320K")
|
||||||
|
cookies_path = cfg.get("cookies_path", "")
|
||||||
|
view = "download"
|
||||||
|
|
||||||
|
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||||
|
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||||
|
|
||||||
|
_last = [0.0]
|
||||||
|
_THROTTLE = 0.10
|
||||||
|
|
||||||
|
def progress_cb(idx, total, title, status, pct):
|
||||||
|
if status == "downloading":
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last[0] < _THROTTLE:
|
||||||
|
return
|
||||||
|
_last[0] = now
|
||||||
|
|
||||||
|
payload_evt = {
|
||||||
|
"idx": idx, "total": total, "track": title,
|
||||||
|
"status": status, "pct": pct,
|
||||||
|
"url_idx": 0, "url_total": 1,
|
||||||
|
}
|
||||||
|
if status == "skipped":
|
||||||
|
self._log(view, f"[SKIP] {title} (gia scaricato)")
|
||||||
|
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||||
|
elif status == "downloading":
|
||||||
|
payload_evt["overall"] = min((idx / total) + (pct / 100 / total), 1.0) if total else 0
|
||||||
|
elif status == "done":
|
||||||
|
self._log(view, f"[OK] {title}")
|
||||||
|
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||||
|
elif status == "stopped":
|
||||||
|
self._log(view, "[INFO] Download interrotto.")
|
||||||
|
elif status == "completed":
|
||||||
|
payload_evt["overall"] = 1.0
|
||||||
|
self._log(view, "[INFO] Download completato!")
|
||||||
|
elif status.startswith("error"):
|
||||||
|
self._log(view, f"[ERRORE] {title}: {status}")
|
||||||
|
|
||||||
|
self._emit("download:progress", payload_evt)
|
||||||
|
|
||||||
|
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb)
|
||||||
|
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:
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|||||||
@@ -399,6 +399,125 @@ def download_direct_url(
|
|||||||
progress_callback(total, total, "", "completed", 100)
|
progress_callback(total, total, "", "completed", 100)
|
||||||
|
|
||||||
|
|
||||||
|
def download_urls(
|
||||||
|
urls: list,
|
||||||
|
titles: list,
|
||||||
|
output_dir: str,
|
||||||
|
bitrate: str = "320K",
|
||||||
|
cookies_path: Optional[str] = None,
|
||||||
|
progress_callback: Optional[Callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Scarica direttamente da URL YouTube (bypassa search).
|
||||||
|
|
||||||
|
Usa gli stessi flag yt-dlp di download_playlist. La differenza e che
|
||||||
|
salta lo step di ricerca perche l'URL e gia noto (usato dal flow del
|
||||||
|
tab "YouTube Search" dopo che l'utente ha selezionato i risultati).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: lista URL YouTube (parallela a titles)
|
||||||
|
titles: lista titoli video (per logging/dedupe)
|
||||||
|
output_dir: cartella destinazione
|
||||||
|
bitrate: qualita audio (default 320K)
|
||||||
|
cookies_path: file cookies opzionale
|
||||||
|
progress_callback: callback(idx, total, title, status, percent)
|
||||||
|
"""
|
||||||
|
global _current_process
|
||||||
|
reset_stop()
|
||||||
|
total = len(urls)
|
||||||
|
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, (url, title) in enumerate(zip(urls, titles)):
|
||||||
|
if is_stopped():
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, "", "stopped", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = title or url
|
||||||
|
|
||||||
|
# Salta se gia scaricato (file di tracking)
|
||||||
|
if key in done_set:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "skipped", 100)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Salta se esiste gia un file corrispondente nella cartella
|
||||||
|
if title and _file_exists_for_query(title, existing_files):
|
||||||
|
_mark_done(done_file, key)
|
||||||
|
done_set.add(key)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "skipped", 100)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "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"),
|
||||||
|
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,
|
||||||
|
**subprocess_flags(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in _current_process.stdout:
|
||||||
|
if is_stopped():
|
||||||
|
_current_process.terminate()
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "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, key, "downloading", pct)
|
||||||
|
|
||||||
|
return_code = _current_process.wait()
|
||||||
|
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = None
|
||||||
|
|
||||||
|
if return_code == 0:
|
||||||
|
_mark_done(done_file, key)
|
||||||
|
done_set.add(key)
|
||||||
|
existing_files = _scan_existing_files(out_path)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, "done", 100)
|
||||||
|
else:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, key, 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, key, f"error: {e}", 0)
|
||||||
|
|
||||||
|
if progress_callback and not is_stopped():
|
||||||
|
progress_callback(total, total, "", "completed", 100)
|
||||||
|
|
||||||
|
|
||||||
def _video_format(quality: str) -> str:
|
def _video_format(quality: str) -> str:
|
||||||
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||||
if not quality or quality == "best":
|
if not quality or quality == "best":
|
||||||
|
|||||||
Reference in new issue
Block a user