# -*- coding: utf-8 -*- """ Music Player Service. Stellt den MusicPlayer als zentralen Service bereit. Reagiert auf Events für Play/Pause/Next/Stop. """ import asyncio from pathlib import Path from typing import TYPE_CHECKING, Any from trixy_core.service import IService, ServicePriority, ServiceGroup from trixy_core.music.player import MusicPlayer, PlayerConfig, PlayerState, PlaybackEvent from trixy_core.music.playlist import Playlist from trixy_core.music.track import Track from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn if TYPE_CHECKING: from trixy_core.application import IApplication class MusicPlayerService(IService): """ Zentraler Music Player Service. Bietet: - Playlist-Management - Wiedergabe-Steuerung über Events - Streaming an verbundene Satellites """ NAME = "music_player" PRIORITY = ServicePriority.OPTIONAL GROUP = ServiceGroup.AUDIO DEPENDENCIES = ["network"] # Nur network als Dependency def __init__(self, application: "IApplication") -> None: """ Initialisiert den MusicPlayerService. Args: application: Referenz zur Hauptanwendung """ super().__init__(application) self._player: MusicPlayer | None = None self._current_playlist: Playlist | None = None self._registered_events = False @property def player(self) -> MusicPlayer | None: """Der MusicPlayer.""" return self._player @property def is_playing(self) -> bool: """Prüft ob Musik abgespielt wird.""" return self._player.is_playing if self._player else False @property def is_paused(self) -> bool: """Prüft ob Musik pausiert ist.""" return self._player.is_paused if self._player else False @property def current_track(self) -> Track | None: """Aktueller Track.""" return self._player.current_track if self._player else None async def on_pre_start(self) -> None: """Initialisiert den MusicPlayer.""" # Player-Config aus Server-Config audio_config = self._application.server_config.audio config = PlayerConfig( sample_rate=audio_config.music_sample_rate, channels=audio_config.music_channels, bit_depth=audio_config.music_bit_depth, crossfade_ms=5000, # 5 Sekunden Crossfade auto_play_next=True, ) self._player = MusicPlayer(config=config) # Playback-Events registrieren self._player.on_event(self._on_playback_event) pdebug("MusicPlayerService initialisiert") async def on_post_start(self) -> None: """Registriert Event-Handler.""" await self._register_events() async def on_pre_stop(self) -> None: """Stoppt die Wiedergabe und räumt auf.""" if self._player: await self._player.stop() await self._unregister_events() async def start(self) -> None: """Startet den MusicPlayerService.""" pdebug("MusicPlayerService gestartet") async def stop(self) -> None: """Stoppt den MusicPlayerService.""" if self._player: await self._player.stop() pdebug("MusicPlayerService gestoppt") async def health_check(self) -> bool: """Prüft ob der Service gesund ist.""" return self._player is not None def get_health_details(self) -> dict[str, Any]: """Liefert detaillierten Health-Status.""" return { "state": self._player.state.value if self._player else "not_initialized", "is_playing": self.is_playing, "current_track": self._player.current_track.title if self._player and self._player.current_track else None, "queue_count": self._player.queue.count if self._player else 0, } # ========================================================================== # Event-Registrierung # ========================================================================== async def _register_events(self) -> None: """Registriert Event-Handler für Musik-Steuerung.""" if self._registered_events: return events = self._application.events # Musik-Steuerung events.register("music_play_pause", self._on_music_play_pause) events.register("music_next", self._on_music_next) events.register("music_previous", self._on_music_previous) events.register("music_stop", self._on_music_stop) events.register("music_paused", self._on_music_paused) events.register("music_resumed", self._on_music_resumed) events.register("media_stop_all", self._on_media_stop_all) events.register("music_volume_change", self._on_music_volume_change) # Satellite-Verbindung (reagiert auf Music-Socket, nicht Command-Socket) events.register("music_socket_connected", self._on_music_socket_connected) events.register("satellite_disconnected", self._on_satellite_disconnected) self._registered_events = True pdebug("MusicPlayerService: Events registriert") async def _unregister_events(self) -> None: """Deregistriert Event-Handler.""" if not self._registered_events: return events = self._application.events events.unregister("music_play_pause", self._on_music_play_pause) events.unregister("music_next", self._on_music_next) events.unregister("music_previous", self._on_music_previous) events.unregister("music_stop", self._on_music_stop) events.unregister("music_paused", self._on_music_paused) events.unregister("music_resumed", self._on_music_resumed) events.unregister("media_stop_all", self._on_media_stop_all) events.unregister("music_volume_change", self._on_music_volume_change) events.unregister("music_socket_connected", self._on_music_socket_connected) events.unregister("satellite_disconnected", self._on_satellite_disconnected) self._registered_events = False # ========================================================================== # Event-Handler # ========================================================================== async def _on_music_play_pause(self, event_name: str, data: Any) -> None: """Toggle Play/Pause.""" if not self._player: return await self._player.toggle_pause() state = "pausiert" if self._player.is_paused else "läuft" pdebug(f"MusicPlayer: {state}") async def _on_music_next(self, event_name: str, data: Any) -> None: """Nächster Track.""" if not self._player: return track = await self._player.next() if track: pinfo(f"MusicPlayer: Nächster Track -> {track.title}") else: pinfo("MusicPlayer: Keine weiteren Tracks") async def _on_music_previous(self, event_name: str, data: Any) -> None: """Vorheriger Track.""" if not self._player: return track = await self._player.previous() if track: pinfo(f"MusicPlayer: Vorheriger Track -> {track.title}") async def _on_music_stop(self, event_name: str, data: Any) -> None: """Stoppt die Wiedergabe.""" if not self._player: return await self._player.stop() pinfo("MusicPlayer: Gestoppt") async def _on_media_stop_all(self, event_name: str, data: Any) -> None: """Stoppt alle Medien-Wiedergabe (Musik + TTS).""" if not self._player: return await self._player.stop() pinfo("MusicPlayer: Alle Medien gestoppt") # TTS-Stop Event weiterleiten satellite_id = data.get("satellite_id") if isinstance(data, dict) else getattr(data, "satellite_id", None) if satellite_id: await self._application.events.emit("tts_stop", {"satellite_id": satellite_id}) async def _on_music_paused(self, event_name: str, data: Any) -> None: """Pausiert die Wiedergabe.""" if not self._player or self._player.is_paused: return await self._player.pause() async def _on_music_resumed(self, event_name: str, data: Any) -> None: """Setzt die Wiedergabe fort.""" if not self._player or not self._player.is_paused: return await self._player.resume() async def _on_music_volume_change(self, event_name: str, data: Any) -> None: """Verarbeitet Lautstärke-Änderungen.""" if not self._player: return direction = data.get("direction") if isinstance(data, dict) else getattr(data, "direction", None) if direction == "up": amount = (data.get("amount", 10) if isinstance(data, dict) else 10) / 100.0 await self._player.set_volume(min(1.0, self._player.volume + amount)) elif direction == "down": amount = (data.get("amount", 10) if isinstance(data, dict) else 10) / 100.0 await self._player.set_volume(max(0.0, self._player.volume - amount)) elif direction == "set": level = (data.get("level", 50) if isinstance(data, dict) else 50) / 100.0 await self._player.set_volume(level) elif direction == "mute": await self._player.mute() elif direction == "unmute": await self._player.unmute() async def _on_music_socket_connected(self, event_name: str, data: Any) -> None: """Fügt Satellite als Streaming-Ziel hinzu wenn Music-Socket bereit.""" pinfo(f"MusicPlayerService: music_socket_connected Event empfangen") if not self._player: pwarn("MusicPlayerService: Player nicht initialisiert") return satellite_id = data.get("satellite_id") if isinstance(data, dict) else getattr(data, "satellite_id", None) if not satellite_id: pwarn("MusicPlayerService: Keine satellite_id in Event-Daten") return pdebug(f"MusicPlayerService: Satellite verbunden: {satellite_id}") # Satellite finden und Streaming-Callback erstellen satellite = self._application.satellites.get(satellite_id) if not satellite: pwarn(f"MusicPlayerService: Satellite {satellite_id} nicht gefunden") return # Prüfen ob Musik-Socket verfügbar if not satellite.sockets.music_out: pwarn(f"MusicPlayerService: Kein Musik-Socket für {satellite_id}") return pdebug(f"MusicPlayerService: Musik-Socket verfügbar für {satellite_id}") async def send_to_satellite(audio_data: bytes) -> bool: """Sendet Audio-Daten an den Satellite.""" music_socket = satellite.sockets.music_out if not music_socket: return False try: music_socket.write(audio_data) await music_socket.drain() return True except Exception as e: pdebug(f"MusicPlayer: Fehler beim Senden an {satellite_id}: {e}") return False self._player.add_satellite(satellite_id, send_to_satellite) pinfo(f"MusicPlayer: Satellite hinzugefügt: {satellite_id}") # DEBUG: Auto-Start der Assets-Playlist (auskommentiert - funktioniert, für spätere Referenz) # if not self._player.is_playing and not self._player.is_paused: # pdebug("MusicPlayerService: Starte Assets-Playlist") # await self._start_assets_playlist(satellite_id) # else: # pdebug(f"MusicPlayerService: Player läuft bereits (playing={self._player.is_playing}, paused={self._player.is_paused})") async def _on_satellite_disconnected(self, event_name: str, data: Any) -> None: """Entfernt Satellite als Streaming-Ziel.""" if not self._player: return satellite_id = data.get("satellite_id") if isinstance(data, dict) else getattr(data, "satellite_id", None) if satellite_id: await self._player.remove_satellite(satellite_id) pdebug(f"MusicPlayer: Satellite entfernt: {satellite_id}") async def _on_playback_event(self, event: PlaybackEvent, data: dict) -> None: """Verarbeitet Playback-Events vom MusicPlayer.""" # Leite Events an das Event-System weiter event_mapping = { PlaybackEvent.TRACK_STARTED: "music_track_started", PlaybackEvent.TRACK_ENDED: "music_track_ended", PlaybackEvent.TRACK_CHANGED: "music_track_changed", PlaybackEvent.STATE_CHANGED: "music_state_changed", } event_name = event_mapping.get(event) if event_name: # emit() für dict-Daten verwenden (konvertiert automatisch zu EventData) await self._application.events.emit(event_name, data) # ========================================================================== # Playlist-Management # ========================================================================== async def _start_assets_playlist(self, satellite_id: str) -> bool: """ Erstellt Playlist aus Assets-Musik und startet Wiedergabe. Args: satellite_id: Ziel-Satellite Returns: True wenn erfolgreich """ try: # Musik-Dateien aus Assets laden music_files = await self._get_music_files() if not music_files: pwarn("Keine Musik-Dateien im Assets-Ordner gefunden") return False # Playlist erstellen playlist = Playlist(name="Assets Music") for music_path in music_files: track = self._create_track_from_file(music_path) if track: playlist.add(track) if playlist.is_empty: pwarn("Keine gültigen Tracks in Assets gefunden") return False self._current_playlist = playlist pinfo(f"MusicPlayer: Starte Playlist mit {len(playlist)} Tracks") # Wiedergabe starten await self._player.play( playlist=playlist, satellite_ids=[satellite_id], ) return True except Exception as e: perror(f"Fehler beim Erstellen der Assets-Playlist: {e}") return False def _create_track_from_file(self, file_path: Path) -> Track | None: """ Erstellt einen Track aus einer Datei. Args: file_path: Pfad zur Musik-Datei Returns: Track oder None bei Fehler """ from trixy_core.music.track import TrackMetadata, TrackState try: # Dateiname als Titel verwenden filename = file_path.stem # Versuche Artist - Title Format zu parsen if " - " in filename: parts = filename.split(" - ", 1) artist = parts[0].strip() title = parts[1].strip() else: artist = "Unbekannt" title = filename # Dauer aus Audio-Datei lesen duration_ms = self._get_audio_duration(file_path) # Metadata erstellen metadata = TrackMetadata( title=title, artist=artist, format=file_path.suffix.lower().lstrip("."), duration_ms=duration_ms, ) # Track erstellen track = Track( source_type="local", source_id=str(file_path), path=file_path, metadata=metadata, state=TrackState.AVAILABLE, ) pdebug(f"Track erstellt: '{title}' ({duration_ms}ms)") return track except Exception as e: pdebug(f"Fehler beim Erstellen des Tracks für {file_path}: {e}") return None def _get_audio_duration(self, file_path: Path) -> int: """ Liest die Dauer einer Audio-Datei. Args: file_path: Pfad zur Audio-Datei Returns: Dauer in Millisekunden (0 bei Fehler) """ try: suffix = file_path.suffix.lower() if suffix == ".mp3": import mutagen.mp3 audio = mutagen.mp3.MP3(file_path) return int(audio.info.length * 1000) elif suffix == ".ogg": try: import mutagen.oggvorbis audio = mutagen.oggvorbis.OggVorbis(file_path) return int(audio.info.length * 1000) except Exception: import mutagen.oggopus audio = mutagen.oggopus.OggOpus(file_path) return int(audio.info.length * 1000) elif suffix == ".flac": import mutagen.flac audio = mutagen.flac.FLAC(file_path) return int(audio.info.length * 1000) elif suffix == ".m4a": import mutagen.mp4 audio = mutagen.mp4.MP4(file_path) return int(audio.info.length * 1000) elif suffix == ".wav": import wave with wave.open(str(file_path), 'rb') as wav: frames = wav.getnframes() rate = wav.getframerate() return int((frames / rate) * 1000) except Exception as e: pdebug(f"Konnte Dauer nicht lesen für {file_path}: {e}") return 0 async def _get_music_files(self) -> list[Path]: """ Sucht alle Musik-Dateien im Assets-Ordner. Returns: Liste von Pfaden zu Musik-Dateien """ music_files = [] assets_dir = Path(self._application.server_config.assets_directory) profile = self._application.server_config.profile # Suchpfade search_dirs = [ assets_dir / profile / "music", assets_dir / "default" / "music", ] # Unterstützte Formate supported_formats = {".mp3", ".wav", ".ogg", ".flac", ".m4a"} for search_dir in search_dirs: if not search_dir.exists(): continue for file_path in search_dir.iterdir(): if file_path.is_file() and file_path.suffix.lower() in supported_formats: # Duplikate vermeiden (wenn in beiden Ordnern vorhanden) if not any(f.name == file_path.name for f in music_files): music_files.append(file_path) # Nach Name sortieren music_files.sort(key=lambda p: p.name.lower()) pdebug(f"MusicPlayer: {len(music_files)} Musik-Dateien gefunden") return music_files # ========================================================================== # Öffentliche API # ========================================================================== async def play_playlist( self, playlist: Playlist, satellite_ids: list[str] | None = None, ) -> bool: """ Spielt eine Playlist ab. Args: playlist: Die Playlist satellite_ids: Ziel-Satellites (None = alle verbundenen) Returns: True wenn erfolgreich """ if not self._player: return False self._current_playlist = playlist # Wenn keine Satellites angegeben, alle verbundenen verwenden if satellite_ids is None: satellite_ids = [ sat.id for sat in self._application.satellites.all() if sat.is_connected and sat.sockets.music_out ] return await self._player.play(playlist=playlist, satellite_ids=satellite_ids) async def play_track( self, track: Track, satellite_ids: list[str] | None = None, ) -> bool: """ Spielt einen einzelnen Track ab. Args: track: Der Track satellite_ids: Ziel-Satellites Returns: True wenn erfolgreich """ if not self._player: return False if satellite_ids is None: satellite_ids = [ sat.id for sat in self._application.satellites.all() if sat.is_connected and sat.sockets.music_out ] return await self._player.play(track=track, satellite_ids=satellite_ids) def get_status(self) -> dict[str, Any]: """Liefert den Player-Status.""" if not self._player: return {"state": "not_initialized"} return self._player.get_status()