| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- # -*- 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()
|