youtube.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. # -*- coding: utf-8 -*-
  2. """
  3. YouTube-Quelle via yt-dlp.
  4. Sucht und laedt YouTube-Audio herunter fuer die Wiedergabe
  5. ueber die bestehende Decoder-Pipeline.
  6. """
  7. import asyncio
  8. import os
  9. import re
  10. import time
  11. from pathlib import Path
  12. from typing import AsyncIterator
  13. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
  14. from trixy_core.music.track import Track, TrackMetadata, TrackState
  15. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  16. # Optionale Abhaengigkeit
  17. _YT_DLP_AVAILABLE = False
  18. try:
  19. import yt_dlp
  20. _YT_DLP_AVAILABLE = True
  21. except ImportError:
  22. pass
  23. class YouTubeSource(MusicSource):
  24. """YouTube-Quelle via yt-dlp (optional)."""
  25. def __init__(
  26. self,
  27. name: str = "YouTube",
  28. cache_dir: str = "cache/youtube",
  29. max_cache_size_mb: int = 500,
  30. preferred_format: str = "bestaudio[ext=m4a]/bestaudio/best",
  31. max_results: int = 5,
  32. search_mode: str = "music",
  33. ) -> None:
  34. """
  35. Initialisiert die YouTube-Quelle.
  36. Args:
  37. name: Name der Quelle
  38. cache_dir: Cache-Verzeichnis fuer Downloads
  39. max_cache_size_mb: Maximale Cache-Groesse in MB
  40. preferred_format: yt-dlp Format-String
  41. max_results: Maximale Suchergebnisse
  42. search_mode: Suchmodus — "music" (YouTube Music) oder "youtube" (allgemein)
  43. """
  44. super().__init__(name=name, source_type=SourceType.YOUTUBE)
  45. self._cache_dir = Path(cache_dir)
  46. self._max_cache_size_mb = max_cache_size_mb
  47. self._preferred_format = preferred_format
  48. self._max_results = max_results
  49. self._search_mode = search_mode
  50. @property
  51. def is_yt_dlp_available(self) -> bool:
  52. """Prueft ob yt-dlp installiert ist."""
  53. return _YT_DLP_AVAILABLE
  54. async def connect(self) -> bool:
  55. """Prueft ob yt-dlp installiert ist und erstellt Cache-Verzeichnis."""
  56. if not _YT_DLP_AVAILABLE:
  57. pwarn("YouTubeSource: yt-dlp nicht installiert")
  58. self._state = SourceState.UNAVAILABLE
  59. return False
  60. # Cache-Verzeichnis erstellen
  61. try:
  62. self._cache_dir.mkdir(parents=True, exist_ok=True)
  63. self._state = SourceState.AVAILABLE
  64. pinfo("YouTubeSource: Verbunden")
  65. return True
  66. except OSError as e:
  67. perror(f"YouTubeSource: Cache-Verzeichnis konnte nicht erstellt werden: {e}")
  68. self._state = SourceState.ERROR
  69. return False
  70. async def disconnect(self) -> None:
  71. """Trennt die Verbindung."""
  72. self._state = SourceState.UNAVAILABLE
  73. async def scan(self) -> int:
  74. """YouTube unterstuetzt kein Scanning — gibt 0 zurueck."""
  75. return 0
  76. async def get_track(self, track_id: str) -> Track | None:
  77. """Holt Track nach ID."""
  78. return self._tracks.get(track_id)
  79. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  80. """Liefert Audio-Daten als Stream (aus heruntergeladener Datei)."""
  81. if not track.path or not track.path.exists():
  82. return
  83. chunk_size = 65536 # 64 KB
  84. def read_file():
  85. with open(track.path, "rb") as f:
  86. while True:
  87. chunk = f.read(chunk_size)
  88. if not chunk:
  89. break
  90. yield chunk
  91. for chunk in await asyncio.to_thread(lambda: list(read_file())):
  92. yield chunk
  93. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  94. """Liefert Metadaten eines Tracks."""
  95. return track.metadata
  96. async def search(self, query: str, limit: int = 5) -> list[Track]:
  97. """
  98. Sucht auf YouTube nach Audio-Tracks.
  99. Args:
  100. query: Suchstring
  101. limit: Maximale Ergebnisse
  102. Returns:
  103. Liste gefundener Tracks
  104. """
  105. if not _YT_DLP_AVAILABLE or self._state != SourceState.AVAILABLE:
  106. return []
  107. effective_limit = min(limit, self._max_results)
  108. ydl_opts = {
  109. "quiet": True,
  110. "no_warnings": True,
  111. "extract_flat": False,
  112. "skip_download": True,
  113. }
  114. try:
  115. results = []
  116. # YouTube Music: URL-basierte Suche (ytmsearch funktioniert nicht als Prefix)
  117. if self._search_mode in ("music", "music_fallback"):
  118. music_query = f"https://music.youtube.com/search?q={query}"
  119. results = await asyncio.to_thread(
  120. self._search_sync, music_query, {**ydl_opts, "playlistend": effective_limit},
  121. )
  122. # Allgemeine YouTube-Suche (oder Fallback)
  123. if not results:
  124. if self._search_mode == "music_fallback":
  125. pdebug(f"YouTubeSource: Kein Ergebnis auf YouTube Music, suche allgemein...")
  126. ydl_opts["default_search"] = f"ytsearch{effective_limit}"
  127. results = await asyncio.to_thread(self._search_sync, query, ydl_opts)
  128. return results
  129. except Exception as e:
  130. perror(f"YouTubeSource: Suchfehler: {e}")
  131. return []
  132. def _search_sync(self, query: str, ydl_opts: dict) -> list[Track]:
  133. """Synchrone YouTube-Suche (wird in Thread ausgefuehrt)."""
  134. tracks = []
  135. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  136. info = ydl.extract_info(query, download=False)
  137. entries = info.get("entries", []) if info else []
  138. if not entries and info:
  139. # Einzelnes Ergebnis (direkte URL)
  140. entries = [info]
  141. for entry in entries:
  142. if not entry:
  143. continue
  144. video_id = entry.get("id", "")
  145. title = entry.get("title", "Unbekannt")
  146. duration = entry.get("duration", 0)
  147. uploader = entry.get("uploader", "")
  148. thumbnail = entry.get("thumbnail", "")
  149. # Artist/Title Parsing: "Artist - Title" Format
  150. artist, track_title = self._parse_title(title, uploader)
  151. metadata = TrackMetadata(
  152. title=track_title,
  153. artist=artist,
  154. duration_ms=int(duration * 1000) if duration else 0,
  155. artwork_url=thumbnail,
  156. )
  157. track = Track(
  158. uri=f"youtube:{video_id}",
  159. source_type="youtube",
  160. source_id=video_id,
  161. metadata=metadata,
  162. state=TrackState.AVAILABLE,
  163. extra={"video_id": video_id, "url": entry.get("webpage_url", "")},
  164. )
  165. tracks.append(track)
  166. return tracks
  167. def _parse_title(self, title: str, uploader: str) -> tuple[str, str]:
  168. """
  169. Parst YouTube-Titel in Artist und Track-Titel.
  170. Args:
  171. title: YouTube-Video-Titel
  172. uploader: Uploader-Name
  173. Returns:
  174. Tuple (artist, track_title)
  175. """
  176. # Muster: "Artist - Title"
  177. if " - " in title:
  178. parts = title.split(" - ", 1)
  179. return parts[0].strip(), parts[1].strip()
  180. # Muster: "Artist | Title"
  181. if " | " in title:
  182. parts = title.split(" | ", 1)
  183. return parts[0].strip(), parts[1].strip()
  184. # Suffixe entfernen (Official Video, Lyric Video, etc.)
  185. cleaned = re.sub(
  186. r'\s*[\(\[\{].*?(official|lyric|music|video|audio|hd|hq|4k).*?[\)\]\}]',
  187. '', title, flags=re.IGNORECASE
  188. ).strip()
  189. return uploader or "Unbekannt", cleaned or title
  190. async def prepare_track(self, track: Track) -> bool:
  191. """
  192. Laedt Audio herunter und setzt track.path.
  193. Args:
  194. track: Track zum Vorbereiten
  195. Returns:
  196. True wenn erfolgreich
  197. """
  198. if not _YT_DLP_AVAILABLE or self._state != SourceState.AVAILABLE:
  199. return False
  200. video_id = track.extra.get("video_id", track.source_id)
  201. if not video_id:
  202. perror("YouTubeSource: Keine video_id fuer Download")
  203. return False
  204. # Cache pruefen
  205. cached_path = self._get_cached_path(video_id)
  206. if cached_path:
  207. track.path = cached_path
  208. track.state = TrackState.AVAILABLE
  209. pdebug(f"YouTubeSource: Cache-Treffer: {cached_path.name}")
  210. return True
  211. # Herunterladen
  212. track.state = TrackState.LOADING
  213. try:
  214. output_path = await asyncio.to_thread(
  215. self._download_sync, video_id
  216. )
  217. if output_path and output_path.exists():
  218. track.path = output_path
  219. track.state = TrackState.AVAILABLE
  220. pinfo(f"YouTubeSource: Heruntergeladen: {output_path.name}")
  221. # Cache aufraeumen
  222. await self.cleanup_cache()
  223. return True
  224. else:
  225. track.state = TrackState.ERROR
  226. track.error_message = "Download fehlgeschlagen"
  227. return False
  228. except Exception as e:
  229. perror(f"YouTubeSource: Download-Fehler: {e}")
  230. track.state = TrackState.ERROR
  231. track.error_message = str(e)
  232. return False
  233. def _download_sync(self, video_id: str) -> Path | None:
  234. """Synchroner Download (wird in Thread ausgefuehrt)."""
  235. output_template = str(self._cache_dir / f"{video_id}.%(ext)s")
  236. ydl_opts = {
  237. "format": self._preferred_format,
  238. "outtmpl": output_template,
  239. "quiet": True,
  240. "no_warnings": True,
  241. "postprocessors": [{
  242. "key": "FFmpegExtractAudio",
  243. "preferredcodec": "mp3",
  244. "preferredquality": "192",
  245. }],
  246. }
  247. url = f"https://www.youtube.com/watch?v={video_id}"
  248. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  249. ydl.download([url])
  250. # Ergebnis-Datei finden (yt-dlp aendert Extension nach Postprocessing)
  251. expected_path = self._cache_dir / f"{video_id}.mp3"
  252. if expected_path.exists():
  253. return expected_path
  254. # Fallback: Suche nach video_id im Dateinamen
  255. for f in self._cache_dir.iterdir():
  256. if f.stem == video_id and f.is_file():
  257. return f
  258. return None
  259. def _get_cached_path(self, video_id: str) -> Path | None:
  260. """Prueft ob eine Datei im Cache liegt."""
  261. if not self._cache_dir.exists():
  262. return None
  263. for f in self._cache_dir.iterdir():
  264. if f.stem == video_id and f.is_file():
  265. return f
  266. return None
  267. async def cleanup_cache(self) -> int:
  268. """
  269. Entfernt aelteste Dateien wenn Cache zu gross.
  270. Returns:
  271. Anzahl entfernter Dateien
  272. """
  273. if not self._cache_dir.exists():
  274. return 0
  275. return await asyncio.to_thread(self._cleanup_cache_sync)
  276. def _cleanup_cache_sync(self) -> int:
  277. """Synchrone Cache-Bereinigung."""
  278. # Alle Dateien mit Groesse und Zeitstempel
  279. files: list[tuple[Path, float, int]] = []
  280. total_size = 0
  281. for f in self._cache_dir.iterdir():
  282. if f.is_file():
  283. stat = f.stat()
  284. files.append((f, stat.st_mtime, stat.st_size))
  285. total_size += stat.st_size
  286. max_size = self._max_cache_size_mb * 1024 * 1024
  287. if total_size <= max_size:
  288. return 0
  289. # Nach Aenderungsdatum sortieren (aelteste zuerst)
  290. files.sort(key=lambda x: x[1])
  291. removed = 0
  292. for file_path, _, size in files:
  293. if total_size <= max_size:
  294. break
  295. try:
  296. file_path.unlink()
  297. total_size -= size
  298. removed += 1
  299. except OSError:
  300. pass
  301. if removed > 0:
  302. pdebug(f"YouTubeSource: {removed} Cache-Dateien entfernt")
  303. return removed