url.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. # -*- coding: utf-8 -*-
  2. """
  3. URL-Streams als Musikquelle.
  4. Unterstützt HTTP/HTTPS Audio-URLs und Internet-Radio-Streams.
  5. """
  6. import asyncio
  7. import logging
  8. import re
  9. from dataclasses import dataclass
  10. from pathlib import Path
  11. from typing import AsyncIterator
  12. from urllib.parse import urlparse
  13. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
  14. from trixy_core.music.track import Track, TrackMetadata, TrackState
  15. @dataclass
  16. class URLTrackInfo:
  17. """Informationen für URL-basierte Tracks."""
  18. url: str
  19. name: str = ""
  20. description: str = ""
  21. content_type: str = ""
  22. content_length: int = 0
  23. is_stream: bool = False # True für Live-Streams (z.B. Radio)
  24. class URLSource(MusicSource):
  25. """
  26. URL-basierte Musikquelle.
  27. Unterstützt:
  28. - Direkte Audio-URLs (MP3, OGG, etc.)
  29. - Internet-Radio-Streams
  30. - Podcast-URLs
  31. """
  32. # Unterstützte Content-Types
  33. SUPPORTED_CONTENT_TYPES = {
  34. "audio/mpeg": "mp3",
  35. "audio/mp3": "mp3",
  36. "audio/ogg": "ogg",
  37. "audio/vorbis": "ogg",
  38. "audio/opus": "opus",
  39. "audio/flac": "flac",
  40. "audio/wav": "wav",
  41. "audio/x-wav": "wav",
  42. "audio/aac": "aac",
  43. "audio/mp4": "m4a",
  44. "audio/x-m4a": "m4a",
  45. "application/ogg": "ogg",
  46. "audio/x-mpegurl": "m3u",
  47. "application/x-mpegURL": "m3u",
  48. "audio/x-scpls": "pls",
  49. }
  50. def __init__(
  51. self,
  52. name: str = "URL-Streams",
  53. timeout: float = 30.0,
  54. chunk_size: int = 65536,
  55. ) -> None:
  56. """
  57. Initialisiert die URL-Quelle.
  58. Args:
  59. name: Name der Quelle
  60. timeout: Timeout für HTTP-Anfragen (Sekunden)
  61. chunk_size: Chunk-Größe für Streaming (Bytes)
  62. """
  63. super().__init__(name=name, source_type=SourceType.URL)
  64. self.timeout = timeout
  65. self.chunk_size = chunk_size
  66. self.logger = logging.getLogger(__name__)
  67. # Registrierte URLs
  68. self._urls: dict[str, URLTrackInfo] = {}
  69. # ==========================================================================
  70. # URL-Management
  71. # ==========================================================================
  72. async def add_url(
  73. self,
  74. url: str,
  75. name: str = "",
  76. verify: bool = True,
  77. ) -> Track | None:
  78. """
  79. Fügt eine URL als Track hinzu.
  80. Args:
  81. url: Audio-URL
  82. name: Optionaler Name
  83. verify: URL validieren
  84. Returns:
  85. Erstellter Track oder None bei Fehler
  86. """
  87. if verify:
  88. info = await self._verify_url(url)
  89. if not info:
  90. return None
  91. else:
  92. info = URLTrackInfo(url=url, name=name or self._extract_name(url))
  93. if not info.name:
  94. info.name = name or self._extract_name(url)
  95. # Track erstellen
  96. metadata = TrackMetadata(
  97. title=info.name,
  98. format=info.content_type or "stream",
  99. )
  100. if info.content_length > 0:
  101. # Geschätzte Dauer (grob: 128kbps)
  102. metadata.duration_ms = int((info.content_length / 16000) * 1000)
  103. track = Track(
  104. uri=url,
  105. source_type="url",
  106. source_id=url,
  107. metadata=metadata,
  108. state=TrackState.AVAILABLE,
  109. )
  110. self._add_track(track)
  111. self._urls[track.id] = info
  112. self.logger.debug(f"URL hinzugefügt: {info.name}")
  113. return track
  114. async def add_urls(self, urls: list[str]) -> list[Track]:
  115. """
  116. Fügt mehrere URLs hinzu.
  117. Args:
  118. urls: Liste von Audio-URLs
  119. Returns:
  120. Liste der erstellten Tracks
  121. """
  122. tracks = []
  123. for url in urls:
  124. track = await self.add_url(url, verify=True)
  125. if track:
  126. tracks.append(track)
  127. return tracks
  128. def remove_url(self, track_id: str) -> bool:
  129. """
  130. Entfernt eine URL.
  131. Args:
  132. track_id: Track-ID
  133. Returns:
  134. True wenn entfernt
  135. """
  136. if track_id in self._tracks:
  137. self._remove_track(track_id)
  138. self._urls.pop(track_id, None)
  139. return True
  140. return False
  141. async def _verify_url(self, url: str) -> URLTrackInfo | None:
  142. """
  143. Verifiziert eine URL und holt Metadaten.
  144. Args:
  145. url: Zu prüfende URL
  146. Returns:
  147. URL-Info oder None bei Fehler
  148. """
  149. try:
  150. import aiohttp
  151. async with aiohttp.ClientSession() as session:
  152. async with session.head(
  153. url,
  154. timeout=aiohttp.ClientTimeout(total=self.timeout),
  155. allow_redirects=True,
  156. ) as response:
  157. if response.status != 200:
  158. self.logger.warning(f"URL nicht erreichbar: {url} ({response.status})")
  159. return None
  160. content_type = response.headers.get("Content-Type", "").split(";")[0].strip()
  161. content_length = int(response.headers.get("Content-Length", 0))
  162. # Prüfen ob unterstützter Content-Type
  163. if content_type not in self.SUPPORTED_CONTENT_TYPES:
  164. # Versuche Dateiendung
  165. parsed = urlparse(url)
  166. ext = Path(parsed.path).suffix.lower()
  167. if ext not in {".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac"}:
  168. self.logger.warning(f"Nicht unterstützter Content-Type: {content_type}")
  169. return None
  170. # Stream erkennen (kein Content-Length = wahrscheinlich Stream)
  171. is_stream = content_length == 0 and "icy" in response.headers.get("Server", "").lower()
  172. return URLTrackInfo(
  173. url=url,
  174. name=self._extract_name(url),
  175. content_type=self.SUPPORTED_CONTENT_TYPES.get(content_type, ""),
  176. content_length=content_length,
  177. is_stream=is_stream,
  178. )
  179. except ImportError:
  180. self.logger.error("aiohttp nicht installiert")
  181. except asyncio.TimeoutError:
  182. self.logger.warning(f"Timeout bei URL-Prüfung: {url}")
  183. except Exception as e:
  184. self.logger.error(f"URL-Prüfung fehlgeschlagen: {e}")
  185. return None
  186. def _extract_name(self, url: str) -> str:
  187. """Extrahiert Namen aus URL."""
  188. parsed = urlparse(url)
  189. path = Path(parsed.path)
  190. if path.stem:
  191. # Dateiname ohne Extension
  192. name = path.stem
  193. # Underscores/Bindestriche durch Leerzeichen ersetzen
  194. name = re.sub(r"[-_]", " ", name)
  195. return name.title()
  196. # Hostname als Fallback
  197. return parsed.netloc or "Unbekannte URL"
  198. # ==========================================================================
  199. # MusicSource Implementation
  200. # ==========================================================================
  201. async def connect(self) -> bool:
  202. """Verbindet zur Quelle (keine echte Verbindung nötig)."""
  203. self._state = SourceState.AVAILABLE
  204. return True
  205. async def disconnect(self) -> None:
  206. """Trennt die Verbindung."""
  207. self._state = SourceState.UNAVAILABLE
  208. async def scan(self) -> int:
  209. """
  210. Scannt ist bei URL-Quelle nicht sinnvoll.
  211. Verifiziert stattdessen vorhandene URLs.
  212. Returns:
  213. Anzahl der verfügbaren Tracks
  214. """
  215. # Alle URLs verifizieren
  216. to_remove = []
  217. for track_id, info in self._urls.items():
  218. verified = await self._verify_url(info.url)
  219. if not verified:
  220. to_remove.append(track_id)
  221. else:
  222. # Track-Zustand aktualisieren
  223. track = self._tracks.get(track_id)
  224. if track:
  225. track.state = TrackState.AVAILABLE
  226. # Nicht mehr erreichbare entfernen
  227. for track_id in to_remove:
  228. self._remove_track(track_id)
  229. self._urls.pop(track_id, None)
  230. self._state = SourceState.AVAILABLE
  231. return self.track_count
  232. async def get_track(self, track_id: str) -> Track | None:
  233. """Holt Track nach ID."""
  234. return self._tracks.get(track_id)
  235. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  236. """
  237. Liefert Audio-Daten als Stream.
  238. Args:
  239. track: Track zum Streamen
  240. Yields:
  241. Audio-Daten in Chunks
  242. """
  243. url = track.uri
  244. try:
  245. import aiohttp
  246. async with aiohttp.ClientSession() as session:
  247. async with session.get(
  248. url,
  249. timeout=aiohttp.ClientTimeout(
  250. total=None, # Kein Gesamt-Timeout für Streams
  251. connect=self.timeout,
  252. sock_read=self.timeout,
  253. ),
  254. ) as response:
  255. if response.status != 200:
  256. self.logger.error(f"Stream-Fehler: {response.status}")
  257. return
  258. async for chunk in response.content.iter_chunked(self.chunk_size):
  259. yield chunk
  260. except ImportError:
  261. self.logger.error("aiohttp nicht installiert")
  262. except asyncio.TimeoutError:
  263. self.logger.warning(f"Stream-Timeout: {url}")
  264. except asyncio.CancelledError:
  265. self.logger.debug(f"Stream abgebrochen: {url}")
  266. except Exception as e:
  267. self.logger.error(f"Stream-Fehler: {e}")
  268. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  269. """
  270. Lädt Metadaten für einen Track.
  271. Bei URL-Streams begrenzt, da keine ID3-Tags verfügbar.
  272. """
  273. info = self._urls.get(track.id)
  274. if not info:
  275. return track.metadata
  276. # ICY-Metadaten für Radio-Streams abrufen
  277. if info.is_stream:
  278. icy_meta = await self._get_icy_metadata(track.uri)
  279. if icy_meta:
  280. # Titel aus StreamTitle parsen
  281. title = icy_meta.get("StreamTitle", "")
  282. if " - " in title:
  283. artist, song = title.split(" - ", 1)
  284. track.metadata.artist = artist.strip()
  285. track.metadata.title = song.strip()
  286. else:
  287. track.metadata.title = title
  288. return track.metadata
  289. async def _get_icy_metadata(self, url: str) -> dict | None:
  290. """
  291. Holt ICY-Metadaten von Radio-Streams.
  292. Args:
  293. url: Stream-URL
  294. Returns:
  295. Metadaten-Dict oder None
  296. """
  297. try:
  298. import aiohttp
  299. headers = {"Icy-MetaData": "1"}
  300. async with aiohttp.ClientSession() as session:
  301. async with session.get(
  302. url,
  303. headers=headers,
  304. timeout=aiohttp.ClientTimeout(total=5),
  305. ) as response:
  306. # ICY-Metadaten aus Header
  307. icy_name = response.headers.get("icy-name", "")
  308. icy_genre = response.headers.get("icy-genre", "")
  309. icy_url = response.headers.get("icy-url", "")
  310. return {
  311. "name": icy_name,
  312. "genre": icy_genre,
  313. "url": icy_url,
  314. }
  315. except Exception:
  316. pass
  317. return None
  318. # ==========================================================================
  319. # Playlist-Support
  320. # ==========================================================================
  321. async def load_playlist(self, url: str) -> list[Track]:
  322. """
  323. Lädt Playlist von URL (M3U, PLS).
  324. Args:
  325. url: Playlist-URL
  326. Returns:
  327. Liste der Tracks
  328. """
  329. tracks = []
  330. try:
  331. import aiohttp
  332. async with aiohttp.ClientSession() as session:
  333. async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.timeout)) as response:
  334. if response.status != 200:
  335. return tracks
  336. content = await response.text()
  337. urls = self._parse_playlist(content, url)
  338. for stream_url in urls:
  339. track = await self.add_url(stream_url, verify=True)
  340. if track:
  341. tracks.append(track)
  342. except Exception as e:
  343. self.logger.error(f"Playlist-Laden fehlgeschlagen: {e}")
  344. return tracks
  345. def _parse_playlist(self, content: str, base_url: str) -> list[str]:
  346. """
  347. Parst Playlist-Inhalt.
  348. Args:
  349. content: Playlist-Inhalt
  350. base_url: Basis-URL für relative Pfade
  351. Returns:
  352. Liste von Stream-URLs
  353. """
  354. urls = []
  355. lines = content.strip().split("\n")
  356. for line in lines:
  357. line = line.strip()
  358. # Kommentare und leere Zeilen überspringen
  359. if not line or line.startswith("#"):
  360. continue
  361. # PLS-Format: File1=http://...
  362. if "=" in line and line.split("=")[0].startswith("File"):
  363. url = line.split("=", 1)[1].strip()
  364. urls.append(url)
  365. elif line.startswith("http://") or line.startswith("https://"):
  366. urls.append(line)
  367. return urls
  368. def get_info(self):
  369. """Liefert Informationen."""
  370. info = super().get_info()
  371. info.description = f"{self.track_count} URL-Streams"
  372. info.supports_streaming = True
  373. return info