# -*- coding: utf-8 -*- """ SoundCloud-Quelle via yt-dlp. yt-dlp unterstuetzt SoundCloud nativ — kein zusaetzliches Paket noetig. Suche und Download laufen komplett ueber yt-dlp. Vorteile gegenueber YouTube: - Viele Indie/DJ/Remix-Tracks die auf YouTube nicht sind - Hoehere Audioqualitaet (oft 128-320kbps) - Weniger Werbung/Filler in den Suchergebnissen Abhaengigkeiten: - yt-dlp (gleiche wie YouTube — shared dependency) """ from __future__ import annotations import asyncio import re 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 Abhaengigkeit (gleiche wie YouTube) _YT_DLP_AVAILABLE = False try: import yt_dlp _YT_DLP_AVAILABLE = True except ImportError: pass class SoundCloudSource(MusicSource): """SoundCloud-Quelle via yt-dlp (optional).""" def __init__( self, name: str = "SoundCloud", cache_dir: str = "cache/soundcloud", max_cache_size_mb: int = 500, max_results: int = 5, ) -> None: super().__init__(name=name, source_type=SourceType.URL) self._cache_dir = Path(cache_dir) self._max_cache_size_mb = max_cache_size_mb self._max_results = max_results async def connect(self) -> bool: """Prueft ob yt-dlp installiert ist.""" if not _YT_DLP_AVAILABLE: pwarn("SoundCloudSource: yt-dlp nicht installiert") self._state = SourceState.UNAVAILABLE return False try: self._cache_dir.mkdir(parents=True, exist_ok=True) self._state = SourceState.AVAILABLE pinfo("SoundCloudSource: Verbunden") return True except OSError as e: perror(f"SoundCloudSource: Cache-Verzeichnis Fehler: {e}") self._state = SourceState.ERROR return False async def disconnect(self) -> None: self._state = SourceState.UNAVAILABLE async def scan(self) -> int: return 0 async def get_track(self, track_id: str) -> Track | None: return self._tracks.get(track_id) async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]: if not track.path or not track.path.exists(): return chunk_size = 65536 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: return track.metadata async def search(self, query: str, limit: int = 5) -> list[Track]: """ Sucht auf SoundCloud via yt-dlp. Nutzt den yt-dlp SoundCloud-Extractor: `scsearch{limit}:query` """ if not _YT_DLP_AVAILABLE or self._state != SourceState.AVAILABLE: return [] effective_limit = min(limit, self._max_results) ydl_opts = { "quiet": True, "no_warnings": True, "extract_flat": False, "skip_download": True, "default_search": f"scsearch{effective_limit}", } try: results = await asyncio.to_thread(self._search_sync, query, ydl_opts) return results except Exception as e: perror(f"SoundCloudSource: Suchfehler: {e}") return [] def _search_sync(self, query: str, ydl_opts: dict) -> list[Track]: """Synchrone SoundCloud-Suche.""" tracks: list[Track] = [] with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(query, download=False) entries = info.get("entries", []) if info else [] if not entries and info: entries = [info] for entry in entries: if not entry: continue track_id = str(entry.get("id", "")) title = entry.get("title", "Unbekannt") duration = entry.get("duration", 0) uploader = entry.get("uploader", "") thumbnail = entry.get("thumbnail", "") webpage_url = entry.get("webpage_url", "") # Artist/Title: SoundCloud hat oft "Artist - Title" im Titel artist, track_title = self._parse_title(title, uploader) metadata = TrackMetadata( title=track_title, artist=artist, duration_ms=int(duration * 1000) if duration else 0, artwork_url=thumbnail, genre=entry.get("genre", ""), ) track = Track( uri=f"soundcloud:{track_id}", source_type="soundcloud", source_id=track_id, metadata=metadata, state=TrackState.AVAILABLE, extra={ "sc_id": track_id, "url": webpage_url, "like_count": entry.get("like_count", 0), "repost_count": entry.get("repost_count", 0), }, ) tracks.append(track) return tracks def _parse_title(self, title: str, uploader: str) -> tuple[str, str]: """Parst SoundCloud-Titel in Artist und Track-Titel.""" if " - " in title: parts = title.split(" - ", 1) return parts[0].strip(), parts[1].strip() if " | " in title: parts = title.split(" | ", 1) return parts[0].strip(), parts[1].strip() # Suffixe entfernen cleaned = re.sub( r'\s*[\(\[\{].*?(official|free|download|remix|bootleg).*?[\)\]\}]', '', title, flags=re.IGNORECASE ).strip() return uploader or "Unbekannt", cleaned or title async def prepare_track(self, track: Track) -> bool: """Laedt Audio herunter via yt-dlp.""" if not _YT_DLP_AVAILABLE or self._state != SourceState.AVAILABLE: return False sc_url = track.extra.get("url", "") if not sc_url: perror("SoundCloudSource: Keine SoundCloud-URL") return False # Cache pruefen cached = self._get_cached_path(track.source_id) if cached: track.path = cached track.state = TrackState.AVAILABLE pdebug(f"SoundCloudSource: Cache-Treffer: {cached.name}") return True track.state = TrackState.LOADING try: output_path = await asyncio.to_thread(self._download_sync, sc_url, track.source_id) if output_path and output_path.exists(): track.path = output_path track.state = TrackState.AVAILABLE pinfo(f"SoundCloudSource: 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"SoundCloudSource: Download-Fehler: {e}") track.state = TrackState.ERROR track.error_message = str(e) return False def _download_sync(self, url: str, track_id: str) -> Path | None: """Synchroner Download via yt-dlp.""" output_template = str(self._cache_dir / f"{track_id}.%(ext)s") ydl_opts = { "format": "bestaudio/best", "outtmpl": output_template, "quiet": True, "no_warnings": True, "postprocessors": [{ "key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192", }], } with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) expected = self._cache_dir / f"{track_id}.mp3" if expected.exists(): return expected for f in self._cache_dir.iterdir(): if f.stem == track_id and f.is_file(): return f return None def _get_cached_path(self, track_id: str) -> Path | None: if not self._cache_dir.exists(): return None for f in self._cache_dir.iterdir(): if f.stem == track_id and f.is_file(): return f return None async def cleanup_cache(self) -> int: if not self._cache_dir.exists(): return 0 return await asyncio.to_thread(self._cleanup_cache_sync) def _cleanup_cache_sync(self) -> int: 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 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"SoundCloudSource: {removed} Cache-Dateien entfernt") return removed