soundcloud.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. # -*- coding: utf-8 -*-
  2. """
  3. SoundCloud-Quelle via yt-dlp.
  4. yt-dlp unterstuetzt SoundCloud nativ — kein zusaetzliches
  5. Paket noetig. Suche und Download laufen komplett ueber yt-dlp.
  6. Vorteile gegenueber YouTube:
  7. - Viele Indie/DJ/Remix-Tracks die auf YouTube nicht sind
  8. - Hoehere Audioqualitaet (oft 128-320kbps)
  9. - Weniger Werbung/Filler in den Suchergebnissen
  10. Abhaengigkeiten:
  11. - yt-dlp (gleiche wie YouTube — shared dependency)
  12. """
  13. from __future__ import annotations
  14. import asyncio
  15. import re
  16. from pathlib import Path
  17. from typing import AsyncIterator
  18. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
  19. from trixy_core.music.track import Track, TrackMetadata, TrackState
  20. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  21. # Optionale Abhaengigkeit (gleiche wie YouTube)
  22. _YT_DLP_AVAILABLE = False
  23. try:
  24. import yt_dlp
  25. _YT_DLP_AVAILABLE = True
  26. except ImportError:
  27. pass
  28. class SoundCloudSource(MusicSource):
  29. """SoundCloud-Quelle via yt-dlp (optional)."""
  30. def __init__(
  31. self,
  32. name: str = "SoundCloud",
  33. cache_dir: str = "cache/soundcloud",
  34. max_cache_size_mb: int = 500,
  35. max_results: int = 5,
  36. ) -> None:
  37. super().__init__(name=name, source_type=SourceType.URL)
  38. self._cache_dir = Path(cache_dir)
  39. self._max_cache_size_mb = max_cache_size_mb
  40. self._max_results = max_results
  41. async def connect(self) -> bool:
  42. """Prueft ob yt-dlp installiert ist."""
  43. if not _YT_DLP_AVAILABLE:
  44. pwarn("SoundCloudSource: yt-dlp nicht installiert")
  45. self._state = SourceState.UNAVAILABLE
  46. return False
  47. try:
  48. self._cache_dir.mkdir(parents=True, exist_ok=True)
  49. self._state = SourceState.AVAILABLE
  50. pinfo("SoundCloudSource: Verbunden")
  51. return True
  52. except OSError as e:
  53. perror(f"SoundCloudSource: Cache-Verzeichnis Fehler: {e}")
  54. self._state = SourceState.ERROR
  55. return False
  56. async def disconnect(self) -> None:
  57. self._state = SourceState.UNAVAILABLE
  58. async def scan(self) -> int:
  59. return 0
  60. async def get_track(self, track_id: str) -> Track | None:
  61. return self._tracks.get(track_id)
  62. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  63. if not track.path or not track.path.exists():
  64. return
  65. chunk_size = 65536
  66. def read_file():
  67. with open(track.path, "rb") as f:
  68. while True:
  69. chunk = f.read(chunk_size)
  70. if not chunk:
  71. break
  72. yield chunk
  73. for chunk in await asyncio.to_thread(lambda: list(read_file())):
  74. yield chunk
  75. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  76. return track.metadata
  77. async def search(self, query: str, limit: int = 5) -> list[Track]:
  78. """
  79. Sucht auf SoundCloud via yt-dlp.
  80. Nutzt den yt-dlp SoundCloud-Extractor: `scsearch{limit}:query`
  81. """
  82. if not _YT_DLP_AVAILABLE or self._state != SourceState.AVAILABLE:
  83. return []
  84. effective_limit = min(limit, self._max_results)
  85. ydl_opts = {
  86. "quiet": True,
  87. "no_warnings": True,
  88. "extract_flat": False,
  89. "skip_download": True,
  90. "default_search": f"scsearch{effective_limit}",
  91. }
  92. try:
  93. results = await asyncio.to_thread(self._search_sync, query, ydl_opts)
  94. return results
  95. except Exception as e:
  96. perror(f"SoundCloudSource: Suchfehler: {e}")
  97. return []
  98. def _search_sync(self, query: str, ydl_opts: dict) -> list[Track]:
  99. """Synchrone SoundCloud-Suche."""
  100. tracks: list[Track] = []
  101. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  102. info = ydl.extract_info(query, download=False)
  103. entries = info.get("entries", []) if info else []
  104. if not entries and info:
  105. entries = [info]
  106. for entry in entries:
  107. if not entry:
  108. continue
  109. track_id = str(entry.get("id", ""))
  110. title = entry.get("title", "Unbekannt")
  111. duration = entry.get("duration", 0)
  112. uploader = entry.get("uploader", "")
  113. thumbnail = entry.get("thumbnail", "")
  114. webpage_url = entry.get("webpage_url", "")
  115. # Artist/Title: SoundCloud hat oft "Artist - Title" im Titel
  116. artist, track_title = self._parse_title(title, uploader)
  117. metadata = TrackMetadata(
  118. title=track_title,
  119. artist=artist,
  120. duration_ms=int(duration * 1000) if duration else 0,
  121. artwork_url=thumbnail,
  122. genre=entry.get("genre", ""),
  123. )
  124. track = Track(
  125. uri=f"soundcloud:{track_id}",
  126. source_type="soundcloud",
  127. source_id=track_id,
  128. metadata=metadata,
  129. state=TrackState.AVAILABLE,
  130. extra={
  131. "sc_id": track_id,
  132. "url": webpage_url,
  133. "like_count": entry.get("like_count", 0),
  134. "repost_count": entry.get("repost_count", 0),
  135. },
  136. )
  137. tracks.append(track)
  138. return tracks
  139. def _parse_title(self, title: str, uploader: str) -> tuple[str, str]:
  140. """Parst SoundCloud-Titel in Artist und Track-Titel."""
  141. if " - " in title:
  142. parts = title.split(" - ", 1)
  143. return parts[0].strip(), parts[1].strip()
  144. if " | " in title:
  145. parts = title.split(" | ", 1)
  146. return parts[0].strip(), parts[1].strip()
  147. # Suffixe entfernen
  148. cleaned = re.sub(
  149. r'\s*[\(\[\{].*?(official|free|download|remix|bootleg).*?[\)\]\}]',
  150. '', title, flags=re.IGNORECASE
  151. ).strip()
  152. return uploader or "Unbekannt", cleaned or title
  153. async def prepare_track(self, track: Track) -> bool:
  154. """Laedt Audio herunter via yt-dlp."""
  155. if not _YT_DLP_AVAILABLE or self._state != SourceState.AVAILABLE:
  156. return False
  157. sc_url = track.extra.get("url", "")
  158. if not sc_url:
  159. perror("SoundCloudSource: Keine SoundCloud-URL")
  160. return False
  161. # Cache pruefen
  162. cached = self._get_cached_path(track.source_id)
  163. if cached:
  164. track.path = cached
  165. track.state = TrackState.AVAILABLE
  166. pdebug(f"SoundCloudSource: Cache-Treffer: {cached.name}")
  167. return True
  168. track.state = TrackState.LOADING
  169. try:
  170. output_path = await asyncio.to_thread(self._download_sync, sc_url, track.source_id)
  171. if output_path and output_path.exists():
  172. track.path = output_path
  173. track.state = TrackState.AVAILABLE
  174. pinfo(f"SoundCloudSource: Heruntergeladen: {output_path.name}")
  175. await self.cleanup_cache()
  176. return True
  177. else:
  178. track.state = TrackState.ERROR
  179. track.error_message = "Download fehlgeschlagen"
  180. return False
  181. except Exception as e:
  182. perror(f"SoundCloudSource: Download-Fehler: {e}")
  183. track.state = TrackState.ERROR
  184. track.error_message = str(e)
  185. return False
  186. def _download_sync(self, url: str, track_id: str) -> Path | None:
  187. """Synchroner Download via yt-dlp."""
  188. output_template = str(self._cache_dir / f"{track_id}.%(ext)s")
  189. ydl_opts = {
  190. "format": "bestaudio/best",
  191. "outtmpl": output_template,
  192. "quiet": True,
  193. "no_warnings": True,
  194. "postprocessors": [{
  195. "key": "FFmpegExtractAudio",
  196. "preferredcodec": "mp3",
  197. "preferredquality": "192",
  198. }],
  199. }
  200. with yt_dlp.YoutubeDL(ydl_opts) as ydl:
  201. ydl.download([url])
  202. expected = self._cache_dir / f"{track_id}.mp3"
  203. if expected.exists():
  204. return expected
  205. for f in self._cache_dir.iterdir():
  206. if f.stem == track_id and f.is_file():
  207. return f
  208. return None
  209. def _get_cached_path(self, track_id: str) -> Path | None:
  210. if not self._cache_dir.exists():
  211. return None
  212. for f in self._cache_dir.iterdir():
  213. if f.stem == track_id and f.is_file():
  214. return f
  215. return None
  216. async def cleanup_cache(self) -> int:
  217. if not self._cache_dir.exists():
  218. return 0
  219. return await asyncio.to_thread(self._cleanup_cache_sync)
  220. def _cleanup_cache_sync(self) -> int:
  221. files: list[tuple[Path, float, int]] = []
  222. total_size = 0
  223. for f in self._cache_dir.iterdir():
  224. if f.is_file():
  225. stat = f.stat()
  226. files.append((f, stat.st_mtime, stat.st_size))
  227. total_size += stat.st_size
  228. max_size = self._max_cache_size_mb * 1024 * 1024
  229. if total_size <= max_size:
  230. return 0
  231. files.sort(key=lambda x: x[1])
  232. removed = 0
  233. for file_path, _, size in files:
  234. if total_size <= max_size:
  235. break
  236. try:
  237. file_path.unlink()
  238. total_size -= size
  239. removed += 1
  240. except OSError:
  241. pass
  242. if removed > 0:
  243. pdebug(f"SoundCloudSource: {removed} Cache-Dateien entfernt")
  244. return removed