radio.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. # -*- coding: utf-8 -*-
  2. """
  3. Radio-Stream-Quelle fuer HTTP Radio (Icecast/Shoutcast).
  4. Spielt HTTP-Audio-Streams ab — kein Download, direktes Streaming.
  5. Unterstuetzt ICY-Metadaten fuer Artist/Titel-Anzeige.
  6. Keine externen Abhaengigkeiten — nutzt nur aiohttp (bereits
  7. im Projekt) fuer HTTP-Requests.
  8. Konfiguration ueber config.json:
  9. "radio": {
  10. "enabled": true,
  11. "favorites": [
  12. {"name": "SWR3", "url": "https://liveradio.swr.de/..."},
  13. {"name": "1LIVE", "url": "https://wdr-1live-live.icecast.wdr.de/..."}
  14. ]
  15. }
  16. """
  17. from __future__ import annotations
  18. import asyncio
  19. import re
  20. from pathlib import Path
  21. from typing import AsyncIterator, Any
  22. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState, SourceInfo
  23. from trixy_core.music.track import Track, TrackMetadata, TrackState
  24. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  25. # Optionale Abhaengigkeit
  26. _AIOHTTP_AVAILABLE = False
  27. try:
  28. import aiohttp
  29. _AIOHTTP_AVAILABLE = True
  30. except ImportError:
  31. pass
  32. # Vordefinierte deutsche Radio-Sender als Fallback
  33. _DEFAULT_STATIONS: list[dict[str, str]] = [
  34. {"name": "SWR3", "url": "https://liveradio.swr.de/sw282p3/swr3/play.mp3", "genre": "Pop"},
  35. {"name": "1LIVE", "url": "https://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3", "genre": "Pop/Rock"},
  36. {"name": "WDR 2", "url": "https://wdr-wdr2-rheinland.icecast.wdr.de/wdr/wdr2/rheinland/mp3/128/stream.mp3", "genre": "Pop"},
  37. {"name": "Bayern 3", "url": "https://streams.br.de/bayern3_2.m3u", "genre": "Pop"},
  38. {"name": "NDR 2", "url": "https://icecast.ndr.de/ndr/ndr2/niedersachsen/mp3/128/stream.mp3", "genre": "Pop/Rock"},
  39. {"name": "Fritz", "url": "https://rbb-fritz-live.cast.addradio.de/rbb/fritz/live/mp3/128/stream.mp3", "genre": "Alternative"},
  40. {"name": "Radio BOB!", "url": "https://streams.radiobob.de/bob-live/mp3-192/mediaplayer", "genre": "Rock"},
  41. {"name": "Klassik Radio", "url": "https://stream.klassikradio.de/live/mp3-192/stream.klassikradio.de/", "genre": "Klassik"},
  42. {"name": "Deutschlandfunk", "url": "https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3", "genre": "Nachrichten"},
  43. {"name": "FluxFM", "url": "https://streams.fluxfm.de/live/mp3-320/audio/", "genre": "Alternative/Indie"},
  44. {"name": "bigFM", "url": "https://streams.bigfm.de/bigfm-deutschland-128-mp3", "genre": "Pop/Dance"},
  45. {"name": "bigFM Charts", "url": "https://streams.bigfm.de/bigfm-charts-128-mp3", "genre": "Charts"},
  46. {"name": "bigFM Nitrox", "url": "https://streams.bigfm.de/bigfm-nitrox-128-mp3", "genre": "Dance/EDM"},
  47. ]
  48. class RadioStation:
  49. """Ein einzelner Radio-Sender."""
  50. def __init__(
  51. self,
  52. name: str,
  53. url: str,
  54. genre: str = "",
  55. country: str = "DE",
  56. description: str = "",
  57. ) -> None:
  58. self.name = name
  59. self.url = url
  60. self.genre = genre
  61. self.country = country
  62. self.description = description
  63. def to_track(self) -> Track:
  64. """Konvertiert den Sender in ein Track-Objekt."""
  65. return Track(
  66. uri=f"radio:{self.name}",
  67. source_type="radio",
  68. source_id=self.name,
  69. metadata=TrackMetadata(
  70. title=self.name,
  71. artist="Live Radio",
  72. genre=self.genre,
  73. duration_ms=0, # Live = keine Dauer
  74. custom={"country": self.country, "stream_url": self.url},
  75. ),
  76. state=TrackState.AVAILABLE,
  77. extra={
  78. "stream_url": self.url,
  79. "is_live": True,
  80. "genre": self.genre,
  81. "country": self.country,
  82. "description": self.description,
  83. },
  84. )
  85. class RadioSource(MusicSource):
  86. """Radio-Stream-Quelle fuer HTTP Radio (Icecast/Shoutcast)."""
  87. def __init__(
  88. self,
  89. name: str = "Radio",
  90. favorites: list[dict[str, str]] | None = None,
  91. include_defaults: bool = True,
  92. buffer_size: int = 16384,
  93. icy_metadata: bool = True,
  94. ) -> None:
  95. """
  96. Initialisiert die Radio-Quelle.
  97. Args:
  98. name: Name der Quelle
  99. favorites: Liste von Favoriten-Sendern [{name, url, genre}]
  100. include_defaults: Standard-Sender hinzufuegen wenn keine Favoriten
  101. buffer_size: Stream-Buffer-Groesse in Bytes
  102. icy_metadata: ICY-Metadaten anfordern (Artist/Titel)
  103. """
  104. super().__init__(name=name, source_type=SourceType.URL)
  105. self._favorites = favorites or []
  106. self._include_defaults = include_defaults
  107. self._buffer_size = buffer_size
  108. self._icy_metadata = icy_metadata
  109. self._stations: list[RadioStation] = []
  110. self._current_metadata: dict[str, str] = {}
  111. @property
  112. def stations(self) -> list[RadioStation]:
  113. return list(self._stations)
  114. @property
  115. def station_count(self) -> int:
  116. return len(self._stations)
  117. @property
  118. def current_metadata(self) -> dict[str, str]:
  119. """Aktueller ICY-Metadaten-Stand (title, artist)."""
  120. return dict(self._current_metadata)
  121. async def connect(self) -> bool:
  122. """Laedt Sender-Liste und prueft Verfuegbarkeit."""
  123. if not _AIOHTTP_AVAILABLE:
  124. pwarn("RadioSource: aiohttp nicht installiert")
  125. self._state = SourceState.UNAVAILABLE
  126. return False
  127. self._stations = []
  128. # Favoriten laden
  129. for fav in self._favorites:
  130. name = fav.get("name", "")
  131. url = fav.get("url", "")
  132. if name and url:
  133. self._stations.append(RadioStation(
  134. name=name,
  135. url=url,
  136. genre=fav.get("genre", ""),
  137. country=fav.get("country", "DE"),
  138. description=fav.get("description", ""),
  139. ))
  140. # Standard-Sender als Fallback
  141. if not self._stations and self._include_defaults:
  142. for default in _DEFAULT_STATIONS:
  143. self._stations.append(RadioStation(
  144. name=default["name"],
  145. url=default["url"],
  146. genre=default.get("genre", ""),
  147. ))
  148. if not self._stations:
  149. pwarn("RadioSource: Keine Sender konfiguriert")
  150. self._state = SourceState.UNAVAILABLE
  151. return False
  152. # Tracks registrieren
  153. for station in self._stations:
  154. self._add_track(station.to_track())
  155. self._state = SourceState.AVAILABLE
  156. pinfo(f"RadioSource: {len(self._stations)} Sender geladen")
  157. return True
  158. async def disconnect(self) -> None:
  159. """Trennt die Verbindung."""
  160. self._stations = []
  161. self._clear_tracks()
  162. self._current_metadata = {}
  163. self._state = SourceState.UNAVAILABLE
  164. async def scan(self) -> int:
  165. """Gibt Anzahl der Sender zurueck."""
  166. return len(self._stations)
  167. async def get_track(self, track_id: str) -> Track | None:
  168. """Holt Track nach ID."""
  169. return self._tracks.get(track_id)
  170. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  171. """Liefert Metadaten eines Tracks."""
  172. return track.metadata
  173. async def search(self, query: str, limit: int = 5) -> list[Track]:
  174. """
  175. Sucht in der Sender-Liste nach passenden Sendern.
  176. Durchsucht Name, Genre und Beschreibung.
  177. Args:
  178. query: Suchstring (z.B. "SWR3", "Rock", "Klassik")
  179. limit: Maximale Ergebnisse
  180. Returns:
  181. Liste passender Sender als Tracks
  182. """
  183. if self._state != SourceState.AVAILABLE:
  184. return []
  185. query_lower = query.lower()
  186. results: list[tuple[int, Track]] = []
  187. for station in self._stations:
  188. score = 0
  189. # Exakter Name-Match (hoechste Prioritaet)
  190. if query_lower == station.name.lower():
  191. score = 100
  192. # Name enthaelt Query
  193. elif query_lower in station.name.lower():
  194. score = 80
  195. # Genre-Match
  196. elif station.genre and query_lower in station.genre.lower():
  197. score = 60
  198. # Beschreibung
  199. elif station.description and query_lower in station.description.lower():
  200. score = 40
  201. # Wort-Teilmatch im Namen
  202. else:
  203. for word in query_lower.split():
  204. if word in station.name.lower():
  205. score = max(score, 30)
  206. if score > 0:
  207. results.append((score, station.to_track()))
  208. # Nach Score sortieren, Limit anwenden
  209. results.sort(key=lambda x: x[0], reverse=True)
  210. return [track for _, track in results[:limit]]
  211. async def prepare_track(self, track: Track) -> bool:
  212. """
  213. Radio-Streams brauchen keine Vorbereitung.
  214. Setzt den Track auf AVAILABLE — das eigentliche Streaming
  215. passiert in get_audio_stream().
  216. """
  217. stream_url = track.extra.get("stream_url", "")
  218. if not stream_url:
  219. track.state = TrackState.ERROR
  220. track.error_message = "Keine Stream-URL"
  221. return False
  222. track.state = TrackState.AVAILABLE
  223. return True
  224. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  225. """
  226. Streamt Audio-Daten direkt vom HTTP-Radio-Server.
  227. Unterstuetzt ICY-Metadaten fuer Live-Titel-Anzeige.
  228. Laeuft endlos bis der Consumer aufhoert zu lesen.
  229. """
  230. if not _AIOHTTP_AVAILABLE:
  231. return
  232. stream_url = track.extra.get("stream_url", "")
  233. if not stream_url:
  234. return
  235. headers = {"User-Agent": "Trixy/1.0"}
  236. if self._icy_metadata:
  237. headers["Icy-MetaData"] = "1"
  238. try:
  239. timeout = aiohttp.ClientTimeout(total=None, connect=10)
  240. async with aiohttp.ClientSession(timeout=timeout) as session:
  241. async with session.get(stream_url, headers=headers) as resp:
  242. if resp.status != 200:
  243. perror(f"RadioSource: HTTP {resp.status} fuer {stream_url}")
  244. return
  245. # ICY-Metadaten-Intervall ermitteln
  246. icy_interval = int(resp.headers.get("icy-metaint", "0"))
  247. track.state = TrackState.PLAYING
  248. if icy_interval > 0 and self._icy_metadata:
  249. async for chunk in self._read_with_icy(
  250. resp, icy_interval, track
  251. ):
  252. yield chunk
  253. else:
  254. async for chunk in resp.content.iter_chunked(
  255. self._buffer_size
  256. ):
  257. yield chunk
  258. except asyncio.CancelledError:
  259. pdebug(f"RadioSource: Stream abgebrochen: {track.metadata.title}")
  260. except Exception as e:
  261. pdebug(f"RadioSource: Stream-Fehler: {e}")
  262. finally:
  263. track.state = TrackState.AVAILABLE
  264. async def _read_with_icy(
  265. self,
  266. resp: Any,
  267. interval: int,
  268. track: Track,
  269. ) -> AsyncIterator[bytes]:
  270. """
  271. Liest HTTP-Stream mit ICY-Metadaten-Extraktion.
  272. ICY-Protokoll: Alle `interval` Bytes kommt ein Metadaten-Block:
  273. - 1 Byte: Laenge * 16
  274. - N Bytes: Metadaten-String (z.B. "StreamTitle='Artist - Title';")
  275. - Danach wieder `interval` Bytes Audio
  276. """
  277. buffer = b""
  278. async for raw_chunk in resp.content.iter_any():
  279. buffer += raw_chunk
  280. while len(buffer) >= interval:
  281. # Audio-Daten bis zum Metadaten-Marker
  282. audio_chunk = buffer[:interval]
  283. buffer = buffer[interval:]
  284. yield audio_chunk
  285. # Metadaten-Block lesen
  286. if len(buffer) < 1:
  287. # Mehr Daten brauchen
  288. break
  289. meta_len = buffer[0] * 16
  290. buffer = buffer[1:]
  291. if meta_len > 0:
  292. if len(buffer) < meta_len:
  293. # Noch nicht genug Daten — warten
  294. # Metadaten-Byte zuruecklegen
  295. buffer = bytes([meta_len // 16]) + buffer
  296. break
  297. meta_bytes = buffer[:meta_len]
  298. buffer = buffer[meta_len:]
  299. # Metadaten parsen
  300. self._parse_icy_metadata(
  301. meta_bytes.decode("utf-8", errors="replace"), track
  302. )
  303. def _parse_icy_metadata(self, meta_str: str, track: Track) -> None:
  304. """
  305. Parst ICY-Metadaten-String.
  306. Format: StreamTitle='Artist - Title';StreamUrl='...';
  307. """
  308. match = re.search(r"StreamTitle='([^']*)'", meta_str)
  309. if not match:
  310. return
  311. stream_title = match.group(1).strip()
  312. if not stream_title:
  313. return
  314. # Artist - Title aufteilen
  315. artist = ""
  316. title = stream_title
  317. if " - " in stream_title:
  318. parts = stream_title.split(" - ", 1)
  319. artist = parts[0].strip()
  320. title = parts[1].strip()
  321. # Metadaten aktualisieren
  322. self._current_metadata = {"artist": artist, "title": title}
  323. # Track-Metadaten live aktualisieren
  324. if title:
  325. track.metadata.title = f"{track.extra.get('station_name', track.metadata.title)}"
  326. track.metadata.custom["now_playing_artist"] = artist
  327. track.metadata.custom["now_playing_title"] = title
  328. pdebug(f"RadioSource: ICY → {artist} - {title}" if artist else f"RadioSource: ICY → {title}")
  329. def get_info(self) -> SourceInfo:
  330. """Gibt Informationen ueber die Radio-Quelle zurueck."""
  331. return SourceInfo(
  332. source_type=self.source_type,
  333. name=self.name,
  334. description=f"{len(self._stations)} Sender konfiguriert",
  335. state=self._state,
  336. track_count=len(self._stations),
  337. supports_search=True,
  338. supports_metadata=True,
  339. supports_artwork=False,
  340. supports_streaming=True,
  341. metadata={
  342. "station_count": len(self._stations),
  343. "stations": [s.name for s in self._stations],
  344. },
  345. )
  346. async def check_stream(self, url: str, timeout: float = 5.0) -> bool:
  347. """
  348. Prueft ob ein Stream-URL erreichbar ist.
  349. Args:
  350. url: Stream-URL
  351. timeout: Timeout in Sekunden
  352. Returns:
  353. True wenn erreichbar
  354. """
  355. if not _AIOHTTP_AVAILABLE:
  356. return False
  357. try:
  358. client_timeout = aiohttp.ClientTimeout(total=timeout)
  359. async with aiohttp.ClientSession(timeout=client_timeout) as session:
  360. async with session.head(url) as resp:
  361. return resp.status == 200
  362. except Exception:
  363. return False