# -*- coding: utf-8 -*- """ Source-Manager. Verwaltet mehrere Musikquellen und bietet einheitlichen Zugriff. """ import asyncio import logging from typing import Any, AsyncIterator from trixy_core.music.sources.base import MusicSource, SourceType, SourceState, SourceInfo from trixy_core.music.track import Track, TrackMetadata class SourceManager: """ Verwaltet mehrere Musikquellen. Bietet einheitlichen Zugriff auf alle registrierten Quellen: - Lokale Dateien - USB-Speicher - URL-Streams - (zukünftig) Streaming-Dienste """ def __init__(self) -> None: """Initialisiert den SourceManager.""" self._sources: dict[str, MusicSource] = {} self._default_source: str = "" self.logger = logging.getLogger(__name__) # ========================================================================== # Properties # ========================================================================== @property def sources(self) -> list[MusicSource]: """Alle registrierten Quellen.""" return list(self._sources.values()) @property def source_count(self) -> int: """Anzahl der Quellen.""" return len(self._sources) @property def total_track_count(self) -> int: """Gesamtzahl der Tracks über alle Quellen.""" return sum(source.track_count for source in self._sources.values()) @property def default_source(self) -> MusicSource | None: """Standard-Quelle.""" return self._sources.get(self._default_source) # ========================================================================== # Quellen-Management # ========================================================================== def register( self, source: MusicSource, name: str = "", set_default: bool = False, ) -> str: """ Registriert eine Musikquelle. Args: source: Musikquelle name: Optionaler eindeutiger Name set_default: Als Standard-Quelle setzen Returns: Registrierter Name """ # Name generieren falls nicht angegeben if not name: name = f"{source.source_type.value}_{len(self._sources)}" # Eindeutigkeit prüfen if name in self._sources: i = 1 while f"{name}_{i}" in self._sources: i += 1 name = f"{name}_{i}" self._sources[name] = source self.logger.debug(f"Quelle registriert: {name} ({source.source_type.value})") if set_default or not self._default_source: self._default_source = name return name def unregister(self, name: str) -> bool: """ Entfernt eine Musikquelle. Args: name: Quellen-Name Returns: True wenn entfernt """ if name in self._sources: del self._sources[name] if self._default_source == name: self._default_source = next(iter(self._sources), "") self.logger.debug(f"Quelle entfernt: {name}") return True return False def get(self, name: str) -> MusicSource | None: """ Holt Quelle nach Name. Args: name: Quellen-Name Returns: Quelle oder None """ return self._sources.get(name) def get_by_type(self, source_type: SourceType) -> list[MusicSource]: """ Holt alle Quellen eines Typs. Args: source_type: Quellen-Typ Returns: Liste der Quellen """ return [s for s in self._sources.values() if s.source_type == source_type] def set_default(self, name: str) -> bool: """ Setzt Standard-Quelle. Args: name: Quellen-Name Returns: True wenn gesetzt """ if name in self._sources: self._default_source = name return True return False # ========================================================================== # Lifecycle # ========================================================================== async def connect_all(self) -> dict[str, bool]: """ Verbindet alle Quellen. Returns: Dict Name → Erfolg """ results = {} tasks = { name: source.connect() for name, source in self._sources.items() } for name, task in tasks.items(): try: results[name] = await task except Exception as e: self.logger.error(f"Verbindung zu {name} fehlgeschlagen: {e}") results[name] = False self.logger.info(f"Quellen verbunden: {sum(results.values())}/{len(results)}") return results async def disconnect_all(self) -> None: """Trennt alle Quellen.""" for source in self._sources.values(): try: await source.disconnect() except Exception as e: self.logger.error(f"Trennen fehlgeschlagen: {e}") async def scan_all(self) -> dict[str, int]: """ Scannt alle Quellen. Returns: Dict Name → Anzahl Tracks """ results = {} # Parallel scannen async def scan_source(name: str, source: MusicSource) -> tuple[str, int]: try: count = await source.scan() return (name, count) except Exception as e: self.logger.error(f"Scan von {name} fehlgeschlagen: {e}") return (name, 0) tasks = [ scan_source(name, source) for name, source in self._sources.items() ] for coro in asyncio.as_completed(tasks): name, count = await coro results[name] = count total = sum(results.values()) self.logger.info(f"Scan abgeschlossen: {total} Tracks gesamt") return results # ========================================================================== # Track-Zugriff # ========================================================================== async def get_track(self, track_id: str) -> Track | None: """ Sucht Track in allen Quellen. Args: track_id: Track-ID Returns: Track oder None """ for source in self._sources.values(): track = await source.get_track(track_id) if track: return track return None async def get_track_from_source( self, source_name: str, track_id: str, ) -> Track | None: """ Holt Track von bestimmter Quelle. Args: source_name: Quellen-Name track_id: Track-ID Returns: Track oder None """ source = self._sources.get(source_name) if source: return await source.get_track(track_id) return None def get_all_tracks(self) -> list[Track]: """ Holt alle Tracks aus allen Quellen. Returns: Liste aller Tracks """ all_tracks = [] for source in self._sources.values(): all_tracks.extend(source.tracks) return all_tracks # ========================================================================== # Streaming # ========================================================================== async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]: """ Liefert Audio-Stream für Track. Findet automatisch die richtige Quelle. Args: track: Track zum Streamen Yields: Audio-Daten in Chunks """ # Quelle anhand source_type finden for source in self._sources.values(): if source.source_type.value == track.source_type: async for chunk in source.get_audio_stream(track): yield chunk return # Fallback: Alle Quellen durchsuchen for source in self._sources.values(): found = await source.get_track(track.id) if found: async for chunk in source.get_audio_stream(track): yield chunk return self.logger.warning(f"Keine Quelle für Track: {track.id}") # ========================================================================== # Suche # ========================================================================== async def search( self, query: str, limit: int = 50, source_types: list[SourceType] | None = None, ) -> list[Track]: """ Sucht in allen (oder ausgewählten) Quellen. Args: query: Suchstring limit: Maximale Ergebnisse source_types: Optionale Einschränkung auf Quellen-Typen Returns: Liste der gefundenen Tracks """ results = [] for source in self._sources.values(): # Typ-Filter if source_types and source.source_type not in source_types: continue # Suchen try: tracks = await source.search(query, limit=limit - len(results)) results.extend(tracks) if len(results) >= limit: break except Exception as e: self.logger.error(f"Suche in {source.name} fehlgeschlagen: {e}") return results[:limit] # ========================================================================== # Metadaten # ========================================================================== async def get_metadata(self, track: Track) -> TrackMetadata | None: """ Lädt Metadaten für Track. Args: track: Track Returns: Metadaten oder None """ for source in self._sources.values(): if source.source_type.value == track.source_type: return await source.get_metadata(track) return track.metadata async def get_artwork(self, track: Track) -> bytes | None: """ Lädt Artwork für Track. Args: track: Track Returns: Artwork-Daten oder None """ for source in self._sources.values(): if source.source_type.value == track.source_type: return await source.get_artwork(track) return None # ========================================================================== # Status # ========================================================================== def get_status(self) -> dict[str, Any]: """ Liefert Status aller Quellen. Returns: Status-Dictionary """ return { "source_count": self.source_count, "total_tracks": self.total_track_count, "default_source": self._default_source, "sources": { name: { "type": source.source_type.value, "state": source.state.value, "track_count": source.track_count, "is_available": source.is_available, } for name, source in self._sources.items() }, } def get_source_infos(self) -> list[SourceInfo]: """ Liefert Informationen über alle Quellen. Returns: Liste von SourceInfo """ return [source.get_info() for source in self._sources.values()] def __contains__(self, name: str) -> bool: return name in self._sources def __getitem__(self, name: str) -> MusicSource: return self._sources[name] def __iter__(self): return iter(self._sources.values()) def __len__(self) -> int: return len(self._sources) def __repr__(self) -> str: return ( f"SourceManager(sources={self.source_count}, " f"tracks={self.total_track_count})" )