spotify.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. # -*- coding: utf-8 -*-
  2. """
  3. Spotify-Quelle via Spotify Web API + spotdl.
  4. Sucht Tracks ueber die offizielle Spotify Web API (kostenlos,
  5. Client Credentials Flow) und laedt Audio via spotdl herunter.
  6. spotdl nutzt intern YouTube als Audio-Quelle — das ist der
  7. sauberste Ansatz, da Spotify selbst kein Audio-Streaming
  8. fuer Drittanbieter erlaubt (DRM).
  9. Abhaengigkeiten (alle optional):
  10. - spotipy: Spotify Web API Client
  11. - spotdl: Audio-Download (nutzt YouTube + FFmpeg)
  12. """
  13. from __future__ import annotations
  14. import asyncio
  15. import os
  16. import re
  17. import shutil
  18. from pathlib import Path
  19. from typing import AsyncIterator
  20. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
  21. from trixy_core.music.track import Track, TrackMetadata, TrackState
  22. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  23. # Optionale Abhaengigkeiten
  24. _SPOTIPY_AVAILABLE = False
  25. try:
  26. import spotipy
  27. from spotipy.oauth2 import SpotifyClientCredentials
  28. _SPOTIPY_AVAILABLE = True
  29. except ImportError:
  30. pass
  31. class SpotifySource(MusicSource):
  32. """Spotify-Quelle via Spotify Web API + spotdl."""
  33. def __init__(
  34. self,
  35. name: str = "Spotify",
  36. client_id: str = "",
  37. client_secret: str = "",
  38. cache_dir: str = "cache/spotify",
  39. max_cache_size_mb: int = 500,
  40. max_results: int = 5,
  41. market: str = "DE",
  42. ) -> None:
  43. """
  44. Initialisiert die Spotify-Quelle.
  45. Args:
  46. name: Name der Quelle
  47. client_id: Spotify API Client ID
  48. client_secret: Spotify API Client Secret
  49. cache_dir: Cache-Verzeichnis fuer Downloads
  50. max_cache_size_mb: Maximale Cache-Groesse in MB
  51. max_results: Maximale Suchergebnisse
  52. market: Spotify-Markt fuer regionale Verfuegbarkeit
  53. """
  54. super().__init__(name=name, source_type=SourceType.SPOTIFY)
  55. self._client_id = client_id
  56. self._client_secret = client_secret
  57. self._cache_dir = Path(cache_dir)
  58. self._max_cache_size_mb = max_cache_size_mb
  59. self._max_results = max_results
  60. self._market = market
  61. self._sp: spotipy.Spotify | None = None
  62. self._spotdl_available = False
  63. @property
  64. def has_api(self) -> bool:
  65. """Prueft ob die Spotify API verfuegbar ist."""
  66. return self._sp is not None
  67. @property
  68. def has_download(self) -> bool:
  69. """Prueft ob spotdl fuer Downloads verfuegbar ist."""
  70. return self._spotdl_available
  71. async def connect(self) -> bool:
  72. """
  73. Verbindet sich mit der Spotify Web API und prueft spotdl.
  74. Benoetigt:
  75. - spotipy (Python-Paket)
  76. - Gueltige client_id + client_secret
  77. - spotdl (optional, fuer Audio-Download)
  78. """
  79. if not _SPOTIPY_AVAILABLE:
  80. pwarn("SpotifySource: spotipy nicht installiert")
  81. self._state = SourceState.UNAVAILABLE
  82. return False
  83. if not self._client_id or not self._client_secret:
  84. pwarn("SpotifySource: client_id oder client_secret fehlt")
  85. self._state = SourceState.UNAVAILABLE
  86. return False
  87. # Spotify API Client erstellen
  88. try:
  89. auth_manager = SpotifyClientCredentials(
  90. client_id=self._client_id,
  91. client_secret=self._client_secret,
  92. )
  93. self._sp = spotipy.Spotify(auth_manager=auth_manager)
  94. # Verbindung testen
  95. await asyncio.to_thread(self._sp.search, q="test", limit=1)
  96. pdebug("SpotifySource: API-Verbindung erfolgreich")
  97. except Exception as e:
  98. perror(f"SpotifySource: API-Verbindung fehlgeschlagen: {e}")
  99. self._sp = None
  100. self._state = SourceState.ERROR
  101. return False
  102. # spotdl pruefen (fuer Audio-Download)
  103. self._spotdl_available = shutil.which("spotdl") is not None
  104. if not self._spotdl_available:
  105. pwarn(
  106. "SpotifySource: spotdl nicht installiert — "
  107. "Suche funktioniert, Download nicht"
  108. )
  109. # Cache-Verzeichnis erstellen
  110. try:
  111. self._cache_dir.mkdir(parents=True, exist_ok=True)
  112. except OSError as e:
  113. perror(f"SpotifySource: Cache-Verzeichnis Fehler: {e}")
  114. self._state = SourceState.AVAILABLE
  115. dl_status = "ja" if self._spotdl_available else "nein"
  116. pinfo(f"SpotifySource: Verbunden (Download={dl_status})")
  117. return True
  118. async def disconnect(self) -> None:
  119. """Trennt die Verbindung."""
  120. self._sp = None
  121. self._state = SourceState.UNAVAILABLE
  122. async def scan(self) -> int:
  123. """Spotify unterstuetzt kein Scanning — gibt 0 zurueck."""
  124. return 0
  125. async def get_track(self, track_id: str) -> Track | None:
  126. """Holt Track nach ID (aus internem Cache)."""
  127. return self._tracks.get(track_id)
  128. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  129. """Liefert Audio-Daten als Stream (aus heruntergeladener Datei)."""
  130. if not track.path or not track.path.exists():
  131. return
  132. chunk_size = 65536 # 64 KB
  133. def read_file():
  134. with open(track.path, "rb") as f:
  135. while True:
  136. chunk = f.read(chunk_size)
  137. if not chunk:
  138. break
  139. yield chunk
  140. for chunk in await asyncio.to_thread(lambda: list(read_file())):
  141. yield chunk
  142. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  143. """Liefert Metadaten eines Tracks."""
  144. return track.metadata
  145. async def search(self, query: str, limit: int = 5) -> list[Track]:
  146. """
  147. Sucht auf Spotify nach Tracks.
  148. Nutzt die offizielle Spotify Web API — schnell, zuverlaessig
  149. und mit hochwertigen Metadaten (Artist, Album, Artwork, Dauer).
  150. Args:
  151. query: Suchstring
  152. limit: Maximale Ergebnisse
  153. Returns:
  154. Liste gefundener Tracks
  155. """
  156. if not self._sp or self._state != SourceState.AVAILABLE:
  157. return []
  158. effective_limit = min(limit, self._max_results)
  159. try:
  160. results = await asyncio.to_thread(
  161. self._search_sync, query, effective_limit
  162. )
  163. return results
  164. except Exception as e:
  165. perror(f"SpotifySource: Suchfehler: {e}")
  166. return []
  167. def _search_sync(self, query: str, limit: int) -> list[Track]:
  168. """Synchrone Spotify-Suche (wird in Thread ausgefuehrt)."""
  169. tracks: list[Track] = []
  170. result = self._sp.search(q=query, limit=limit, type="track", market=self._market)
  171. if not result or "tracks" not in result:
  172. return tracks
  173. for item in result["tracks"].get("items", []):
  174. if not item:
  175. continue
  176. track_id = item.get("id", "")
  177. track_name = item.get("name", "Unbekannt")
  178. duration_ms = item.get("duration_ms", 0)
  179. # Artists
  180. artists = item.get("artists", [])
  181. artist_name = ", ".join(a.get("name", "") for a in artists) if artists else "Unbekannt"
  182. # Album
  183. album = item.get("album", {})
  184. album_name = album.get("name", "")
  185. album_images = album.get("images", [])
  186. artwork_url = album_images[0]["url"] if album_images else ""
  187. # Release-Jahr
  188. release_date = album.get("release_date", "")
  189. year = 0
  190. if release_date:
  191. try:
  192. year = int(release_date[:4])
  193. except (ValueError, IndexError):
  194. pass
  195. # Track-Nummer
  196. track_number = item.get("track_number", 0)
  197. disc_number = item.get("disc_number", 1)
  198. # Spotify URI fuer spotdl
  199. spotify_uri = item.get("uri", f"spotify:track:{track_id}")
  200. external_url = item.get("external_urls", {}).get("spotify", "")
  201. metadata = TrackMetadata(
  202. title=track_name,
  203. artist=artist_name,
  204. album=album_name,
  205. artwork_url=artwork_url,
  206. duration_ms=duration_ms,
  207. year=year,
  208. track_number=track_number,
  209. disc_number=disc_number,
  210. )
  211. track = Track(
  212. uri=f"spotify:{track_id}",
  213. source_type="spotify",
  214. source_id=track_id,
  215. metadata=metadata,
  216. state=TrackState.AVAILABLE,
  217. extra={
  218. "spotify_uri": spotify_uri,
  219. "spotify_url": external_url,
  220. "preview_url": item.get("preview_url", ""),
  221. "popularity": item.get("popularity", 0),
  222. "explicit": item.get("explicit", False),
  223. },
  224. )
  225. tracks.append(track)
  226. return tracks
  227. async def prepare_track(self, track: Track) -> bool:
  228. """
  229. Laedt Audio herunter via spotdl und setzt track.path.
  230. spotdl sucht den Track auf YouTube und laedt die Audio-Version
  231. herunter. Das ist der empfohlene Weg — legal, zuverlaessig,
  232. gute Audioqualitaet.
  233. Args:
  234. track: Track zum Vorbereiten
  235. Returns:
  236. True wenn erfolgreich
  237. """
  238. if not self._spotdl_available:
  239. perror("SpotifySource: spotdl nicht installiert — Download nicht moeglich")
  240. return False
  241. spotify_url = track.extra.get("spotify_url", "")
  242. if not spotify_url:
  243. spotify_uri = track.extra.get("spotify_uri", "")
  244. if spotify_uri:
  245. # spotify:track:ABC123 → https://open.spotify.com/track/ABC123
  246. parts = spotify_uri.split(":")
  247. if len(parts) == 3:
  248. spotify_url = f"https://open.spotify.com/track/{parts[2]}"
  249. if not spotify_url:
  250. perror("SpotifySource: Keine Spotify-URL fuer Download")
  251. return False
  252. # Cache pruefen
  253. cached_path = self._get_cached_path(track.source_id)
  254. if cached_path:
  255. track.path = cached_path
  256. track.state = TrackState.AVAILABLE
  257. pdebug(f"SpotifySource: Cache-Treffer: {cached_path.name}")
  258. return True
  259. # Download via spotdl
  260. track.state = TrackState.LOADING
  261. try:
  262. output_path = await asyncio.to_thread(
  263. self._download_sync, spotify_url, track.source_id
  264. )
  265. if output_path and output_path.exists():
  266. track.path = output_path
  267. track.state = TrackState.AVAILABLE
  268. pinfo(f"SpotifySource: Heruntergeladen: {output_path.name}")
  269. await self.cleanup_cache()
  270. return True
  271. else:
  272. track.state = TrackState.ERROR
  273. track.error_message = "Download fehlgeschlagen"
  274. return False
  275. except Exception as e:
  276. perror(f"SpotifySource: Download-Fehler: {e}")
  277. track.state = TrackState.ERROR
  278. track.error_message = str(e)
  279. return False
  280. def _download_sync(self, spotify_url: str, track_id: str) -> Path | None:
  281. """Synchroner Download via spotdl (wird in Thread ausgefuehrt)."""
  282. import subprocess
  283. output_path = self._cache_dir / f"{track_id}.mp3"
  284. # spotdl: Download mit Ausgabe-Format und Verzeichnis
  285. cmd = [
  286. "spotdl", "download", spotify_url,
  287. "--output", str(output_path),
  288. "--format", "mp3",
  289. "--bitrate", "192k",
  290. ]
  291. try:
  292. result = subprocess.run(
  293. cmd,
  294. capture_output=True,
  295. timeout=120,
  296. cwd=str(self._cache_dir),
  297. )
  298. if result.returncode != 0:
  299. stderr = result.stderr.decode(errors="replace")
  300. pdebug(f"SpotifySource: spotdl Fehler: {stderr[:200]}")
  301. # spotdl benennt die Datei manchmal nach Artist - Title
  302. if output_path.exists():
  303. return output_path
  304. # Fallback: Suche nach neuen Dateien im Cache-Verzeichnis
  305. for f in sorted(
  306. self._cache_dir.glob("*.mp3"),
  307. key=lambda p: p.stat().st_mtime,
  308. reverse=True,
  309. ):
  310. # Neueste Datei die nicht schon als Cache bekannt ist
  311. target = self._cache_dir / f"{track_id}.mp3"
  312. if f != target:
  313. f.rename(target)
  314. return target
  315. return f
  316. except subprocess.TimeoutExpired:
  317. perror("SpotifySource: spotdl Timeout (120s)")
  318. except FileNotFoundError:
  319. perror("SpotifySource: spotdl nicht gefunden")
  320. except Exception as e:
  321. perror(f"SpotifySource: Download-Fehler: {e}")
  322. return None
  323. def _get_cached_path(self, track_id: str) -> Path | None:
  324. """Prueft ob eine Datei im Cache liegt."""
  325. if not self._cache_dir.exists():
  326. return None
  327. cached = self._cache_dir / f"{track_id}.mp3"
  328. return cached if cached.exists() else None
  329. async def cleanup_cache(self) -> int:
  330. """
  331. Entfernt aelteste Dateien wenn Cache zu gross.
  332. Returns:
  333. Anzahl entfernter Dateien
  334. """
  335. if not self._cache_dir.exists():
  336. return 0
  337. return await asyncio.to_thread(self._cleanup_cache_sync)
  338. def _cleanup_cache_sync(self) -> int:
  339. """Synchrone Cache-Bereinigung (LRU)."""
  340. files: list[tuple[Path, float, int]] = []
  341. total_size = 0
  342. for f in self._cache_dir.iterdir():
  343. if f.is_file():
  344. stat = f.stat()
  345. files.append((f, stat.st_mtime, stat.st_size))
  346. total_size += stat.st_size
  347. max_size = self._max_cache_size_mb * 1024 * 1024
  348. if total_size <= max_size:
  349. return 0
  350. # Aelteste zuerst entfernen
  351. files.sort(key=lambda x: x[1])
  352. removed = 0
  353. for file_path, _, size in files:
  354. if total_size <= max_size:
  355. break
  356. try:
  357. file_path.unlink()
  358. total_size -= size
  359. removed += 1
  360. except OSError:
  361. pass
  362. if removed > 0:
  363. pdebug(f"SpotifySource: {removed} Cache-Dateien entfernt")
  364. return removed