service.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. # -*- coding: utf-8 -*-
  2. """
  3. Music Player Service.
  4. Stellt den MusicPlayer als zentralen Service bereit.
  5. Reagiert auf Events für Play/Pause/Next/Stop.
  6. """
  7. import asyncio
  8. from pathlib import Path
  9. from typing import TYPE_CHECKING, Any
  10. from trixy_core.service import IService, ServicePriority, ServiceGroup
  11. from trixy_core.music.player import MusicPlayer, PlayerConfig, PlayerState, PlaybackEvent
  12. from trixy_core.music.playlist import Playlist
  13. from trixy_core.music.track import Track
  14. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  15. if TYPE_CHECKING:
  16. from trixy_core.application import IApplication
  17. class MusicPlayerService(IService):
  18. """
  19. Zentraler Music Player Service.
  20. Bietet:
  21. - Playlist-Management
  22. - Wiedergabe-Steuerung über Events
  23. - Streaming an verbundene Satellites
  24. """
  25. NAME = "music_player"
  26. PRIORITY = ServicePriority.OPTIONAL
  27. GROUP = ServiceGroup.AUDIO
  28. DEPENDENCIES = ["network"] # Nur network als Dependency
  29. def __init__(self, application: "IApplication") -> None:
  30. """
  31. Initialisiert den MusicPlayerService.
  32. Args:
  33. application: Referenz zur Hauptanwendung
  34. """
  35. super().__init__(application)
  36. self._player: MusicPlayer | None = None
  37. self._current_playlist: Playlist | None = None
  38. self._registered_events = False
  39. @property
  40. def player(self) -> MusicPlayer | None:
  41. """Der MusicPlayer."""
  42. return self._player
  43. @property
  44. def is_playing(self) -> bool:
  45. """Prüft ob Musik abgespielt wird."""
  46. return self._player.is_playing if self._player else False
  47. @property
  48. def is_paused(self) -> bool:
  49. """Prüft ob Musik pausiert ist."""
  50. return self._player.is_paused if self._player else False
  51. @property
  52. def current_track(self) -> Track | None:
  53. """Aktueller Track."""
  54. return self._player.current_track if self._player else None
  55. async def on_pre_start(self) -> None:
  56. """Initialisiert den MusicPlayer."""
  57. # Player-Config aus Server-Config
  58. audio_config = self._application.server_config.audio
  59. config = PlayerConfig(
  60. sample_rate=audio_config.music_sample_rate,
  61. channels=audio_config.music_channels,
  62. bit_depth=audio_config.music_bit_depth,
  63. crossfade_ms=5000, # 5 Sekunden Crossfade
  64. auto_play_next=True,
  65. )
  66. self._player = MusicPlayer(config=config)
  67. # Playback-Events registrieren
  68. self._player.on_event(self._on_playback_event)
  69. pdebug("MusicPlayerService initialisiert")
  70. async def on_post_start(self) -> None:
  71. """Registriert Event-Handler."""
  72. await self._register_events()
  73. async def on_pre_stop(self) -> None:
  74. """Stoppt die Wiedergabe und räumt auf."""
  75. if self._player:
  76. await self._player.stop()
  77. await self._unregister_events()
  78. async def start(self) -> None:
  79. """Startet den MusicPlayerService."""
  80. pdebug("MusicPlayerService gestartet")
  81. async def stop(self) -> None:
  82. """Stoppt den MusicPlayerService."""
  83. if self._player:
  84. await self._player.stop()
  85. pdebug("MusicPlayerService gestoppt")
  86. async def health_check(self) -> bool:
  87. """Prüft ob der Service gesund ist."""
  88. return self._player is not None
  89. def get_health_details(self) -> dict[str, Any]:
  90. """Liefert detaillierten Health-Status."""
  91. return {
  92. "state": self._player.state.value if self._player else "not_initialized",
  93. "is_playing": self.is_playing,
  94. "current_track": self._player.current_track.title if self._player and self._player.current_track else None,
  95. "queue_count": self._player.queue.count if self._player else 0,
  96. }
  97. # ==========================================================================
  98. # Event-Registrierung
  99. # ==========================================================================
  100. async def _register_events(self) -> None:
  101. """Registriert Event-Handler für Musik-Steuerung."""
  102. if self._registered_events:
  103. return
  104. events = self._application.events
  105. # Musik-Steuerung
  106. events.register("music_play_pause", self._on_music_play_pause)
  107. events.register("music_next", self._on_music_next)
  108. events.register("music_previous", self._on_music_previous)
  109. events.register("music_stop", self._on_music_stop)
  110. events.register("music_paused", self._on_music_paused)
  111. events.register("music_resumed", self._on_music_resumed)
  112. events.register("media_stop_all", self._on_media_stop_all)
  113. events.register("music_volume_change", self._on_music_volume_change)
  114. # Satellite-Verbindung (reagiert auf Music-Socket, nicht Command-Socket)
  115. events.register("music_socket_connected", self._on_music_socket_connected)
  116. events.register("satellite_disconnected", self._on_satellite_disconnected)
  117. self._registered_events = True
  118. pdebug("MusicPlayerService: Events registriert")
  119. async def _unregister_events(self) -> None:
  120. """Deregistriert Event-Handler."""
  121. if not self._registered_events:
  122. return
  123. events = self._application.events
  124. events.unregister("music_play_pause", self._on_music_play_pause)
  125. events.unregister("music_next", self._on_music_next)
  126. events.unregister("music_previous", self._on_music_previous)
  127. events.unregister("music_stop", self._on_music_stop)
  128. events.unregister("music_paused", self._on_music_paused)
  129. events.unregister("music_resumed", self._on_music_resumed)
  130. events.unregister("media_stop_all", self._on_media_stop_all)
  131. events.unregister("music_volume_change", self._on_music_volume_change)
  132. events.unregister("music_socket_connected", self._on_music_socket_connected)
  133. events.unregister("satellite_disconnected", self._on_satellite_disconnected)
  134. self._registered_events = False
  135. # ==========================================================================
  136. # Event-Handler
  137. # ==========================================================================
  138. async def _on_music_play_pause(self, event_name: str, data: Any) -> None:
  139. """Toggle Play/Pause."""
  140. if not self._player:
  141. return
  142. await self._player.toggle_pause()
  143. state = "pausiert" if self._player.is_paused else "läuft"
  144. pdebug(f"MusicPlayer: {state}")
  145. async def _on_music_next(self, event_name: str, data: Any) -> None:
  146. """Nächster Track."""
  147. if not self._player:
  148. return
  149. track = await self._player.next()
  150. if track:
  151. pinfo(f"MusicPlayer: Nächster Track -> {track.title}")
  152. else:
  153. pinfo("MusicPlayer: Keine weiteren Tracks")
  154. async def _on_music_previous(self, event_name: str, data: Any) -> None:
  155. """Vorheriger Track."""
  156. if not self._player:
  157. return
  158. track = await self._player.previous()
  159. if track:
  160. pinfo(f"MusicPlayer: Vorheriger Track -> {track.title}")
  161. async def _on_music_stop(self, event_name: str, data: Any) -> None:
  162. """Stoppt die Wiedergabe."""
  163. if not self._player:
  164. return
  165. await self._player.stop()
  166. pinfo("MusicPlayer: Gestoppt")
  167. async def _on_media_stop_all(self, event_name: str, data: Any) -> None:
  168. """Stoppt alle Medien-Wiedergabe (Musik + TTS)."""
  169. if not self._player:
  170. return
  171. await self._player.stop()
  172. pinfo("MusicPlayer: Alle Medien gestoppt")
  173. # TTS-Stop Event weiterleiten
  174. satellite_id = data.get("satellite_id") if isinstance(data, dict) else getattr(data, "satellite_id", None)
  175. if satellite_id:
  176. await self._application.events.emit("tts_stop", {"satellite_id": satellite_id})
  177. async def _on_music_paused(self, event_name: str, data: Any) -> None:
  178. """Pausiert die Wiedergabe."""
  179. if not self._player or self._player.is_paused:
  180. return
  181. await self._player.pause()
  182. async def _on_music_resumed(self, event_name: str, data: Any) -> None:
  183. """Setzt die Wiedergabe fort."""
  184. if not self._player or not self._player.is_paused:
  185. return
  186. await self._player.resume()
  187. async def _on_music_volume_change(self, event_name: str, data: Any) -> None:
  188. """Verarbeitet Lautstärke-Änderungen."""
  189. if not self._player:
  190. return
  191. direction = data.get("direction") if isinstance(data, dict) else getattr(data, "direction", None)
  192. if direction == "up":
  193. amount = (data.get("amount", 10) if isinstance(data, dict) else 10) / 100.0
  194. await self._player.set_volume(min(1.0, self._player.volume + amount))
  195. elif direction == "down":
  196. amount = (data.get("amount", 10) if isinstance(data, dict) else 10) / 100.0
  197. await self._player.set_volume(max(0.0, self._player.volume - amount))
  198. elif direction == "set":
  199. level = (data.get("level", 50) if isinstance(data, dict) else 50) / 100.0
  200. await self._player.set_volume(level)
  201. elif direction == "mute":
  202. await self._player.mute()
  203. elif direction == "unmute":
  204. await self._player.unmute()
  205. async def _on_music_socket_connected(self, event_name: str, data: Any) -> None:
  206. """Fügt Satellite als Streaming-Ziel hinzu wenn Music-Socket bereit."""
  207. pinfo(f"MusicPlayerService: music_socket_connected Event empfangen")
  208. if not self._player:
  209. pwarn("MusicPlayerService: Player nicht initialisiert")
  210. return
  211. satellite_id = data.get("satellite_id") if isinstance(data, dict) else getattr(data, "satellite_id", None)
  212. if not satellite_id:
  213. pwarn("MusicPlayerService: Keine satellite_id in Event-Daten")
  214. return
  215. pdebug(f"MusicPlayerService: Satellite verbunden: {satellite_id}")
  216. # Satellite finden und Streaming-Callback erstellen
  217. satellite = self._application.satellites.get(satellite_id)
  218. if not satellite:
  219. pwarn(f"MusicPlayerService: Satellite {satellite_id} nicht gefunden")
  220. return
  221. # Prüfen ob Musik-Socket verfügbar
  222. if not satellite.sockets.music_out:
  223. pwarn(f"MusicPlayerService: Kein Musik-Socket für {satellite_id}")
  224. return
  225. pdebug(f"MusicPlayerService: Musik-Socket verfügbar für {satellite_id}")
  226. async def send_to_satellite(audio_data: bytes) -> bool:
  227. """Sendet Audio-Daten an den Satellite."""
  228. music_socket = satellite.sockets.music_out
  229. if not music_socket:
  230. return False
  231. try:
  232. music_socket.write(audio_data)
  233. await music_socket.drain()
  234. return True
  235. except Exception as e:
  236. pdebug(f"MusicPlayer: Fehler beim Senden an {satellite_id}: {e}")
  237. return False
  238. self._player.add_satellite(satellite_id, send_to_satellite)
  239. pinfo(f"MusicPlayer: Satellite hinzugefügt: {satellite_id}")
  240. # DEBUG: Auto-Start der Assets-Playlist (auskommentiert - funktioniert, für spätere Referenz)
  241. # if not self._player.is_playing and not self._player.is_paused:
  242. # pdebug("MusicPlayerService: Starte Assets-Playlist")
  243. # await self._start_assets_playlist(satellite_id)
  244. # else:
  245. # pdebug(f"MusicPlayerService: Player läuft bereits (playing={self._player.is_playing}, paused={self._player.is_paused})")
  246. async def _on_satellite_disconnected(self, event_name: str, data: Any) -> None:
  247. """Entfernt Satellite als Streaming-Ziel."""
  248. if not self._player:
  249. return
  250. satellite_id = data.get("satellite_id") if isinstance(data, dict) else getattr(data, "satellite_id", None)
  251. if satellite_id:
  252. await self._player.remove_satellite(satellite_id)
  253. pdebug(f"MusicPlayer: Satellite entfernt: {satellite_id}")
  254. async def _on_playback_event(self, event: PlaybackEvent, data: dict) -> None:
  255. """Verarbeitet Playback-Events vom MusicPlayer."""
  256. # Leite Events an das Event-System weiter
  257. event_mapping = {
  258. PlaybackEvent.TRACK_STARTED: "music_track_started",
  259. PlaybackEvent.TRACK_ENDED: "music_track_ended",
  260. PlaybackEvent.TRACK_CHANGED: "music_track_changed",
  261. PlaybackEvent.STATE_CHANGED: "music_state_changed",
  262. }
  263. event_name = event_mapping.get(event)
  264. if event_name:
  265. # emit() für dict-Daten verwenden (konvertiert automatisch zu EventData)
  266. await self._application.events.emit(event_name, data)
  267. # ==========================================================================
  268. # Playlist-Management
  269. # ==========================================================================
  270. async def _start_assets_playlist(self, satellite_id: str) -> bool:
  271. """
  272. Erstellt Playlist aus Assets-Musik und startet Wiedergabe.
  273. Args:
  274. satellite_id: Ziel-Satellite
  275. Returns:
  276. True wenn erfolgreich
  277. """
  278. try:
  279. # Musik-Dateien aus Assets laden
  280. music_files = await self._get_music_files()
  281. if not music_files:
  282. pwarn("Keine Musik-Dateien im Assets-Ordner gefunden")
  283. return False
  284. # Playlist erstellen
  285. playlist = Playlist(name="Assets Music")
  286. for music_path in music_files:
  287. track = self._create_track_from_file(music_path)
  288. if track:
  289. playlist.add(track)
  290. if playlist.is_empty:
  291. pwarn("Keine gültigen Tracks in Assets gefunden")
  292. return False
  293. self._current_playlist = playlist
  294. pinfo(f"MusicPlayer: Starte Playlist mit {len(playlist)} Tracks")
  295. # Wiedergabe starten
  296. await self._player.play(
  297. playlist=playlist,
  298. satellite_ids=[satellite_id],
  299. )
  300. return True
  301. except Exception as e:
  302. perror(f"Fehler beim Erstellen der Assets-Playlist: {e}")
  303. return False
  304. def _create_track_from_file(self, file_path: Path) -> Track | None:
  305. """
  306. Erstellt einen Track aus einer Datei.
  307. Args:
  308. file_path: Pfad zur Musik-Datei
  309. Returns:
  310. Track oder None bei Fehler
  311. """
  312. from trixy_core.music.track import TrackMetadata, TrackState
  313. try:
  314. # Dateiname als Titel verwenden
  315. filename = file_path.stem
  316. # Versuche Artist - Title Format zu parsen
  317. if " - " in filename:
  318. parts = filename.split(" - ", 1)
  319. artist = parts[0].strip()
  320. title = parts[1].strip()
  321. else:
  322. artist = "Unbekannt"
  323. title = filename
  324. # Dauer aus Audio-Datei lesen
  325. duration_ms = self._get_audio_duration(file_path)
  326. # Metadata erstellen
  327. metadata = TrackMetadata(
  328. title=title,
  329. artist=artist,
  330. format=file_path.suffix.lower().lstrip("."),
  331. duration_ms=duration_ms,
  332. )
  333. # Track erstellen
  334. track = Track(
  335. source_type="local",
  336. source_id=str(file_path),
  337. path=file_path,
  338. metadata=metadata,
  339. state=TrackState.AVAILABLE,
  340. )
  341. pdebug(f"Track erstellt: '{title}' ({duration_ms}ms)")
  342. return track
  343. except Exception as e:
  344. pdebug(f"Fehler beim Erstellen des Tracks für {file_path}: {e}")
  345. return None
  346. def _get_audio_duration(self, file_path: Path) -> int:
  347. """
  348. Liest die Dauer einer Audio-Datei.
  349. Args:
  350. file_path: Pfad zur Audio-Datei
  351. Returns:
  352. Dauer in Millisekunden (0 bei Fehler)
  353. """
  354. try:
  355. suffix = file_path.suffix.lower()
  356. if suffix == ".mp3":
  357. import mutagen.mp3
  358. audio = mutagen.mp3.MP3(file_path)
  359. return int(audio.info.length * 1000)
  360. elif suffix == ".ogg":
  361. try:
  362. import mutagen.oggvorbis
  363. audio = mutagen.oggvorbis.OggVorbis(file_path)
  364. return int(audio.info.length * 1000)
  365. except Exception:
  366. import mutagen.oggopus
  367. audio = mutagen.oggopus.OggOpus(file_path)
  368. return int(audio.info.length * 1000)
  369. elif suffix == ".flac":
  370. import mutagen.flac
  371. audio = mutagen.flac.FLAC(file_path)
  372. return int(audio.info.length * 1000)
  373. elif suffix == ".m4a":
  374. import mutagen.mp4
  375. audio = mutagen.mp4.MP4(file_path)
  376. return int(audio.info.length * 1000)
  377. elif suffix == ".wav":
  378. import wave
  379. with wave.open(str(file_path), 'rb') as wav:
  380. frames = wav.getnframes()
  381. rate = wav.getframerate()
  382. return int((frames / rate) * 1000)
  383. except Exception as e:
  384. pdebug(f"Konnte Dauer nicht lesen für {file_path}: {e}")
  385. return 0
  386. async def _get_music_files(self) -> list[Path]:
  387. """
  388. Sucht alle Musik-Dateien im Assets-Ordner.
  389. Returns:
  390. Liste von Pfaden zu Musik-Dateien
  391. """
  392. music_files = []
  393. assets_dir = Path(self._application.server_config.assets_directory)
  394. profile = self._application.server_config.profile
  395. # Suchpfade
  396. search_dirs = [
  397. assets_dir / profile / "music",
  398. assets_dir / "default" / "music",
  399. ]
  400. # Unterstützte Formate
  401. supported_formats = {".mp3", ".wav", ".ogg", ".flac", ".m4a"}
  402. for search_dir in search_dirs:
  403. if not search_dir.exists():
  404. continue
  405. for file_path in search_dir.iterdir():
  406. if file_path.is_file() and file_path.suffix.lower() in supported_formats:
  407. # Duplikate vermeiden (wenn in beiden Ordnern vorhanden)
  408. if not any(f.name == file_path.name for f in music_files):
  409. music_files.append(file_path)
  410. # Nach Name sortieren
  411. music_files.sort(key=lambda p: p.name.lower())
  412. pdebug(f"MusicPlayer: {len(music_files)} Musik-Dateien gefunden")
  413. return music_files
  414. # ==========================================================================
  415. # Öffentliche API
  416. # ==========================================================================
  417. async def play_playlist(
  418. self,
  419. playlist: Playlist,
  420. satellite_ids: list[str] | None = None,
  421. ) -> bool:
  422. """
  423. Spielt eine Playlist ab.
  424. Args:
  425. playlist: Die Playlist
  426. satellite_ids: Ziel-Satellites (None = alle verbundenen)
  427. Returns:
  428. True wenn erfolgreich
  429. """
  430. if not self._player:
  431. return False
  432. self._current_playlist = playlist
  433. # Wenn keine Satellites angegeben, alle verbundenen verwenden
  434. if satellite_ids is None:
  435. satellite_ids = [
  436. sat.id for sat in self._application.satellites.all()
  437. if sat.is_connected and sat.sockets.music_out
  438. ]
  439. return await self._player.play(playlist=playlist, satellite_ids=satellite_ids)
  440. async def play_track(
  441. self,
  442. track: Track,
  443. satellite_ids: list[str] | None = None,
  444. ) -> bool:
  445. """
  446. Spielt einen einzelnen Track ab.
  447. Args:
  448. track: Der Track
  449. satellite_ids: Ziel-Satellites
  450. Returns:
  451. True wenn erfolgreich
  452. """
  453. if not self._player:
  454. return False
  455. if satellite_ids is None:
  456. satellite_ids = [
  457. sat.id for sat in self._application.satellites.all()
  458. if sat.is_connected and sat.sockets.music_out
  459. ]
  460. return await self._player.play(track=track, satellite_ids=satellite_ids)
  461. def get_status(self) -> dict[str, Any]:
  462. """Liefert den Player-Status."""
  463. if not self._player:
  464. return {"state": "not_initialized"}
  465. return self._player.get_status()