local.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. # -*- coding: utf-8 -*-
  2. """
  3. Lokale Dateien als Musikquelle.
  4. Scannt lokale Verzeichnisse nach Musikdateien.
  5. """
  6. import asyncio
  7. import logging
  8. import os
  9. from pathlib import Path
  10. from typing import AsyncIterator
  11. from trixy_core.music.sources.base import MusicSource, SourceType, SourceState
  12. from trixy_core.music.track import Track, TrackMetadata, TrackState
  13. # Unterstützte Audio-Formate
  14. SUPPORTED_EXTENSIONS = {
  15. ".mp3", ".wav", ".ogg", ".flac",
  16. ".m4a", ".aac", ".wma", ".opus",
  17. ".aiff", ".ape", ".mka",
  18. }
  19. class LocalFileSource(MusicSource):
  20. """
  21. Lokale Dateien als Musikquelle.
  22. Scannt ein oder mehrere Verzeichnisse nach Musikdateien und
  23. extrahiert Metadaten aus ID3-Tags.
  24. """
  25. def __init__(
  26. self,
  27. name: str = "Lokale Musik",
  28. paths: list[Path] | None = None,
  29. recursive: bool = True,
  30. ) -> None:
  31. """
  32. Initialisiert die lokale Musikquelle.
  33. Args:
  34. name: Name der Quelle
  35. paths: Zu scannende Verzeichnisse
  36. recursive: Rekursiv scannen
  37. """
  38. super().__init__(name=name, source_type=SourceType.LOCAL)
  39. self.paths = paths or []
  40. self.recursive = recursive
  41. self.logger = logging.getLogger(__name__)
  42. # Statistiken
  43. self._total_size = 0
  44. self._scan_errors: list[tuple[Path, str]] = []
  45. @property
  46. def total_size_bytes(self) -> int:
  47. """Gesamtgröße aller Dateien."""
  48. return self._total_size
  49. @property
  50. def scan_errors(self) -> list[tuple[Path, str]]:
  51. """Fehler beim letzten Scan."""
  52. return self._scan_errors.copy()
  53. # ==========================================================================
  54. # Pfad-Management
  55. # ==========================================================================
  56. def add_path(self, path: Path) -> bool:
  57. """
  58. Fügt ein Verzeichnis hinzu.
  59. Args:
  60. path: Verzeichnispfad
  61. Returns:
  62. True wenn hinzugefügt
  63. """
  64. if path.exists() and path.is_dir():
  65. if path not in self.paths:
  66. self.paths.append(path)
  67. return True
  68. return False
  69. def remove_path(self, path: Path) -> bool:
  70. """
  71. Entfernt ein Verzeichnis.
  72. Args:
  73. path: Verzeichnispfad
  74. Returns:
  75. True wenn entfernt
  76. """
  77. if path in self.paths:
  78. self.paths.remove(path)
  79. return True
  80. return False
  81. # ==========================================================================
  82. # MusicSource Implementation
  83. # ==========================================================================
  84. async def connect(self) -> bool:
  85. """Verbindet zur Quelle (prüft Verzeichnisse)."""
  86. available = False
  87. for path in self.paths:
  88. if path.exists() and path.is_dir():
  89. available = True
  90. break
  91. self._state = SourceState.AVAILABLE if available else SourceState.UNAVAILABLE
  92. return available
  93. async def disconnect(self) -> None:
  94. """Trennt die Verbindung (Cleanup)."""
  95. self._state = SourceState.UNAVAILABLE
  96. async def scan(self) -> int:
  97. """
  98. Scannt alle Verzeichnisse nach Musikdateien.
  99. Returns:
  100. Anzahl der gefundenen Tracks
  101. """
  102. async with self._scan_lock:
  103. if self._scanning:
  104. return self.track_count
  105. self._scanning = True
  106. self._state = SourceState.SCANNING
  107. self._scan_errors.clear()
  108. self._total_size = 0
  109. try:
  110. self._clear_tracks()
  111. for base_path in self.paths:
  112. if not base_path.exists():
  113. continue
  114. await self._scan_directory(base_path)
  115. self._state = SourceState.AVAILABLE
  116. self.logger.info(
  117. f"Scan abgeschlossen: {self.track_count} Tracks, "
  118. f"{self._total_size / (1024*1024):.1f} MB"
  119. )
  120. except Exception as e:
  121. self.logger.error(f"Scan-Fehler: {e}")
  122. self._state = SourceState.ERROR
  123. finally:
  124. self._scanning = False
  125. return self.track_count
  126. async def _scan_directory(self, directory: Path) -> None:
  127. """
  128. Scannt ein einzelnes Verzeichnis.
  129. Args:
  130. directory: Zu scannendes Verzeichnis
  131. """
  132. try:
  133. # Asynchron über Verzeichnis iterieren
  134. entries = await asyncio.to_thread(list, directory.iterdir())
  135. for entry in entries:
  136. try:
  137. if entry.is_file():
  138. if entry.suffix.lower() in SUPPORTED_EXTENSIONS:
  139. track = await self._create_track_from_file(entry)
  140. if track:
  141. self._add_track(track)
  142. self._total_size += entry.stat().st_size
  143. elif entry.is_dir() and self.recursive:
  144. # Test-Verzeichnisse ueberspringen
  145. dir_name = entry.name.lower()
  146. if dir_name.startswith("test") or dir_name.endswith("_test") or dir_name.endswith("_tests"):
  147. continue
  148. await self._scan_directory(entry)
  149. except PermissionError:
  150. self._scan_errors.append((entry, "Keine Berechtigung"))
  151. except Exception as e:
  152. self._scan_errors.append((entry, str(e)))
  153. except PermissionError:
  154. self._scan_errors.append((directory, "Keine Berechtigung"))
  155. except Exception as e:
  156. self._scan_errors.append((directory, str(e)))
  157. async def _create_track_from_file(self, file_path: Path) -> Track | None:
  158. """
  159. Erstellt Track aus Datei.
  160. Args:
  161. file_path: Pfad zur Audiodatei
  162. Returns:
  163. Track oder None bei Fehler
  164. """
  165. try:
  166. # Basis-Metadaten aus Dateinamen
  167. metadata = TrackMetadata(
  168. title=file_path.stem,
  169. format=file_path.suffix.lstrip(".").upper(),
  170. )
  171. # ID3-Tags laden (wenn Mutagen verfügbar)
  172. metadata = await self._load_metadata(file_path, metadata)
  173. track = Track(
  174. uri=f"file://{file_path}",
  175. source_type="local",
  176. source_id=str(file_path),
  177. path=file_path,
  178. metadata=metadata,
  179. state=TrackState.AVAILABLE,
  180. )
  181. return track
  182. except Exception as e:
  183. self.logger.debug(f"Fehler bei {file_path}: {e}")
  184. return None
  185. async def _load_metadata(
  186. self,
  187. file_path: Path,
  188. default: TrackMetadata,
  189. ) -> TrackMetadata:
  190. """
  191. Lädt Metadaten aus ID3-Tags.
  192. Args:
  193. file_path: Pfad zur Datei
  194. default: Standard-Metadaten
  195. Returns:
  196. Geladene Metadaten
  197. """
  198. try:
  199. # Mutagen für ID3-Tags verwenden (falls installiert)
  200. import mutagen
  201. from mutagen.easyid3 import EasyID3
  202. from mutagen.mp3 import MP3
  203. from mutagen.flac import FLAC
  204. from mutagen.oggvorbis import OggVorbis
  205. audio = mutagen.File(file_path)
  206. if audio is None:
  207. return default
  208. # Dauer
  209. if hasattr(audio.info, "length"):
  210. default.duration_ms = int(audio.info.length * 1000)
  211. # Technische Daten
  212. if hasattr(audio.info, "sample_rate"):
  213. default.sample_rate = audio.info.sample_rate
  214. if hasattr(audio.info, "bitrate"):
  215. default.bit_rate = getattr(audio.info, "bitrate", 0)
  216. if hasattr(audio.info, "channels"):
  217. default.channels = audio.info.channels
  218. # Tags
  219. tags = {}
  220. if hasattr(audio, "tags") and audio.tags:
  221. # EasyID3-Style Tags
  222. if isinstance(audio.tags, dict):
  223. tags = dict(audio.tags)
  224. else:
  225. # Versuche als dict zu behandeln
  226. for key in audio.tags.keys():
  227. try:
  228. tags[key] = audio.tags[key]
  229. except Exception:
  230. pass
  231. # Standard-Tags extrahieren
  232. default.title = self._get_tag(tags, ["title", "TIT2"]) or default.title
  233. default.artist = self._get_tag(tags, ["artist", "TPE1"]) or default.artist
  234. default.album = self._get_tag(tags, ["album", "TALB"]) or default.album
  235. default.album_artist = self._get_tag(tags, ["albumartist", "TPE2"]) or ""
  236. default.genre = self._get_tag(tags, ["genre", "TCON"]) or ""
  237. default.composer = self._get_tag(tags, ["composer", "TCOM"]) or ""
  238. # Track-Nummer
  239. track_num = self._get_tag(tags, ["tracknumber", "TRCK"])
  240. if track_num:
  241. if "/" in track_num:
  242. num, total = track_num.split("/")
  243. default.track_number = int(num)
  244. default.track_total = int(total)
  245. else:
  246. default.track_number = int(track_num)
  247. # Jahr
  248. year = self._get_tag(tags, ["date", "year", "TDRC", "TYER"])
  249. if year:
  250. try:
  251. default.year = int(str(year)[:4])
  252. except ValueError:
  253. pass
  254. # Artwork
  255. artwork = self._extract_artwork(audio)
  256. if artwork:
  257. default.artwork_data = artwork
  258. except ImportError:
  259. # Mutagen nicht installiert
  260. pass
  261. except Exception as e:
  262. self.logger.debug(f"Metadaten-Fehler bei {file_path}: {e}")
  263. return default
  264. def _get_tag(self, tags: dict, keys: list[str]) -> str | None:
  265. """Holt Tag-Wert für verschiedene Schlüssel."""
  266. for key in keys:
  267. if key in tags:
  268. value = tags[key]
  269. if isinstance(value, list):
  270. return str(value[0]) if value else None
  271. return str(value)
  272. return None
  273. def _extract_artwork(self, audio) -> bytes | None:
  274. """Extrahiert Artwork aus Audio-Datei."""
  275. try:
  276. # ID3
  277. if hasattr(audio, "tags"):
  278. tags = audio.tags
  279. if tags:
  280. # APIC Frame (MP3)
  281. for key in tags.keys():
  282. if key.startswith("APIC"):
  283. return tags[key].data
  284. # FLAC/OGG Pictures
  285. if hasattr(audio, "pictures"):
  286. if audio.pictures:
  287. return audio.pictures[0].data
  288. except Exception:
  289. pass
  290. return None
  291. async def get_track(self, track_id: str) -> Track | None:
  292. """Holt Track nach ID."""
  293. return self._tracks.get(track_id)
  294. async def get_audio_stream(self, track: Track) -> AsyncIterator[bytes]:
  295. """
  296. Liefert Audio-Daten als Stream.
  297. Args:
  298. track: Track zum Streamen
  299. Yields:
  300. Audio-Daten in Chunks (64KB)
  301. """
  302. if not track.path or not track.path.exists():
  303. return
  304. chunk_size = 65536 # 64 KB
  305. def read_file():
  306. with open(track.path, "rb") as f:
  307. while True:
  308. chunk = f.read(chunk_size)
  309. if not chunk:
  310. break
  311. yield chunk
  312. for chunk in await asyncio.to_thread(lambda: list(read_file())):
  313. yield chunk
  314. async def get_metadata(self, track: Track) -> TrackMetadata | None:
  315. """Lädt Metadaten für einen Track."""
  316. if track.path:
  317. return await self._load_metadata(track.path, track.metadata)
  318. return track.metadata
  319. def get_info(self):
  320. """Liefert erweiterte Informationen."""
  321. info = super().get_info()
  322. info.path = self.paths[0] if self.paths else None
  323. info.total_size_bytes = self._total_size
  324. info.description = f"{len(self.paths)} Verzeichnisse"
  325. return info