download.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. # -*- coding: utf-8 -*-
  2. """
  3. Download-Utilities fuer Modelle und Ressourcen.
  4. Unterstuetzt Downloads von:
  5. - Hugging Face Hub
  6. - GitHub Releases
  7. - Direkte URLs
  8. - Mit Fortschrittsanzeige und typisierten EventData-Events
  9. Events (wenn EventManager verfuegbar):
  10. - before_download: Vor dem Download (cancellable, URL/Pfad aenderbar)
  11. - download_progress: Fortschritts-Update (alle 5%)
  12. - download_completed: Download erfolgreich
  13. - download_failed: Download fehlgeschlagen
  14. - before_extract: Vor dem Entpacken (cancellable, Ziel aenderbar)
  15. - extract_completed: Entpacken abgeschlossen
  16. """
  17. import asyncio
  18. import hashlib
  19. import shutil
  20. import time
  21. from pathlib import Path
  22. from typing import TYPE_CHECKING
  23. from urllib.parse import urlparse
  24. from trixy_core.utils.debug import pinfo, pdebug, perror, pprogress
  25. if TYPE_CHECKING:
  26. from trixy_core.events.eventmanager import EventManager
  27. class DownloadProgressTracker:
  28. """Fortschritts-Tracker fuer Downloads mit visueller Progressbar."""
  29. def __init__(
  30. self,
  31. total_size: int = 0,
  32. event_manager: "EventManager | None" = None,
  33. download_id: str = "",
  34. filename: str = "",
  35. ):
  36. self.total_size = total_size
  37. self.downloaded = 0
  38. self.event_manager = event_manager
  39. self.download_id = download_id
  40. self.filename = filename
  41. self._last_percent = -1
  42. self._start_time = time.time()
  43. def update(self, chunk_size: int) -> None:
  44. """Aktualisiert den Fortschritt mit visueller Progressbar."""
  45. self.downloaded += chunk_size
  46. if self.total_size > 0:
  47. percent = (self.downloaded / self.total_size) * 100
  48. # Geschwindigkeit + ETA berechnen
  49. elapsed = time.time() - self._start_time
  50. speed = self.downloaded / elapsed if elapsed > 0 else 0
  51. remaining = (self.total_size - self.downloaded) / speed if speed > 0 else 0
  52. suffix = (
  53. f"{format_size(self.downloaded)}/{format_size(self.total_size)} | "
  54. f"{format_size(int(speed))}/s | {format_eta(int(remaining))}"
  55. )
  56. # Visuelle Progressbar anzeigen
  57. pprogress(self.filename, self.downloaded, self.total_size, suffix)
  58. # Event emittieren (nur alle 5%)
  59. current_percent = int(percent / 5) * 5
  60. if self.event_manager and current_percent > self._last_percent:
  61. self._last_percent = current_percent
  62. asyncio.create_task(self._emit_progress(percent, int(speed), int(remaining)))
  63. async def _emit_progress(self, percent: float, speed: int, eta_seconds: int) -> None:
  64. """Emittiert Progress-Event ueber trigger()."""
  65. if self.event_manager:
  66. from trixy_core.events.event_data.download import DownloadProgress as DownloadProgressEvent
  67. event = DownloadProgressEvent(
  68. download_id=self.download_id,
  69. filename=self.filename,
  70. downloaded=self.downloaded,
  71. total=self.total_size,
  72. percent=percent,
  73. speed=speed,
  74. eta_seconds=eta_seconds,
  75. )
  76. await self.event_manager.trigger("download_progress", event)
  77. @property
  78. def percent(self) -> float:
  79. """Fortschritt in Prozent."""
  80. if self.total_size == 0:
  81. return 0.0
  82. return (self.downloaded / self.total_size) * 100
  83. # Rueckwaertskompatibilitaet: Alias fuer die umbenannte Klasse
  84. DownloadProgress = DownloadProgressTracker
  85. async def download_file(
  86. url: str,
  87. dest_path: Path,
  88. chunk_size: int = 65536,
  89. timeout: int = 3600,
  90. event_manager: "EventManager | None" = None,
  91. download_id: str | None = None,
  92. display_name: str | None = None,
  93. ) -> bool:
  94. """
  95. Laedt eine Datei von einer URL herunter.
  96. Args:
  97. url: Download-URL
  98. dest_path: Ziel-Pfad
  99. chunk_size: Chunk-Groesse in Bytes
  100. timeout: Timeout in Sekunden
  101. event_manager: Optional EventManager fuer Events
  102. download_id: Eindeutige Download-ID fuer Events
  103. display_name: Anzeigename fuer Progressbar (statt Dateiname)
  104. Returns:
  105. True bei Erfolg
  106. """
  107. filename = display_name or dest_path.name
  108. dl_id = download_id or hashlib.md5(url.encode()).hexdigest()[:8]
  109. # Event: Vor dem Download (cancellable)
  110. if event_manager:
  111. from trixy_core.events.event_data.download import BeforeDownload
  112. before = BeforeDownload(
  113. download_id=dl_id,
  114. url=url,
  115. filename=filename,
  116. dest_path=str(dest_path),
  117. )
  118. before = await event_manager.trigger("before_download", before)
  119. if before.is_cancelled():
  120. pinfo(f"Download abgebrochen durch Plugin: {filename}")
  121. return False
  122. # Uebernahme von Aenderungen (Redirect, anderer Pfad)
  123. url = before.url
  124. dest_path = Path(before.dest_path)
  125. filename = dest_path.name
  126. temp_path = None
  127. start_time = time.time()
  128. total_size = 0
  129. try:
  130. import aiohttp
  131. dest_path.parent.mkdir(parents=True, exist_ok=True)
  132. temp_path = dest_path.with_suffix(dest_path.suffix + ".tmp")
  133. # Timeout nur fuer Socket-Reads, nicht gesamt — grosse Downloads brechen sonst ab
  134. client_timeout = aiohttp.ClientTimeout(
  135. total=None,
  136. sock_read=60,
  137. sock_connect=30,
  138. )
  139. async with aiohttp.ClientSession() as session:
  140. async with session.get(url, timeout=client_timeout) as response:
  141. if response.status != 200:
  142. error_msg = f"HTTP {response.status}"
  143. perror(f"Download fehlgeschlagen: {error_msg}")
  144. if event_manager:
  145. from trixy_core.events.event_data.download import DownloadFailed
  146. failed = DownloadFailed(
  147. download_id=dl_id,
  148. url=url,
  149. filename=filename,
  150. error=error_msg,
  151. )
  152. await event_manager.trigger("download_failed", failed)
  153. return False
  154. total_size = int(response.headers.get("content-length", 0))
  155. progress = DownloadProgressTracker(
  156. total_size,
  157. event_manager,
  158. dl_id,
  159. filename,
  160. )
  161. with open(temp_path, "wb") as f:
  162. async for chunk in response.content.iter_chunked(chunk_size):
  163. f.write(chunk)
  164. progress.update(len(chunk))
  165. # Temp-Datei umbenennen
  166. temp_path.rename(dest_path)
  167. # Event: Download abgeschlossen
  168. elapsed = time.time() - start_time
  169. if event_manager:
  170. from trixy_core.events.event_data.download import DownloadCompleted
  171. completed = DownloadCompleted(
  172. download_id=dl_id,
  173. url=url,
  174. filename=filename,
  175. dest_path=str(dest_path),
  176. size=total_size,
  177. duration_seconds=elapsed,
  178. speed_avg=int(total_size / elapsed) if elapsed > 0 else 0,
  179. )
  180. await event_manager.trigger("download_completed", completed)
  181. return True
  182. except ImportError:
  183. # Fallback ohne aiohttp
  184. return await _download_file_urllib(
  185. url, dest_path, chunk_size,
  186. event_manager, dl_id
  187. )
  188. except (KeyboardInterrupt, asyncio.CancelledError):
  189. # Ctrl+C oder Task-Abbruch — sauber aufraumen
  190. print() # Newline nach Progressbar
  191. perror(f"Download abgebrochen: {filename}")
  192. if event_manager:
  193. from trixy_core.events.event_data.download import DownloadFailed
  194. failed = DownloadFailed(
  195. download_id=dl_id,
  196. url=url,
  197. filename=filename,
  198. error="Download abgebrochen (Keyboard Interrupt)",
  199. )
  200. await event_manager.trigger("download_failed", failed)
  201. try:
  202. if temp_path and temp_path.exists():
  203. temp_path.unlink()
  204. except:
  205. pass
  206. return False
  207. except Exception as e:
  208. error_msg = str(e)
  209. perror(f"Download-Fehler: {error_msg}")
  210. if event_manager:
  211. from trixy_core.events.event_data.download import DownloadFailed
  212. failed = DownloadFailed(
  213. download_id=dl_id,
  214. url=url,
  215. filename=filename,
  216. error=error_msg,
  217. )
  218. await event_manager.trigger("download_failed", failed)
  219. try:
  220. if temp_path and temp_path.exists():
  221. temp_path.unlink()
  222. except:
  223. pass
  224. return False
  225. async def _download_file_urllib(
  226. url: str,
  227. dest_path: Path,
  228. chunk_size: int = 8192,
  229. event_manager: "EventManager | None" = None,
  230. download_id: str = "",
  231. ) -> bool:
  232. """Fallback-Download mit urllib."""
  233. import urllib.request
  234. filename = dest_path.name
  235. temp_path = None
  236. start_time = time.time()
  237. try:
  238. dest_path.parent.mkdir(parents=True, exist_ok=True)
  239. temp_path = dest_path.with_suffix(dest_path.suffix + ".tmp")
  240. loop = asyncio.get_event_loop()
  241. def do_download():
  242. with urllib.request.urlopen(url) as response:
  243. total_size = int(response.headers.get("content-length", 0))
  244. progress = DownloadProgressTracker(
  245. total_size,
  246. None, # Kein async in sync context
  247. download_id,
  248. filename,
  249. )
  250. with open(temp_path, "wb") as f:
  251. while True:
  252. chunk = response.read(chunk_size)
  253. if not chunk:
  254. break
  255. f.write(chunk)
  256. progress.update(len(chunk))
  257. return total_size
  258. total_size = await loop.run_in_executor(None, do_download)
  259. temp_path.rename(dest_path)
  260. # Event: Download abgeschlossen
  261. elapsed = time.time() - start_time
  262. if event_manager:
  263. from trixy_core.events.event_data.download import DownloadCompleted
  264. completed = DownloadCompleted(
  265. download_id=download_id,
  266. url=url,
  267. filename=filename,
  268. dest_path=str(dest_path),
  269. size=total_size,
  270. duration_seconds=elapsed,
  271. speed_avg=int(total_size / elapsed) if elapsed > 0 else 0,
  272. )
  273. await event_manager.trigger("download_completed", completed)
  274. return True
  275. except (KeyboardInterrupt, asyncio.CancelledError):
  276. print()
  277. perror(f"Download abgebrochen: {filename}")
  278. if event_manager:
  279. from trixy_core.events.event_data.download import DownloadFailed
  280. failed = DownloadFailed(
  281. download_id=download_id,
  282. url=url,
  283. filename=filename,
  284. error="Download abgebrochen (Keyboard Interrupt)",
  285. )
  286. await event_manager.trigger("download_failed", failed)
  287. if temp_path and temp_path.exists():
  288. temp_path.unlink()
  289. return False
  290. except Exception as e:
  291. error_msg = str(e)
  292. perror(f"Download-Fehler (urllib): {error_msg}")
  293. if event_manager:
  294. from trixy_core.events.event_data.download import DownloadFailed
  295. failed = DownloadFailed(
  296. download_id=download_id,
  297. url=url,
  298. filename=filename,
  299. error=error_msg,
  300. )
  301. await event_manager.trigger("download_failed", failed)
  302. if temp_path and temp_path.exists():
  303. temp_path.unlink()
  304. return False
  305. async def download_from_huggingface(
  306. repo_id: str,
  307. filename: str,
  308. dest_path: Path,
  309. revision: str = "main",
  310. event_manager: "EventManager | None" = None,
  311. download_id: str | None = None,
  312. ) -> bool:
  313. """
  314. Laedt eine Datei von Hugging Face Hub herunter.
  315. Args:
  316. repo_id: Repository-ID (z.B. "rhasspy/piper-voices")
  317. filename: Dateiname im Repository
  318. dest_path: Ziel-Pfad
  319. revision: Branch/Tag/Commit
  320. event_manager: Optional EventManager fuer Events
  321. download_id: Eindeutige Download-ID
  322. Returns:
  323. True bei Erfolg
  324. """
  325. dl_id = download_id or hashlib.md5(f"{repo_id}/{filename}".encode()).hexdigest()[:8]
  326. try:
  327. from huggingface_hub import hf_hub_download
  328. dest_path.parent.mkdir(parents=True, exist_ok=True)
  329. loop = asyncio.get_event_loop()
  330. def do_download():
  331. # huggingface_hub laedt in Cache, wir kopieren dann
  332. cached_path = hf_hub_download(
  333. repo_id=repo_id,
  334. filename=filename,
  335. revision=revision,
  336. local_dir=None, # Nutzt Cache
  337. )
  338. # Kopieren zum Ziel
  339. shutil.copy2(cached_path, dest_path)
  340. return Path(cached_path).stat().st_size
  341. pinfo(f"Lade von Hugging Face: {repo_id}/{filename}")
  342. start_time = time.time()
  343. file_size = await loop.run_in_executor(None, do_download)
  344. elapsed = time.time() - start_time
  345. pinfo(f"Download abgeschlossen: {dest_path.name}")
  346. # Event: Download abgeschlossen
  347. if event_manager:
  348. from trixy_core.events.event_data.download import DownloadCompleted
  349. completed = DownloadCompleted(
  350. download_id=dl_id,
  351. url=f"hf://{repo_id}/{filename}",
  352. filename=dest_path.name,
  353. dest_path=str(dest_path),
  354. size=file_size,
  355. duration_seconds=elapsed,
  356. speed_avg=int(file_size / elapsed) if elapsed > 0 else 0,
  357. )
  358. await event_manager.trigger("download_completed", completed)
  359. return True
  360. except ImportError:
  361. # Fallback: Direkte URL
  362. url = f"https://huggingface.co/{repo_id}/resolve/{revision}/{filename}"
  363. pinfo(f"Hugging Face Hub nicht installiert, nutze direkte URL")
  364. return await download_file(
  365. url, dest_path,
  366. event_manager=event_manager, download_id=dl_id
  367. )
  368. except Exception as e:
  369. error_msg = str(e)
  370. perror(f"Hugging Face Download-Fehler: {error_msg}")
  371. if event_manager:
  372. from trixy_core.events.event_data.download import DownloadFailed
  373. failed = DownloadFailed(
  374. download_id=dl_id,
  375. url=f"hf://{repo_id}/{filename}",
  376. filename=dest_path.name,
  377. error=error_msg,
  378. )
  379. await event_manager.trigger("download_failed", failed)
  380. return False
  381. async def download_from_github(
  382. repo: str,
  383. tag: str,
  384. asset_name: str,
  385. dest_path: Path,
  386. event_manager: "EventManager | None" = None,
  387. download_id: str | None = None,
  388. ) -> bool:
  389. """
  390. Laedt ein Release-Asset von GitHub herunter.
  391. Args:
  392. repo: Repository (z.B. "mozilla/DeepSpeech")
  393. tag: Release-Tag (z.B. "v0.9.3")
  394. asset_name: Asset-Dateiname
  395. dest_path: Ziel-Pfad
  396. event_manager: Optional EventManager fuer Events
  397. download_id: Eindeutige Download-ID
  398. Returns:
  399. True bei Erfolg
  400. """
  401. url = f"https://github.com/{repo}/releases/download/{tag}/{asset_name}"
  402. dl_id = download_id or hashlib.md5(url.encode()).hexdigest()[:8]
  403. pinfo(f"Lade von GitHub: {repo} @ {tag} / {asset_name}")
  404. return await download_file(
  405. url, dest_path,
  406. event_manager=event_manager, download_id=dl_id
  407. )
  408. async def download_and_extract(
  409. url: str,
  410. dest_dir: Path,
  411. event_manager: "EventManager | None" = None,
  412. download_id: str | None = None,
  413. ) -> bool:
  414. """
  415. Laedt ein Archiv herunter und extrahiert es.
  416. Unterstuetzt: .zip, .tar.gz, .tgz
  417. Args:
  418. url: Download-URL
  419. dest_dir: Ziel-Verzeichnis
  420. event_manager: Optional EventManager fuer Events
  421. download_id: Eindeutige Download-ID
  422. Returns:
  423. True bei Erfolg
  424. """
  425. import tempfile
  426. # Dateiname aus URL
  427. parsed = urlparse(url)
  428. filename = Path(parsed.path).name
  429. dl_id = download_id or hashlib.md5(url.encode()).hexdigest()[:8]
  430. # Temporaere Datei — suffix mit _ damit Temp-Prefix nicht im Dateinamen erscheint
  431. with tempfile.NamedTemporaryFile(suffix="_" + filename, delete=False) as tmp:
  432. temp_path = Path(tmp.name)
  433. try:
  434. # Download — display_name zeigt echten Dateinamen statt Temp-Pfad
  435. if not await download_file(
  436. url, temp_path,
  437. event_manager=event_manager, download_id=dl_id,
  438. display_name=filename,
  439. ):
  440. return False
  441. # Event: Vor dem Entpacken (cancellable)
  442. if event_manager:
  443. from trixy_core.events.event_data.download import BeforeExtract
  444. before = BeforeExtract(
  445. download_id=dl_id,
  446. filename=filename,
  447. archive_path=str(temp_path),
  448. dest_dir=str(dest_dir),
  449. )
  450. before = await event_manager.trigger("before_extract", before)
  451. if before.is_cancelled():
  452. pinfo(f"Entpacken abgebrochen/uebernommen durch Plugin: {filename}")
  453. return True # Plugin hat sich drum gekuemmert
  454. dest_dir = Path(before.dest_dir) # Plugin kann Ziel aendern
  455. # Extrahieren
  456. dest_dir.mkdir(parents=True, exist_ok=True)
  457. loop = asyncio.get_event_loop()
  458. def do_extract():
  459. if filename.endswith(".zip"):
  460. import zipfile
  461. with zipfile.ZipFile(temp_path, "r") as zf:
  462. zf.extractall(dest_dir)
  463. return len(zf.namelist())
  464. elif filename.endswith((".tar.gz", ".tgz")):
  465. import tarfile
  466. with tarfile.open(temp_path, "r:gz") as tf:
  467. tf.extractall(dest_dir)
  468. return len(tf.getnames())
  469. elif filename.endswith(".tar"):
  470. import tarfile
  471. with tarfile.open(temp_path, "r") as tf:
  472. tf.extractall(dest_dir)
  473. return len(tf.getnames())
  474. else:
  475. raise ValueError(f"Unbekanntes Archiv-Format: {filename}")
  476. file_count = await loop.run_in_executor(None, do_extract)
  477. pinfo(f"Extrahiert nach: {dest_dir} ({file_count} Dateien)")
  478. # Event: Entpacken abgeschlossen
  479. if event_manager:
  480. from trixy_core.events.event_data.download import ExtractCompleted
  481. completed = ExtractCompleted(
  482. download_id=dl_id,
  483. filename=filename,
  484. dest_dir=str(dest_dir),
  485. file_count=file_count,
  486. )
  487. await event_manager.trigger("extract_completed", completed)
  488. return True
  489. except Exception as e:
  490. error_msg = str(e)
  491. perror(f"Extrahieren fehlgeschlagen: {error_msg}")
  492. if event_manager:
  493. from trixy_core.events.event_data.download import DownloadFailed
  494. failed = DownloadFailed(
  495. download_id=dl_id,
  496. url=url,
  497. filename=filename,
  498. error=error_msg,
  499. )
  500. await event_manager.trigger("download_failed", failed)
  501. return False
  502. finally:
  503. if temp_path.exists():
  504. temp_path.unlink()
  505. def format_size(size_bytes: int) -> str:
  506. """Formatiert Bytes als lesbare Groesse."""
  507. for unit in ["B", "KB", "MB", "GB"]:
  508. if size_bytes < 1024:
  509. return f"{size_bytes:.1f} {unit}"
  510. size_bytes /= 1024
  511. return f"{size_bytes:.1f} TB"
  512. def format_eta(seconds: int) -> str:
  513. """
  514. Formatiert ETA mit automatischer Einheit, max. 2-stellig.
  515. <= 99s → "XXs", <= 99min → "XXm", sonst → "XXh"
  516. """
  517. if seconds < 0:
  518. return "~"
  519. if seconds <= 99:
  520. return f"{seconds}s"
  521. minutes = seconds // 60
  522. if minutes <= 99:
  523. return f"{minutes}m"
  524. hours = min(seconds // 3600, 99)
  525. return f"{hours}h"