deezer.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. # -*- coding: utf-8 -*-
  2. """
  3. Deezer-Quelle via Deezer Public API + yt-dlp.
  4. Die Deezer Public API ist kostenlos und braucht KEINEN API-Key
  5. fuer Suchoperationen: https://api.deezer.com/search?q=...
  6. Audio-Download:
  7. - 30-Sekunden-Preview direkt von Deezer (immer verfuegbar)
  8. - Voller Track via yt-dlp (sucht den Song auf YouTube)
  9. - Konfigurierbar: "preview" oder "full" (Default: "full")
  10. Vorteile:
  11. - Kein API-Key / Registrierung noetig
  12. - Hochwertige Metadaten (Artist, Album, Cover, Dauer, ISRC)
  13. - Gute Suchqualitaet (besser als reine YouTube-Suche)
  14. Abhaengigkeiten:
  15. - aiohttp: Fuer API-Anfragen (oft schon installiert)
  16. - yt-dlp: Fuer vollen Audio-Download (optional, Fallback auf Preview)
  17. """
  18. from __future__ import annotations
  19. import asyncio
  20. import re
  21. from pathlib import Path
  22. from typing import AsyncIterator
  23. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
  24. from trixy_core.music.track import Track, TrackMetadata, TrackState
  25. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  26. _AIOHTTP_AVAILABLE = False
  27. try:
  28. import aiohttp
  29. _AIOHTTP_AVAILABLE = True
  30. except ImportError:
  31. pass
  32. _YT_DLP_AVAILABLE = False
  33. try:
  34. import yt_dlp
  35. _YT_DLP_AVAILABLE = True
  36. except ImportError:
  37. pass
  38. DEEZER_API_BASE = "https://api.deezer.com"
  39. class DeezerSource(MusicSource):
  40. """Deezer-Quelle via Public API + yt-dlp."""
  41. def __init__(
  42. self,
  43. name: str = "Deezer",
  44. cache_dir: str = "cache/deezer",
  45. max_cache_size_mb: int = 500,
  46. max_results: int = 5,
  47. download_mode: str = "full",
  48. ) -> None:
  49. """
  50. Initialisiert die Deezer-Quelle.
  51. Args:
  52. name: Name der Quelle
  53. cache_dir: Cache-Verzeichnis
  54. max_cache_size_mb: Maximale Cache-Groesse
  55. max_results: Maximale Suchergebnisse
  56. download_mode: "full" (yt-dlp, voller Track) oder
  57. "preview" (30s Deezer-Preview)
  58. """
  59. super().__init__(name=name, source_type=SourceType.URL)
  60. self._cache_dir = Path(cache_dir)
  61. self._max_cache_size_mb = max_cache_size_mb
  62. self._max_results = max_results
  63. self._download_mode = download_mode
  64. async def connect(self) -> bool:
  65. """Prueft aiohttp Verfuegbarkeit und API-Erreichbarkeit."""
  66. if not _AIOHTTP_AVAILABLE:
  67. pwarn("DeezerSource: aiohttp nicht installiert")
  68. self._state = SourceState.UNAVAILABLE
  69. return False
  70. # API-Erreichbarkeit testen
  71. try:
  72. async with aiohttp.ClientSession() as session:
  73. async with session.get(
  74. f"{DEEZER_API_BASE}/search?q=test&limit=1",
  75. timeout=aiohttp.ClientTimeout(total=5),
  76. ) as resp:
  77. if resp.status != 200:
  78. perror(f"DeezerSource: API nicht erreichbar (HTTP {resp.status})")
  79. self._state = SourceState.ERROR
  80. return False
  81. except Exception as e:
  82. perror(f"DeezerSource: API-Test fehlgeschlagen: {e}")
  83. self._state = SourceState.ERROR
  84. return False
  85. try:
  86. self._cache_dir.mkdir(parents=True, exist_ok=True)
  87. except OSError as e:
  88. perror(f"DeezerSource: Cache-Verzeichnis Fehler: {e}")
  89. self._state = SourceState.AVAILABLE
  90. dl_mode = self._download_mode
  91. if dl_mode == "full" and not _YT_DLP_AVAILABLE:
  92. dl_mode = "preview (yt-dlp nicht installiert)"
  93. pinfo(f"DeezerSource: Verbunden (Download={dl_mode})")
  94. return True
  95. async def disconnect(self) -> None:
  96. self._state = SourceState.UNAVAILABLE
  97. async def scan(self) -> int:
  98. return 0
  99. async def get_track(self, track_id: str) -> Track | None:
  100. return self._tracks.get(track_id)
  101. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  102. if not track.path or not track.path.exists():
  103. return
  104. chunk_size = 65536
  105. def read_file():
  106. with open(track.path, "rb") as f:
  107. while True:
  108. chunk = f.read(chunk_size)
  109. if not chunk:
  110. break
  111. yield chunk
  112. for chunk in await asyncio.to_thread(lambda: list(read_file())):
  113. yield chunk
  114. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  115. return track.metadata
  116. async def search(self, query: str, limit: int = 5) -> list[Track]:
  117. """
  118. Sucht auf Deezer via Public API.
  119. Kein API-Key noetig — die Deezer Search API ist
  120. oeffentlich zugaenglich.
  121. """
  122. if not _AIOHTTP_AVAILABLE or self._state != SourceState.AVAILABLE:
  123. return []
  124. effective_limit = min(limit, self._max_results)
  125. try:
  126. return await self._search_async(query, effective_limit)
  127. except Exception as e:
  128. perror(f"DeezerSource: Suchfehler: {e}")
  129. return []
  130. async def _search_async(self, query: str, limit: int) -> list[Track]:
  131. """Async Deezer-Suche."""
  132. tracks: list[Track] = []
  133. url = f"{DEEZER_API_BASE}/search"
  134. params = {"q": query, "limit": limit}
  135. async with aiohttp.ClientSession() as session:
  136. async with session.get(
  137. url, params=params,
  138. timeout=aiohttp.ClientTimeout(total=10),
  139. ) as resp:
  140. if resp.status != 200:
  141. return []
  142. data = await resp.json()
  143. for item in data.get("data", []):
  144. if not item:
  145. continue
  146. track_id = str(item.get("id", ""))
  147. title = item.get("title", "Unbekannt")
  148. duration = item.get("duration", 0)
  149. preview_url = item.get("preview", "")
  150. # Artist
  151. artist_data = item.get("artist", {})
  152. artist_name = artist_data.get("name", "Unbekannt")
  153. artist_picture = artist_data.get("picture_medium", "")
  154. # Album
  155. album_data = item.get("album", {})
  156. album_name = album_data.get("title", "")
  157. album_cover = album_data.get("cover_big", album_data.get("cover_medium", ""))
  158. metadata = TrackMetadata(
  159. title=title,
  160. artist=artist_name,
  161. album=album_name,
  162. artwork_url=album_cover or artist_picture,
  163. duration_ms=duration * 1000,
  164. )
  165. track = Track(
  166. uri=f"deezer:{track_id}",
  167. source_type="deezer",
  168. source_id=track_id,
  169. metadata=metadata,
  170. state=TrackState.AVAILABLE,
  171. extra={
  172. "deezer_id": track_id,
  173. "preview_url": preview_url,
  174. "deezer_url": item.get("link", ""),
  175. "rank": item.get("rank", 0),
  176. "explicit": item.get("explicit_lyrics", False),
  177. },
  178. )
  179. tracks.append(track)
  180. return tracks
  181. async def prepare_track(self, track: Track) -> bool:
  182. """
  183. Bereitet einen Track fuer die Wiedergabe vor.
  184. Strategie:
  185. 1. Cache pruefen
  186. 2. download_mode == "full" + yt-dlp: Suche auf YouTube, lade vollen Track
  187. 3. Fallback: 30-Sekunden-Preview von Deezer
  188. """
  189. # Cache pruefen
  190. cached = self._get_cached_path(track.source_id)
  191. if cached:
  192. track.path = cached
  193. track.state = TrackState.AVAILABLE
  194. pdebug(f"DeezerSource: Cache-Treffer: {cached.name}")
  195. return True
  196. track.state = TrackState.LOADING
  197. # Voller Track via yt-dlp (YouTube-Suche nach Artist + Title)
  198. if self._download_mode == "full" and _YT_DLP_AVAILABLE:
  199. search_query = f"{track.metadata.artist} {track.metadata.title}"
  200. try:
  201. output_path = await asyncio.to_thread(
  202. self._download_full_sync, search_query, track.source_id
  203. )
  204. if output_path and output_path.exists():
  205. track.path = output_path
  206. track.state = TrackState.AVAILABLE
  207. pinfo(f"DeezerSource: Heruntergeladen: {output_path.name}")
  208. await self.cleanup_cache()
  209. return True
  210. except Exception as e:
  211. pdebug(f"DeezerSource: yt-dlp Download fehlgeschlagen: {e}")
  212. # Fallthrough zu Preview
  213. # Fallback: 30s Preview von Deezer
  214. preview_url = track.extra.get("preview_url", "")
  215. if preview_url and _AIOHTTP_AVAILABLE:
  216. try:
  217. output_path = await self._download_preview(preview_url, track.source_id)
  218. if output_path and output_path.exists():
  219. track.path = output_path
  220. track.state = TrackState.AVAILABLE
  221. pdebug(f"DeezerSource: Preview heruntergeladen: {output_path.name}")
  222. return True
  223. except Exception as e:
  224. pdebug(f"DeezerSource: Preview-Download fehlgeschlagen: {e}")
  225. track.state = TrackState.ERROR
  226. track.error_message = "Download fehlgeschlagen"
  227. return False
  228. def _download_full_sync(self, search_query: str, track_id: str) -> Path | None:
  229. """Voller Track via yt-dlp YouTube-Suche."""
  230. output_template = str(self._cache_dir / f"{track_id}.%(ext)s")
  231. ydl_opts = {
  232. "format": "bestaudio/best",
  233. "outtmpl": output_template,
  234. "quiet": True,
  235. "no_warnings": True,
  236. "default_search": "ytsearch1",
  237. "postprocessors": [{
  238. "key": "FFmpegExtractAudio",
  239. "preferredcodec": "mp3",
  240. "preferredquality": "192",
  241. }],
  242. }
  243. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  244. ydl.download([search_query])
  245. expected = self._cache_dir / f"{track_id}.mp3"
  246. if expected.exists():
  247. return expected
  248. for f in self._cache_dir.iterdir():
  249. if f.stem == track_id and f.is_file():
  250. return f
  251. return None
  252. async def _download_preview(self, url: str, track_id: str) -> Path | None:
  253. """Laedt 30-Sekunden-Preview von Deezer herunter."""
  254. output_path = self._cache_dir / f"{track_id}_preview.mp3"
  255. async with aiohttp.ClientSession() as session:
  256. async with session.get(
  257. url, timeout=aiohttp.ClientTimeout(total=30)
  258. ) as resp:
  259. if resp.status != 200:
  260. return None
  261. data = await resp.read()
  262. output_path.write_bytes(data)
  263. return output_path
  264. def _get_cached_path(self, track_id: str) -> Path | None:
  265. if not self._cache_dir.exists():
  266. return None
  267. # Voller Track bevorzugt
  268. full = self._cache_dir / f"{track_id}.mp3"
  269. if full.exists():
  270. return full
  271. # Preview als Fallback
  272. preview = self._cache_dir / f"{track_id}_preview.mp3"
  273. if preview.exists():
  274. return preview
  275. return None
  276. async def cleanup_cache(self) -> int:
  277. if not self._cache_dir.exists():
  278. return 0
  279. return await asyncio.to_thread(self._cleanup_cache_sync)
  280. def _cleanup_cache_sync(self) -> int:
  281. files: list[tuple[Path, float, int]] = []
  282. total_size = 0
  283. for f in self._cache_dir.iterdir():
  284. if f.is_file():
  285. stat = f.stat()
  286. files.append((f, stat.st_mtime, stat.st_size))
  287. total_size += stat.st_size
  288. max_size = self._max_cache_size_mb * 1024 * 1024
  289. if total_size <= max_size:
  290. return 0
  291. files.sort(key=lambda x: x[1])
  292. removed = 0
  293. for file_path, _, size in files:
  294. if total_size <= max_size:
  295. break
  296. try:
  297. file_path.unlink()
  298. total_size -= size
  299. removed += 1
  300. except OSError:
  301. pass
  302. if removed > 0:
  303. pdebug(f"DeezerSource: {removed} Cache-Dateien entfernt")
  304. return removed