base.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. # -*- coding: utf-8 -*-
  2. """
  3. Basis-Klassen für Musikquellen.
  4. Definiert die abstrakte Schnittstelle für alle Musikquellen.
  5. """
  6. import asyncio
  7. from abc import ABC, abstractmethod
  8. from dataclasses import dataclass, field
  9. from datetime import datetime
  10. from enum import Enum
  11. from pathlib import Path
  12. from typing import Any, AsyncIterator
  13. from trixy_core.music.track import Track, TrackMetadata
  14. class SourceType(Enum):
  15. """Typ der Musikquelle."""
  16. LOCAL = "local" # Lokale Dateien
  17. USB = "usb" # USB-Speicher
  18. URL = "url" # HTTP/HTTPS URLs
  19. SPOTIFY = "spotify" # Spotify (zukünftig)
  20. AMAZON = "amazon" # Amazon Music (zukünftig)
  21. YOUTUBE = "youtube" # YouTube Music (zukünftig)
  22. WEBDAV = "webdav" # WebDAV (zukünftig)
  23. SMB = "smb" # SMB/CIFS Share (zukünftig)
  24. class SourceState(Enum):
  25. """Zustand einer Musikquelle."""
  26. UNKNOWN = "unknown" # Unbekannt
  27. AVAILABLE = "available" # Verfügbar
  28. SCANNING = "scanning" # Wird gescannt
  29. UNAVAILABLE = "unavailable" # Nicht verfügbar
  30. ERROR = "error" # Fehler
  31. MOUNTING = "mounting" # Wird eingebunden
  32. UNMOUNTING = "unmounting" # Wird ausgehängt
  33. @dataclass
  34. class SourceInfo:
  35. """Informationen über eine Musikquelle."""
  36. source_type: SourceType
  37. name: str
  38. description: str = ""
  39. path: Path | None = None
  40. uri: str = ""
  41. state: SourceState = SourceState.UNKNOWN
  42. # Statistiken
  43. track_count: int = 0
  44. total_size_bytes: int = 0
  45. last_scan: datetime | None = None
  46. # Capabilities
  47. supports_search: bool = True
  48. supports_metadata: bool = True
  49. supports_artwork: bool = True
  50. supports_streaming: bool = True
  51. # Zusätzliche Daten
  52. metadata: dict[str, Any] = field(default_factory=dict)
  53. def to_dict(self) -> dict[str, Any]:
  54. """Konvertiert zu Dictionary."""
  55. return {
  56. "source_type": self.source_type.value,
  57. "name": self.name,
  58. "description": self.description,
  59. "path": str(self.path) if self.path else None,
  60. "uri": self.uri,
  61. "state": self.state.value,
  62. "track_count": self.track_count,
  63. "total_size_bytes": self.total_size_bytes,
  64. "last_scan": self.last_scan.isoformat() if self.last_scan else None,
  65. "supports_search": self.supports_search,
  66. "supports_metadata": self.supports_metadata,
  67. "supports_artwork": self.supports_artwork,
  68. "supports_streaming": self.supports_streaming,
  69. }
  70. class MusicSource(ABC):
  71. """
  72. Abstrakte Basis-Klasse für Musikquellen.
  73. Definiert die Schnittstelle für alle Arten von Musikquellen:
  74. - Lokale Dateien
  75. - USB-Speicher
  76. - HTTP/HTTPS URLs
  77. - Streaming-Dienste (Spotify, Amazon, etc.)
  78. """
  79. def __init__(
  80. self,
  81. name: str,
  82. source_type: SourceType,
  83. ) -> None:
  84. """
  85. Initialisiert die Musikquelle.
  86. Args:
  87. name: Name der Quelle
  88. source_type: Typ der Quelle
  89. """
  90. self.name = name
  91. self.source_type = source_type
  92. self._state = SourceState.UNKNOWN
  93. self._tracks: dict[str, Track] = {}
  94. self._scanning = False
  95. self._scan_lock = asyncio.Lock()
  96. @property
  97. def state(self) -> SourceState:
  98. """Aktueller Zustand."""
  99. return self._state
  100. @property
  101. def track_count(self) -> int:
  102. """Anzahl der gefundenen Tracks."""
  103. return len(self._tracks)
  104. @property
  105. def tracks(self) -> list[Track]:
  106. """Alle gefundenen Tracks."""
  107. return list(self._tracks.values())
  108. @property
  109. def is_available(self) -> bool:
  110. """Prüft ob Quelle verfügbar ist."""
  111. return self._state == SourceState.AVAILABLE
  112. @property
  113. def is_scanning(self) -> bool:
  114. """Prüft ob gerade gescannt wird."""
  115. return self._scanning
  116. # ==========================================================================
  117. # Abstrakte Methoden
  118. # ==========================================================================
  119. @abstractmethod
  120. async def connect(self) -> bool:
  121. """
  122. Verbindet zur Quelle.
  123. Returns:
  124. True wenn erfolgreich
  125. """
  126. pass
  127. @abstractmethod
  128. async def disconnect(self) -> None:
  129. """Trennt die Verbindung zur Quelle."""
  130. pass
  131. @abstractmethod
  132. async def scan(self) -> int:
  133. """
  134. Scannt die Quelle nach Tracks.
  135. Returns:
  136. Anzahl der gefundenen Tracks
  137. """
  138. pass
  139. @abstractmethod
  140. async def get_track(self, track_id: str) -> Track | None:
  141. """
  142. Holt Track nach ID.
  143. Args:
  144. track_id: Track-ID
  145. Returns:
  146. Track oder None
  147. """
  148. pass
  149. @abstractmethod
  150. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  151. """
  152. Liefert Audio-Daten als Stream.
  153. Args:
  154. track: Track zum Streamen
  155. Yields:
  156. Audio-Daten in Chunks
  157. """
  158. pass
  159. @abstractmethod
  160. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  161. """
  162. Lädt Metadaten für einen Track.
  163. Args:
  164. track: Track
  165. Returns:
  166. Metadaten oder None
  167. """
  168. pass
  169. # ==========================================================================
  170. # Optionale Methoden (mit Default-Implementierung)
  171. # ==========================================================================
  172. async def search(
  173. self,
  174. query: str,
  175. limit: int = 50,
  176. ) -> list[Track]:
  177. """
  178. Sucht nach Tracks.
  179. Args:
  180. query: Suchstring
  181. limit: Maximale Ergebnisse
  182. Returns:
  183. Liste der gefundenen Tracks
  184. """
  185. query_lower = query.lower()
  186. results = []
  187. for track in self._tracks.values():
  188. if (query_lower in track.title.lower() or
  189. query_lower in track.artist.lower() or
  190. query_lower in track.metadata.album.lower()):
  191. results.append(track)
  192. if len(results) >= limit:
  193. break
  194. return results
  195. async def get_artwork(self, track: Track) -> bytes | None:
  196. """
  197. Lädt Artwork für einen Track.
  198. Args:
  199. track: Track
  200. Returns:
  201. Artwork-Daten (JPEG/PNG) oder None
  202. """
  203. if track.metadata.artwork_data:
  204. return track.metadata.artwork_data
  205. return None
  206. async def refresh(self) -> None:
  207. """Aktualisiert die Track-Liste."""
  208. await self.scan()
  209. def get_info(self) -> SourceInfo:
  210. """Liefert Informationen über die Quelle."""
  211. return SourceInfo(
  212. source_type=self.source_type,
  213. name=self.name,
  214. state=self._state,
  215. track_count=self.track_count,
  216. )
  217. # ==========================================================================
  218. # Hilfsmethoden
  219. # ==========================================================================
  220. def _add_track(self, track: Track) -> None:
  221. """Fügt Track zur internen Liste hinzu."""
  222. self._tracks[track.id] = track
  223. def _remove_track(self, track_id: str) -> Track | None:
  224. """Entfernt Track aus interner Liste."""
  225. return self._tracks.pop(track_id, None)
  226. def _clear_tracks(self) -> None:
  227. """Leert die Track-Liste."""
  228. self._tracks.clear()
  229. def __repr__(self) -> str:
  230. return (
  231. f"{self.__class__.__name__}("
  232. f"name={self.name!r}, "
  233. f"state={self._state.value!r}, "
  234. f"tracks={self.track_count})"
  235. )