manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. # -*- coding: utf-8 -*-
  2. """
  3. Source-Manager.
  4. Verwaltet mehrere Musikquellen und bietet einheitlichen Zugriff.
  5. """
  6. import asyncio
  7. import logging
  8. from typing import Any, AsyncIterator
  9. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState, SourceInfo
  10. from trixy_core.music.track import Track, TrackMetadata
  11. class SourceManager:
  12. """
  13. Verwaltet mehrere Musikquellen.
  14. Bietet einheitlichen Zugriff auf alle registrierten Quellen:
  15. - Lokale Dateien
  16. - USB-Speicher
  17. - URL-Streams
  18. - (zukünftig) Streaming-Dienste
  19. """
  20. def __init__(self) -> None:
  21. """Initialisiert den SourceManager."""
  22. self._sources: dict[str, MusicSource] = {}
  23. self._default_source: str = ""
  24. self.logger = logging.getLogger(__name__)
  25. # ==========================================================================
  26. # Properties
  27. # ==========================================================================
  28. @property
  29. def sources(self) -> list[MusicSource]:
  30. """Alle registrierten Quellen."""
  31. return list(self._sources.values())
  32. @property
  33. def source_count(self) -> int:
  34. """Anzahl der Quellen."""
  35. return len(self._sources)
  36. @property
  37. def total_track_count(self) -> int:
  38. """Gesamtzahl der Tracks über alle Quellen."""
  39. return sum(source.track_count for source in self._sources.values())
  40. @property
  41. def default_source(self) -> MusicSource | None:
  42. """Standard-Quelle."""
  43. return self._sources.get(self._default_source)
  44. # ==========================================================================
  45. # Quellen-Management
  46. # ==========================================================================
  47. def register(
  48. self,
  49. source: MusicSource,
  50. name: str = "",
  51. set_default: bool = False,
  52. ) -> str:
  53. """
  54. Registriert eine Musikquelle.
  55. Args:
  56. source: Musikquelle
  57. name: Optionaler eindeutiger Name
  58. set_default: Als Standard-Quelle setzen
  59. Returns:
  60. Registrierter Name
  61. """
  62. # Name generieren falls nicht angegeben
  63. if not name:
  64. name = f"{source.source_type.value}_{len(self._sources)}"
  65. # Eindeutigkeit prüfen
  66. if name in self._sources:
  67. i = 1
  68. while f"{name}_{i}" in self._sources:
  69. i += 1
  70. name = f"{name}_{i}"
  71. self._sources[name] = source
  72. self.logger.debug(f"Quelle registriert: {name} ({source.source_type.value})")
  73. if set_default or not self._default_source:
  74. self._default_source = name
  75. return name
  76. def unregister(self, name: str) -> bool:
  77. """
  78. Entfernt eine Musikquelle.
  79. Args:
  80. name: Quellen-Name
  81. Returns:
  82. True wenn entfernt
  83. """
  84. if name in self._sources:
  85. del self._sources[name]
  86. if self._default_source == name:
  87. self._default_source = next(iter(self._sources), "")
  88. self.logger.debug(f"Quelle entfernt: {name}")
  89. return True
  90. return False
  91. def get(self, name: str) -> MusicSource | None:
  92. """
  93. Holt Quelle nach Name.
  94. Args:
  95. name: Quellen-Name
  96. Returns:
  97. Quelle oder None
  98. """
  99. return self._sources.get(name)
  100. def get_by_type(self, source_type: SourceType) -> list[MusicSource]:
  101. """
  102. Holt alle Quellen eines Typs.
  103. Args:
  104. source_type: Quellen-Typ
  105. Returns:
  106. Liste der Quellen
  107. """
  108. return [s for s in self._sources.values() if s.source_type == source_type]
  109. def set_default(self, name: str) -> bool:
  110. """
  111. Setzt Standard-Quelle.
  112. Args:
  113. name: Quellen-Name
  114. Returns:
  115. True wenn gesetzt
  116. """
  117. if name in self._sources:
  118. self._default_source = name
  119. return True
  120. return False
  121. # ==========================================================================
  122. # Lifecycle
  123. # ==========================================================================
  124. async def connect_all(self) -> dict[str, bool]:
  125. """
  126. Verbindet alle Quellen.
  127. Returns:
  128. Dict Name → Erfolg
  129. """
  130. results = {}
  131. tasks = {
  132. name: source.connect()
  133. for name, source in self._sources.items()
  134. }
  135. for name, task in tasks.items():
  136. try:
  137. results[name] = await task
  138. except Exception as e:
  139. self.logger.error(f"Verbindung zu {name} fehlgeschlagen: {e}")
  140. results[name] = False
  141. self.logger.info(f"Quellen verbunden: {sum(results.values())}/{len(results)}")
  142. return results
  143. async def disconnect_all(self) -> None:
  144. """Trennt alle Quellen."""
  145. for source in self._sources.values():
  146. try:
  147. await source.disconnect()
  148. except Exception as e:
  149. self.logger.error(f"Trennen fehlgeschlagen: {e}")
  150. async def scan_all(self) -> dict[str, int]:
  151. """
  152. Scannt alle Quellen.
  153. Returns:
  154. Dict Name → Anzahl Tracks
  155. """
  156. results = {}
  157. # Parallel scannen
  158. async def scan_source(name: str, source: MusicSource) -> tuple[str, int]:
  159. try:
  160. count = await source.scan()
  161. return (name, count)
  162. except Exception as e:
  163. self.logger.error(f"Scan von {name} fehlgeschlagen: {e}")
  164. return (name, 0)
  165. tasks = [
  166. scan_source(name, source)
  167. for name, source in self._sources.items()
  168. ]
  169. for coro in asyncio.as_completed(tasks):
  170. name, count = await coro
  171. results[name] = count
  172. total = sum(results.values())
  173. self.logger.info(f"Scan abgeschlossen: {total} Tracks gesamt")
  174. return results
  175. # ==========================================================================
  176. # Track-Zugriff
  177. # ==========================================================================
  178. async def get_track(self, track_id: str) -> Track | None:
  179. """
  180. Sucht Track in allen Quellen.
  181. Args:
  182. track_id: Track-ID
  183. Returns:
  184. Track oder None
  185. """
  186. for source in self._sources.values():
  187. track = await source.get_track(track_id)
  188. if track:
  189. return track
  190. return None
  191. async def get_track_from_source(
  192. self,
  193. source_name: str,
  194. track_id: str,
  195. ) -> Track | None:
  196. """
  197. Holt Track von bestimmter Quelle.
  198. Args:
  199. source_name: Quellen-Name
  200. track_id: Track-ID
  201. Returns:
  202. Track oder None
  203. """
  204. source = self._sources.get(source_name)
  205. if source:
  206. return await source.get_track(track_id)
  207. return None
  208. def get_all_tracks(self) -> list[Track]:
  209. """
  210. Holt alle Tracks aus allen Quellen.
  211. Returns:
  212. Liste aller Tracks
  213. """
  214. all_tracks = []
  215. for source in self._sources.values():
  216. all_tracks.extend(source.tracks)
  217. return all_tracks
  218. # ==========================================================================
  219. # Streaming
  220. # ==========================================================================
  221. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  222. """
  223. Liefert Audio-Stream für Track.
  224. Findet automatisch die richtige Quelle.
  225. Args:
  226. track: Track zum Streamen
  227. Yields:
  228. Audio-Daten in Chunks
  229. """
  230. # Quelle anhand source_type finden
  231. for source in self._sources.values():
  232. if source.source_type.value == track.source_type:
  233. async for chunk in source.get_audio_stream(track):
  234. yield chunk
  235. return
  236. # Fallback: Alle Quellen durchsuchen
  237. for source in self._sources.values():
  238. found = await source.get_track(track.id)
  239. if found:
  240. async for chunk in source.get_audio_stream(track):
  241. yield chunk
  242. return
  243. self.logger.warning(f"Keine Quelle für Track: {track.id}")
  244. # ==========================================================================
  245. # Suche
  246. # ==========================================================================
  247. async def search(
  248. self,
  249. query: str,
  250. limit: int = 50,
  251. source_types: list[SourceType] | None = None,
  252. ) -> list[Track]:
  253. """
  254. Sucht in allen (oder ausgewählten) Quellen.
  255. Args:
  256. query: Suchstring
  257. limit: Maximale Ergebnisse
  258. source_types: Optionale Einschränkung auf Quellen-Typen
  259. Returns:
  260. Liste der gefundenen Tracks
  261. """
  262. results = []
  263. for source in self._sources.values():
  264. # Typ-Filter
  265. if source_types and source.source_type not in source_types:
  266. continue
  267. # Suchen
  268. try:
  269. tracks = await source.search(query, limit=limit - len(results))
  270. results.extend(tracks)
  271. if len(results) >= limit:
  272. break
  273. except Exception as e:
  274. self.logger.error(f"Suche in {source.name} fehlgeschlagen: {e}")
  275. return results[:limit]
  276. # ==========================================================================
  277. # Metadaten
  278. # ==========================================================================
  279. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  280. """
  281. Lädt Metadaten für Track.
  282. Args:
  283. track: Track
  284. Returns:
  285. Metadaten oder None
  286. """
  287. for source in self._sources.values():
  288. if source.source_type.value == track.source_type:
  289. return await source.get_metadata(track)
  290. return track.metadata
  291. async def get_artwork(self, track: Track) -> bytes | None:
  292. """
  293. Lädt Artwork für Track.
  294. Args:
  295. track: Track
  296. Returns:
  297. Artwork-Daten oder None
  298. """
  299. for source in self._sources.values():
  300. if source.source_type.value == track.source_type:
  301. return await source.get_artwork(track)
  302. return None
  303. # ==========================================================================
  304. # Status
  305. # ==========================================================================
  306. def get_status(self) -> dict[str, Any]:
  307. """
  308. Liefert Status aller Quellen.
  309. Returns:
  310. Status-Dictionary
  311. """
  312. return {
  313. "source_count": self.source_count,
  314. "total_tracks": self.total_track_count,
  315. "default_source": self._default_source,
  316. "sources": {
  317. name: {
  318. "type": source.source_type.value,
  319. "state": source.state.value,
  320. "track_count": source.track_count,
  321. "is_available": source.is_available,
  322. }
  323. for name, source in self._sources.items()
  324. },
  325. }
  326. def get_source_infos(self) -> list[SourceInfo]:
  327. """
  328. Liefert Informationen über alle Quellen.
  329. Returns:
  330. Liste von SourceInfo
  331. """
  332. return [source.get_info() for source in self._sources.values()]
  333. def __contains__(self, name: str) -> bool:
  334. return name in self._sources
  335. def __getitem__(self, name: str) -> MusicSource:
  336. return self._sources[name]
  337. def __iter__(self):
  338. return iter(self._sources.values())
  339. def __len__(self) -> int:
  340. return len(self._sources)
  341. def __repr__(self) -> str:
  342. return (
  343. f"SourceManager(sources={self.source_count}, "
  344. f"tracks={self.total_track_count})"
  345. )