| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- # -*- coding: utf-8 -*-
- """
- Spotify-Quelle via Spotify Web API + spotdl.
- Sucht Tracks ueber die offizielle Spotify Web API (kostenlos,
- Client Credentials Flow) und laedt Audio via spotdl herunter.
- spotdl nutzt intern YouTube als Audio-Quelle — das ist der
- sauberste Ansatz, da Spotify selbst kein Audio-Streaming
- fuer Drittanbieter erlaubt (DRM).
- Abhaengigkeiten (alle optional):
- - spotipy: Spotify Web API Client
- - spotdl: Audio-Download (nutzt YouTube + FFmpeg)
- """
- from __future__ import annotations
- import asyncio
- import os
- import re
- import shutil
- from pathlib import Path
- from typing import AsyncIterator
- from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
- from trixy_core.music.track import Track, TrackMetadata, TrackState
- from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
- # Optionale Abhaengigkeiten
- _SPOTIPY_AVAILABLE = False
- try:
- import spotipy
- from spotipy.oauth2 import SpotifyClientCredentials
- _SPOTIPY_AVAILABLE = True
- except ImportError:
- pass
- class SpotifySource(MusicSource):
- """Spotify-Quelle via Spotify Web API + spotdl."""
- def __init__(
- self,
- name: str = "Spotify",
- client_id: str = "",
- client_secret: str = "",
- cache_dir: str = "cache/spotify",
- max_cache_size_mb: int = 500,
- max_results: int = 5,
- market: str = "DE",
- ) -> None:
- """
- Initialisiert die Spotify-Quelle.
- Args:
- name: Name der Quelle
- client_id: Spotify API Client ID
- client_secret: Spotify API Client Secret
- cache_dir: Cache-Verzeichnis fuer Downloads
- max_cache_size_mb: Maximale Cache-Groesse in MB
- max_results: Maximale Suchergebnisse
- market: Spotify-Markt fuer regionale Verfuegbarkeit
- """
- super().__init__(name=name, source_type=SourceType.SPOTIFY)
- self._client_id = client_id
- self._client_secret = client_secret
- self._cache_dir = Path(cache_dir)
- self._max_cache_size_mb = max_cache_size_mb
- self._max_results = max_results
- self._market = market
- self._sp: spotipy.Spotify | None = None
- self._spotdl_available = False
- @property
- def has_api(self) -> bool:
- """Prueft ob die Spotify API verfuegbar ist."""
- return self._sp is not None
- @property
- def has_download(self) -> bool:
- """Prueft ob spotdl fuer Downloads verfuegbar ist."""
- return self._spotdl_available
- async def connect(self) -> bool:
- """
- Verbindet sich mit der Spotify Web API und prueft spotdl.
- Benoetigt:
- - spotipy (Python-Paket)
- - Gueltige client_id + client_secret
- - spotdl (optional, fuer Audio-Download)
- """
- if not _SPOTIPY_AVAILABLE:
- pwarn("SpotifySource: spotipy nicht installiert")
- self._state = SourceState.UNAVAILABLE
- return False
- if not self._client_id or not self._client_secret:
- pwarn("SpotifySource: client_id oder client_secret fehlt")
- self._state = SourceState.UNAVAILABLE
- return False
- # Spotify API Client erstellen
- try:
- auth_manager = SpotifyClientCredentials(
- client_id=self._client_id,
- client_secret=self._client_secret,
- )
- self._sp = spotipy.Spotify(auth_manager=auth_manager)
- # Verbindung testen
- await asyncio.to_thread(self._sp.search, q="test", limit=1)
- pdebug("SpotifySource: API-Verbindung erfolgreich")
- except Exception as e:
- perror(f"SpotifySource: API-Verbindung fehlgeschlagen: {e}")
- self._sp = None
- self._state = SourceState.ERROR
- return False
- # spotdl pruefen (fuer Audio-Download)
- self._spotdl_available = shutil.which("spotdl") is not None
- if not self._spotdl_available:
- pwarn(
- "SpotifySource: spotdl nicht installiert — "
- "Suche funktioniert, Download nicht"
- )
- # Cache-Verzeichnis erstellen
- try:
- self._cache_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- perror(f"SpotifySource: Cache-Verzeichnis Fehler: {e}")
- self._state = SourceState.AVAILABLE
- dl_status = "ja" if self._spotdl_available else "nein"
- pinfo(f"SpotifySource: Verbunden (Download={dl_status})")
- return True
- async def disconnect(self) -> None:
- """Trennt die Verbindung."""
- self._sp = None
- self._state = SourceState.UNAVAILABLE
- async def scan(self) -> int:
- """Spotify unterstuetzt kein Scanning — gibt 0 zurueck."""
- return 0
- async def get_track(self, track_id: str) -> Track | None:
- """Holt Track nach ID (aus internem Cache)."""
- return self._tracks.get(track_id)
- async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
- """Liefert Audio-Daten als Stream (aus heruntergeladener Datei)."""
- if not track.path or not track.path.exists():
- return
- chunk_size = 65536 # 64 KB
- def read_file():
- with open(track.path, "rb") as f:
- while True:
- chunk = f.read(chunk_size)
- if not chunk:
- break
- yield chunk
- for chunk in await asyncio.to_thread(lambda: list(read_file())):
- yield chunk
- async def get_metadata(self, track: Track) -> TrackMetadata | None:
- """Liefert Metadaten eines Tracks."""
- return track.metadata
- async def search(self, query: str, limit: int = 5) -> list[Track]:
- """
- Sucht auf Spotify nach Tracks.
- Nutzt die offizielle Spotify Web API — schnell, zuverlaessig
- und mit hochwertigen Metadaten (Artist, Album, Artwork, Dauer).
- Args:
- query: Suchstring
- limit: Maximale Ergebnisse
- Returns:
- Liste gefundener Tracks
- """
- if not self._sp or self._state != SourceState.AVAILABLE:
- return []
- effective_limit = min(limit, self._max_results)
- try:
- results = await asyncio.to_thread(
- self._search_sync, query, effective_limit
- )
- return results
- except Exception as e:
- perror(f"SpotifySource: Suchfehler: {e}")
- return []
- def _search_sync(self, query: str, limit: int) -> list[Track]:
- """Synchrone Spotify-Suche (wird in Thread ausgefuehrt)."""
- tracks: list[Track] = []
- result = self._sp.search(q=query, limit=limit, type="track", market=self._market)
- if not result or "tracks" not in result:
- return tracks
- for item in result["tracks"].get("items", []):
- if not item:
- continue
- track_id = item.get("id", "")
- track_name = item.get("name", "Unbekannt")
- duration_ms = item.get("duration_ms", 0)
- # Artists
- artists = item.get("artists", [])
- artist_name = ", ".join(a.get("name", "") for a in artists) if artists else "Unbekannt"
- # Album
- album = item.get("album", {})
- album_name = album.get("name", "")
- album_images = album.get("images", [])
- artwork_url = album_images[0]["url"] if album_images else ""
- # Release-Jahr
- release_date = album.get("release_date", "")
- year = 0
- if release_date:
- try:
- year = int(release_date[:4])
- except (ValueError, IndexError):
- pass
- # Track-Nummer
- track_number = item.get("track_number", 0)
- disc_number = item.get("disc_number", 1)
- # Spotify URI fuer spotdl
- spotify_uri = item.get("uri", f"spotify:track:{track_id}")
- external_url = item.get("external_urls", {}).get("spotify", "")
- metadata = TrackMetadata(
- title=track_name,
- artist=artist_name,
- album=album_name,
- artwork_url=artwork_url,
- duration_ms=duration_ms,
- year=year,
- track_number=track_number,
- disc_number=disc_number,
- )
- track = Track(
- uri=f"spotify:{track_id}",
- source_type="spotify",
- source_id=track_id,
- metadata=metadata,
- state=TrackState.AVAILABLE,
- extra={
- "spotify_uri": spotify_uri,
- "spotify_url": external_url,
- "preview_url": item.get("preview_url", ""),
- "popularity": item.get("popularity", 0),
- "explicit": item.get("explicit", False),
- },
- )
- tracks.append(track)
- return tracks
- async def prepare_track(self, track: Track) -> bool:
- """
- Laedt Audio herunter via spotdl und setzt track.path.
- spotdl sucht den Track auf YouTube und laedt die Audio-Version
- herunter. Das ist der empfohlene Weg — legal, zuverlaessig,
- gute Audioqualitaet.
- Args:
- track: Track zum Vorbereiten
- Returns:
- True wenn erfolgreich
- """
- if not self._spotdl_available:
- perror("SpotifySource: spotdl nicht installiert — Download nicht moeglich")
- return False
- spotify_url = track.extra.get("spotify_url", "")
- if not spotify_url:
- spotify_uri = track.extra.get("spotify_uri", "")
- if spotify_uri:
- # spotify:track:ABC123 → https://open.spotify.com/track/ABC123
- parts = spotify_uri.split(":")
- if len(parts) == 3:
- spotify_url = f"https://open.spotify.com/track/{parts[2]}"
- if not spotify_url:
- perror("SpotifySource: Keine Spotify-URL fuer Download")
- return False
- # Cache pruefen
- cached_path = self._get_cached_path(track.source_id)
- if cached_path:
- track.path = cached_path
- track.state = TrackState.AVAILABLE
- pdebug(f"SpotifySource: Cache-Treffer: {cached_path.name}")
- return True
- # Download via spotdl
- track.state = TrackState.LOADING
- try:
- output_path = await asyncio.to_thread(
- self._download_sync, spotify_url, track.source_id
- )
- if output_path and output_path.exists():
- track.path = output_path
- track.state = TrackState.AVAILABLE
- pinfo(f"SpotifySource: Heruntergeladen: {output_path.name}")
- await self.cleanup_cache()
- return True
- else:
- track.state = TrackState.ERROR
- track.error_message = "Download fehlgeschlagen"
- return False
- except Exception as e:
- perror(f"SpotifySource: Download-Fehler: {e}")
- track.state = TrackState.ERROR
- track.error_message = str(e)
- return False
- def _download_sync(self, spotify_url: str, track_id: str) -> Path | None:
- """Synchroner Download via spotdl (wird in Thread ausgefuehrt)."""
- import subprocess
- output_path = self._cache_dir / f"{track_id}.mp3"
- # spotdl: Download mit Ausgabe-Format und Verzeichnis
- cmd = [
- "spotdl", "download", spotify_url,
- "--output", str(output_path),
- "--format", "mp3",
- "--bitrate", "192k",
- ]
- try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- timeout=120,
- cwd=str(self._cache_dir),
- )
- if result.returncode != 0:
- stderr = result.stderr.decode(errors="replace")
- pdebug(f"SpotifySource: spotdl Fehler: {stderr[:200]}")
- # spotdl benennt die Datei manchmal nach Artist - Title
- if output_path.exists():
- return output_path
- # Fallback: Suche nach neuen Dateien im Cache-Verzeichnis
- for f in sorted(
- self._cache_dir.glob("*.mp3"),
- key=lambda p: p.stat().st_mtime,
- reverse=True,
- ):
- # Neueste Datei die nicht schon als Cache bekannt ist
- target = self._cache_dir / f"{track_id}.mp3"
- if f != target:
- f.rename(target)
- return target
- return f
- except subprocess.TimeoutExpired:
- perror("SpotifySource: spotdl Timeout (120s)")
- except FileNotFoundError:
- perror("SpotifySource: spotdl nicht gefunden")
- except Exception as e:
- perror(f"SpotifySource: Download-Fehler: {e}")
- return None
- def _get_cached_path(self, track_id: str) -> Path | None:
- """Prueft ob eine Datei im Cache liegt."""
- if not self._cache_dir.exists():
- return None
- cached = self._cache_dir / f"{track_id}.mp3"
- return cached if cached.exists() else None
- async def cleanup_cache(self) -> int:
- """
- Entfernt aelteste Dateien wenn Cache zu gross.
- Returns:
- Anzahl entfernter Dateien
- """
- if not self._cache_dir.exists():
- return 0
- return await asyncio.to_thread(self._cleanup_cache_sync)
- def _cleanup_cache_sync(self) -> int:
- """Synchrone Cache-Bereinigung (LRU)."""
- files: list[tuple[Path, float, int]] = []
- total_size = 0
- for f in self._cache_dir.iterdir():
- if f.is_file():
- stat = f.stat()
- files.append((f, stat.st_mtime, stat.st_size))
- total_size += stat.st_size
- max_size = self._max_cache_size_mb * 1024 * 1024
- if total_size <= max_size:
- return 0
- # Aelteste zuerst entfernen
- files.sort(key=lambda x: x[1])
- removed = 0
- for file_path, _, size in files:
- if total_size <= max_size:
- break
- try:
- file_path.unlink()
- total_size -= size
- removed += 1
- except OSError:
- pass
- if removed > 0:
- pdebug(f"SpotifySource: {removed} Cache-Dateien entfernt")
- return removed
|