session.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. # -*- coding: utf-8 -*-
  2. """
  3. Stream-Session.
  4. Verwaltet eine einzelne Streaming-Verbindung zu einem Satellite.
  5. """
  6. import asyncio
  7. import logging
  8. import time
  9. import uuid
  10. from collections import deque
  11. from dataclasses import dataclass, field
  12. from datetime import datetime
  13. from enum import Enum
  14. from typing import Any, Callable, Awaitable
  15. from trixy_core.music.stream.config import StreamConfig
  16. from trixy_core.music.track import Track
  17. class StreamState(Enum):
  18. """Zustand einer Stream-Session."""
  19. IDLE = "idle" # Keine aktive Session
  20. CONNECTING = "connecting" # Verbindung wird aufgebaut
  21. BUFFERING = "buffering" # Puffert Daten
  22. STREAMING = "streaming" # Aktives Streaming
  23. PAUSED = "paused" # Pausiert
  24. SEEKING = "seeking" # Sucht neue Position
  25. ERROR = "error" # Fehler
  26. DISCONNECTED = "disconnected" # Verbindung getrennt
  27. @dataclass
  28. class StreamStats:
  29. """Statistiken einer Stream-Session."""
  30. bytes_sent: int = 0
  31. packets_sent: int = 0
  32. packets_lost: int = 0
  33. buffer_underruns: int = 0
  34. buffer_overruns: int = 0
  35. # Timing
  36. start_time: datetime | None = None
  37. total_duration_ms: int = 0
  38. current_position_ms: int = 0
  39. # Latenz
  40. min_latency_ms: float = 0.0
  41. max_latency_ms: float = 0.0
  42. avg_latency_ms: float = 0.0
  43. _latency_samples: list[float] = field(default_factory=list)
  44. def add_latency_sample(self, latency_ms: float) -> None:
  45. """Fügt Latenz-Messwert hinzu."""
  46. self._latency_samples.append(latency_ms)
  47. # Nur letzte 100 Samples behalten
  48. if len(self._latency_samples) > 100:
  49. self._latency_samples.pop(0)
  50. # Statistiken aktualisieren
  51. self.min_latency_ms = min(self._latency_samples)
  52. self.max_latency_ms = max(self._latency_samples)
  53. self.avg_latency_ms = sum(self._latency_samples) / len(self._latency_samples)
  54. @property
  55. def loss_rate(self) -> float:
  56. """Paketverlust-Rate."""
  57. total = self.packets_sent + self.packets_lost
  58. return self.packets_lost / total if total > 0 else 0.0
  59. def to_dict(self) -> dict[str, Any]:
  60. """Konvertiert zu Dictionary."""
  61. return {
  62. "bytes_sent": self.bytes_sent,
  63. "packets_sent": self.packets_sent,
  64. "packets_lost": self.packets_lost,
  65. "buffer_underruns": self.buffer_underruns,
  66. "loss_rate": self.loss_rate,
  67. "current_position_ms": self.current_position_ms,
  68. "latency": {
  69. "min_ms": self.min_latency_ms,
  70. "max_ms": self.max_latency_ms,
  71. "avg_ms": self.avg_latency_ms,
  72. },
  73. }
  74. class StreamSession:
  75. """
  76. Eine Streaming-Session zu einem Satellite.
  77. Verwaltet Pufferung, Timing und Synchronisation.
  78. """
  79. def __init__(
  80. self,
  81. satellite_id: str,
  82. config: StreamConfig | None = None,
  83. send_callback: Callable[[bytes], Awaitable[bool]] | None = None,
  84. ) -> None:
  85. """
  86. Initialisiert die Stream-Session.
  87. Args:
  88. satellite_id: ID des Ziel-Satellites
  89. config: Stream-Konfiguration
  90. send_callback: Async Callback zum Senden von Daten
  91. """
  92. self.id = f"session_{uuid.uuid4().hex[:8]}"
  93. self.satellite_id = satellite_id
  94. self.config = config or StreamConfig.for_music()
  95. self._send_callback = send_callback
  96. self.logger = logging.getLogger(__name__)
  97. # Zustand
  98. self._state = StreamState.IDLE
  99. self._error_message = ""
  100. # Aktueller Track
  101. self._current_track: Track | None = None
  102. # Puffer
  103. self._buffer: deque[bytes] = deque()
  104. self._buffer_size = 0
  105. self._buffer_lock = asyncio.Lock()
  106. # Timing
  107. self._start_time: float = 0.0
  108. self._position_ms: int = 0
  109. self._last_sync_time: float = 0.0
  110. # Statistiken
  111. self.stats = StreamStats()
  112. # Streaming-Task
  113. self._stream_task: asyncio.Task | None = None
  114. self._stop_event = asyncio.Event()
  115. # ==========================================================================
  116. # Properties
  117. # ==========================================================================
  118. @property
  119. def state(self) -> StreamState:
  120. """Aktueller Zustand."""
  121. return self._state
  122. @property
  123. def is_active(self) -> bool:
  124. """Prüft ob Session aktiv ist."""
  125. return self._state in (StreamState.BUFFERING, StreamState.STREAMING)
  126. @property
  127. def is_streaming(self) -> bool:
  128. """Prüft ob aktiv gestreamt wird."""
  129. return self._state == StreamState.STREAMING
  130. @property
  131. def current_track(self) -> Track | None:
  132. """Aktueller Track."""
  133. return self._current_track
  134. @property
  135. def position_ms(self) -> int:
  136. """Aktuelle Position in Millisekunden."""
  137. return self._position_ms
  138. @property
  139. def buffer_level(self) -> float:
  140. """Puffer-Füllstand (0.0 - 1.0)."""
  141. max_buffer = self.config.max_buffer_ms * self.config.bytes_per_second // 1000
  142. return min(1.0, self._buffer_size / max_buffer) if max_buffer > 0 else 0.0
  143. @property
  144. def buffer_ms(self) -> int:
  145. """Puffer-Inhalt in Millisekunden."""
  146. return int(self._buffer_size * 1000 / self.config.bytes_per_second)
  147. # ==========================================================================
  148. # Lifecycle
  149. # ==========================================================================
  150. async def start(self, track: Track) -> bool:
  151. """
  152. Startet Streaming für einen Track.
  153. Args:
  154. track: Zu streamender Track
  155. Returns:
  156. True wenn erfolgreich gestartet
  157. """
  158. if self._state not in (StreamState.IDLE, StreamState.DISCONNECTED, StreamState.ERROR):
  159. await self.stop()
  160. self._current_track = track
  161. self._state = StreamState.CONNECTING
  162. self._stop_event.clear()
  163. self.logger.debug(f"Session {self.id}: Starte Streaming für {track.title}")
  164. try:
  165. self._state = StreamState.BUFFERING
  166. self.stats = StreamStats()
  167. self.stats.start_time = datetime.now()
  168. self.stats.total_duration_ms = track.duration_ms
  169. # Streaming-Task starten
  170. self._stream_task = asyncio.create_task(self._streaming_loop())
  171. return True
  172. except Exception as e:
  173. self.logger.error(f"Session {self.id}: Start fehlgeschlagen: {e}")
  174. self._state = StreamState.ERROR
  175. self._error_message = str(e)
  176. return False
  177. async def stop(self) -> None:
  178. """Stoppt das Streaming."""
  179. self.logger.debug(f"Session {self.id}: Stoppe Streaming")
  180. self._stop_event.set()
  181. if self._stream_task and not self._stream_task.done():
  182. self._stream_task.cancel()
  183. try:
  184. await self._stream_task
  185. except asyncio.CancelledError:
  186. pass
  187. # Puffer leeren
  188. async with self._buffer_lock:
  189. self._buffer.clear()
  190. self._buffer_size = 0
  191. self._state = StreamState.IDLE
  192. self._current_track = None
  193. async def pause(self) -> None:
  194. """Pausiert das Streaming."""
  195. if self._state == StreamState.STREAMING:
  196. self._state = StreamState.PAUSED
  197. self.logger.debug(f"Session {self.id}: Pausiert")
  198. async def resume(self) -> None:
  199. """Setzt das Streaming fort."""
  200. if self._state == StreamState.PAUSED:
  201. self._state = StreamState.STREAMING
  202. self.logger.debug(f"Session {self.id}: Fortgesetzt")
  203. async def seek(self, position_ms: int) -> bool:
  204. """
  205. Springt zu einer Position.
  206. Args:
  207. position_ms: Ziel-Position in Millisekunden
  208. Returns:
  209. True wenn erfolgreich
  210. """
  211. if not self._current_track:
  212. return False
  213. self._state = StreamState.SEEKING
  214. # Puffer leeren
  215. async with self._buffer_lock:
  216. self._buffer.clear()
  217. self._buffer_size = 0
  218. self._position_ms = position_ms
  219. self._state = StreamState.BUFFERING
  220. self.logger.debug(f"Session {self.id}: Seek zu {position_ms}ms")
  221. return True
  222. # ==========================================================================
  223. # Puffer-Management
  224. # ==========================================================================
  225. async def feed(self, data: bytes) -> bool:
  226. """
  227. Füttert den Puffer mit Audio-Daten.
  228. Args:
  229. data: PCM-Audio-Daten
  230. Returns:
  231. True wenn akzeptiert
  232. """
  233. async with self._buffer_lock:
  234. # Puffer-Limit prüfen
  235. max_bytes = self.config.max_buffer_ms * self.config.bytes_per_second // 1000
  236. if self._buffer_size + len(data) > max_bytes:
  237. self.stats.buffer_overruns += 1
  238. return False
  239. self._buffer.append(data)
  240. self._buffer_size += len(data)
  241. # Zustand aktualisieren
  242. if self._state == StreamState.BUFFERING:
  243. if self._buffer_size >= self.config.prebuffer_bytes:
  244. self._state = StreamState.STREAMING
  245. self._start_time = time.monotonic()
  246. return True
  247. async def _get_chunk(self) -> bytes | None:
  248. """Holt Chunk aus Puffer."""
  249. async with self._buffer_lock:
  250. if not self._buffer:
  251. return None
  252. chunk = self._buffer.popleft()
  253. self._buffer_size -= len(chunk)
  254. return chunk
  255. # ==========================================================================
  256. # Streaming
  257. # ==========================================================================
  258. async def _streaming_loop(self) -> None:
  259. """Haupt-Streaming-Loop."""
  260. interval = self.config.send_interval_ms / 1000.0
  261. while not self._stop_event.is_set():
  262. try:
  263. # Auf aktiven Zustand warten
  264. if self._state == StreamState.PAUSED:
  265. await asyncio.sleep(0.1)
  266. continue
  267. if self._state == StreamState.BUFFERING:
  268. await asyncio.sleep(0.01)
  269. continue
  270. if self._state != StreamState.STREAMING:
  271. await asyncio.sleep(0.1)
  272. continue
  273. # Chunk holen
  274. chunk = await self._get_chunk()
  275. if chunk is None:
  276. # Buffer Underrun
  277. self.stats.buffer_underruns += 1
  278. self._state = StreamState.BUFFERING
  279. self.logger.debug(f"Session {self.id}: Buffer Underrun")
  280. continue
  281. # Senden
  282. if self._send_callback:
  283. send_start = time.monotonic()
  284. success = await self._send_callback(chunk)
  285. send_duration = (time.monotonic() - send_start) * 1000
  286. if success:
  287. self.stats.packets_sent += 1
  288. self.stats.bytes_sent += len(chunk)
  289. self.stats.add_latency_sample(send_duration)
  290. else:
  291. self.stats.packets_lost += 1
  292. # Position aktualisieren
  293. chunk_duration_ms = int(
  294. len(chunk) * 1000 / self.config.bytes_per_second
  295. )
  296. self._position_ms += chunk_duration_ms
  297. self.stats.current_position_ms = self._position_ms
  298. # Timing
  299. await asyncio.sleep(interval)
  300. except asyncio.CancelledError:
  301. break
  302. except Exception as e:
  303. self.logger.error(f"Session {self.id}: Streaming-Fehler: {e}")
  304. self._state = StreamState.ERROR
  305. self._error_message = str(e)
  306. break
  307. # ==========================================================================
  308. # Synchronisation
  309. # ==========================================================================
  310. async def sync(self, client_time_ms: int) -> dict[str, Any]:
  311. """
  312. Synchronisiert mit Client.
  313. Args:
  314. client_time_ms: Client-Zeit in Millisekunden
  315. Returns:
  316. Sync-Informationen
  317. """
  318. server_time_ms = self._position_ms
  319. drift_ms = server_time_ms - client_time_ms
  320. self._last_sync_time = time.monotonic()
  321. return {
  322. "server_time_ms": server_time_ms,
  323. "client_time_ms": client_time_ms,
  324. "drift_ms": drift_ms,
  325. "should_skip": drift_ms > self.config.max_drift_ms,
  326. "should_wait": drift_ms < -self.config.max_drift_ms,
  327. }
  328. # ==========================================================================
  329. # Status
  330. # ==========================================================================
  331. def get_status(self) -> dict[str, Any]:
  332. """Liefert Session-Status."""
  333. return {
  334. "id": self.id,
  335. "satellite_id": self.satellite_id,
  336. "state": self._state.value,
  337. "track": self._current_track.to_dict() if self._current_track else None,
  338. "position_ms": self._position_ms,
  339. "buffer_level": self.buffer_level,
  340. "buffer_ms": self.buffer_ms,
  341. "stats": self.stats.to_dict(),
  342. "error": self._error_message if self._state == StreamState.ERROR else None,
  343. }
  344. def __repr__(self) -> str:
  345. return (
  346. f"StreamSession(id={self.id!r}, "
  347. f"satellite={self.satellite_id!r}, "
  348. f"state={self._state.value!r})"
  349. )