main.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. # -*- coding: utf-8 -*-
  2. """
  3. Music-Plugin fuer Trixy.
  4. Ermoeglicht Musikwiedergabe ueber YouTube und lokale Dateien.
  5. Registriert Intent-Handler fuer Sprachsteuerung.
  6. """
  7. import random
  8. from pathlib import Path
  9. from typing import Any
  10. from trixy_core.plugins import TrixyPlugin
  11. from trixy_core.nlp.intent_registry import IntentRegistry, IntentDefinition, IntentSlot
  12. from trixy_core.nlp.decorators import intent, pattern, example
  13. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  14. from trixy_core.music.track import Track, TrackState
  15. from trixy_core.music.playlist import Playlist, ShuffleMode
  16. from trixy_core.music.sources.local import LocalFileSource
  17. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  18. from plugins.music.sources.youtube import YouTubeSource
  19. class MusicPlugin(TrixyPlugin):
  20. """
  21. Music-Plugin.
  22. Bietet:
  23. - YouTube-Suche und -Download (optional, via yt-dlp)
  24. - Lokale Musikdateien
  25. - Intent-Handler fuer Sprachsteuerung
  26. """
  27. NAME = "music"
  28. VERSION = "1.0.0"
  29. DESCRIPTION = "Musikwiedergabe ueber YouTube und lokale Dateien"
  30. AUTHOR = "Trixy"
  31. def __init__(self, application, plugin_path, config=None):
  32. super().__init__(application, plugin_path, config)
  33. self._youtube: YouTubeSource | None = None
  34. self._local: LocalFileSource | None = None
  35. self._registry = IntentRegistry.get_instance()
  36. @property
  37. def _music_service(self):
  38. """Zugriff auf den MusicPlayerService."""
  39. return self.application.services.get_service("music_player")
  40. async def on_load(self) -> None:
  41. """Initialisiert Quellen und registriert Intents."""
  42. pinfo(f"MusicPlugin: Lade Plugin v{self.VERSION}")
  43. yt_available = False
  44. local_available = False
  45. # YouTube-Source initialisieren (optional)
  46. yt_config = self.get_config_value("youtube", {})
  47. if yt_config.get("enabled", True):
  48. self._youtube = YouTubeSource(
  49. cache_dir=yt_config.get("cache_dir", "cache/youtube"),
  50. max_cache_size_mb=yt_config.get("max_cache_size_mb", 500),
  51. preferred_format=yt_config.get(
  52. "preferred_format", "bestaudio[ext=m4a]/bestaudio/best"
  53. ),
  54. max_results=yt_config.get("max_results", 5),
  55. search_mode=yt_config.get("search_mode", "music_fallback"),
  56. )
  57. yt_available = await self._youtube.connect()
  58. if not yt_available:
  59. pwarn("MusicPlugin: YouTube nicht verfuegbar (yt-dlp nicht installiert?)")
  60. self._youtube = None
  61. # Lokale Source initialisieren
  62. local_config = self.get_config_value("local", {})
  63. if local_config.get("enabled", True):
  64. music_dirs = self._get_music_dirs(local_config)
  65. if music_dirs:
  66. self._local = LocalFileSource(
  67. name="Lokale Musik",
  68. paths=music_dirs,
  69. recursive=True,
  70. )
  71. local_available = await self._local.connect()
  72. if local_available and local_config.get("scan_on_load", True):
  73. count = await self._local.scan()
  74. pdebug(f"MusicPlugin: {count} lokale Tracks gefunden")
  75. # Intents werden automatisch vom PluginManager registriert
  76. # (via @intent/@pattern/@example Dekoratoren auf den Handler-Methoden)
  77. yt_status = "ja" if yt_available else "nein"
  78. local_status = "ja" if local_available else "nein"
  79. pinfo(f"MusicPlugin: Geladen (YouTube={yt_status}, Lokal={local_status})")
  80. async def on_unload(self) -> None:
  81. """Entlaedt das Plugin und deregistriert Intents."""
  82. # Intents deregistrieren
  83. self._registry.unregister_plugin(self.NAME)
  84. # Quellen trennen
  85. if self._youtube:
  86. await self._youtube.disconnect()
  87. self._youtube = None
  88. if self._local:
  89. await self._local.disconnect()
  90. self._local = None
  91. pdebug("MusicPlugin: Entladen")
  92. async def on_enable(self) -> None:
  93. """Aktiviert das Plugin."""
  94. pinfo("MusicPlugin: Aktiviert")
  95. async def on_disable(self) -> None:
  96. """Deaktiviert das Plugin."""
  97. pinfo("MusicPlugin: Deaktiviert")
  98. # ==========================================================================
  99. # Intent-Handler
  100. # ==========================================================================
  101. @intent("play_music", description="Musik abspielen (mit optionalem Suchbegriff)")
  102. @pattern("(spiele|spiel|abspielen) {query}")
  103. @pattern("(spiele|spiel) [etwas] musik")
  104. @pattern("musik abspielen")
  105. @example("Spiele Bohemian Rhapsody", "Spiel Musik")
  106. @example("Spiele etwas von Mozart", "Musik abspielen")
  107. async def handle_play_music(self, data: IntentReceivedData, query: str = "", source: str = "auto") -> IntentResult:
  108. """Verarbeitet den play_music Intent."""
  109. service = self._music_service
  110. if not service:
  111. return IntentResult.failure(
  112. "MusicPlayerService nicht verfuegbar",
  113. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  114. )
  115. query = data.get_slot("query", "")
  116. source = data.get_slot("source", "auto")
  117. satellite_id = data.satellite_id
  118. # Falls kein query-Slot, aus artist/title/album/genre zusammenbauen
  119. if not query:
  120. parts = []
  121. for slot_name in ("artist", "title", "song", "album", "genre"):
  122. val = data.get_slot(slot_name, "")
  123. if val:
  124. parts.append(val)
  125. if parts:
  126. query = " ".join(parts)
  127. # Kein Query + Musik pausiert → Resume
  128. if not query and service.is_paused:
  129. await self.application.events.emit("music_resumed", {
  130. "satellite_id": satellite_id,
  131. })
  132. return IntentResult.success_with_response("Wird fortgesetzt.")
  133. # Kein Query → Shuffle lokale Bibliothek
  134. if not query:
  135. return await self._play_local_shuffle(satellite_id)
  136. # Query vorhanden → Suchen und abspielen
  137. return await self._search_and_play(query, source, satellite_id)
  138. @intent("next_track", description="Zum naechsten Track springen")
  139. @pattern("(naechster|naechstes) (song|lied|track)")
  140. @pattern("(skip|weiter)")
  141. @example("Naechster Song", "Skip", "Weiter", "Naechstes Lied")
  142. async def handle_next_track(self, data: IntentReceivedData) -> IntentResult:
  143. """Springt zum naechsten Track."""
  144. service = self._music_service
  145. if not service or not service.player:
  146. return IntentResult.failure(
  147. "Kein aktiver Player",
  148. response_text="Es laeuft gerade keine Musik.",
  149. )
  150. await self.application.events.emit("music_next", {
  151. "satellite_id": data.satellite_id,
  152. })
  153. return IntentResult.success_with_response("Naechster Song.")
  154. @intent("previous_track", description="Zum vorherigen Track springen")
  155. @pattern("(vorheriger|vorheriges) (song|lied|track)")
  156. @pattern("zurueck")
  157. @example("Zurueck", "Vorheriger Song", "Vorheriges Lied")
  158. async def handle_previous_track(self, data: IntentReceivedData) -> IntentResult:
  159. """Springt zum vorherigen Track."""
  160. service = self._music_service
  161. if not service or not service.player:
  162. return IntentResult.failure(
  163. "Kein aktiver Player",
  164. response_text="Es laeuft gerade keine Musik.",
  165. )
  166. await self.application.events.emit("music_previous", {
  167. "satellite_id": data.satellite_id,
  168. })
  169. return IntentResult.success_with_response("Vorheriger Song.")
  170. @intent("what_is_playing", description="Aktuellen Track abfragen")
  171. @pattern("was laeuft [gerade]")
  172. @pattern("wie heisst [der] (song|lied|track)")
  173. @pattern("welches lied ist [das]")
  174. @pattern("was hoere ich [gerade]")
  175. @example("Was laeuft?", "Wie heisst der Song?")
  176. @example("Welches Lied ist das?", "Was hoere ich gerade?")
  177. async def handle_what_is_playing(self, data: IntentReceivedData) -> IntentResult:
  178. """Gibt Informationen zum aktuellen Track zurueck."""
  179. service = self._music_service
  180. if not service:
  181. return IntentResult.failure(
  182. "MusicPlayerService nicht verfuegbar",
  183. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  184. )
  185. track = service.current_track
  186. if not track:
  187. return IntentResult.success_with_response(
  188. "Es laeuft gerade keine Musik."
  189. )
  190. # Track-Info zusammenbauen
  191. title = track.metadata.display_title
  192. artist = track.metadata.display_artist
  193. duration = track.metadata.duration_formatted
  194. if artist and artist != "Unbekannter Kuenstler":
  195. response = f"Es laeuft '{title}' von {artist}."
  196. else:
  197. response = f"Es laeuft '{title}'."
  198. if duration and duration != "0:00":
  199. response += f" Dauer: {duration}."
  200. return IntentResult.success_with_response(response)
  201. # ==========================================================================
  202. # Hilfsmethoden
  203. # ==========================================================================
  204. async def _play_local_shuffle(self, satellite_id: str) -> IntentResult:
  205. """Spielt lokale Musik im Shuffle-Modus."""
  206. if not self._local or not self._local.tracks:
  207. # Auch Assets-Musik pruefen
  208. service = self._music_service
  209. if service:
  210. success = await service._start_assets_playlist(satellite_id)
  211. if success:
  212. return IntentResult.success_with_response(
  213. "Musik wird abgespielt."
  214. )
  215. return IntentResult.failure(
  216. "Keine lokale Musik gefunden",
  217. response_text="Ich habe keine Musik in der lokalen Bibliothek gefunden.",
  218. )
  219. # Playlist aus lokalen Tracks erstellen
  220. tracks = list(self._local.tracks)
  221. random.shuffle(tracks)
  222. playlist = Playlist(name="Lokale Musik (Shuffle)")
  223. playlist.shuffle_mode = ShuffleMode.ON
  224. for track in tracks:
  225. playlist.add(track)
  226. service = self._music_service
  227. if not service:
  228. return IntentResult.failure(
  229. "MusicPlayerService nicht verfuegbar",
  230. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  231. )
  232. satellite_ids = self._get_target_satellites(satellite_id)
  233. success = await service.play_playlist(playlist, satellite_ids)
  234. if success:
  235. return IntentResult.success_with_response("Musik wird abgespielt.")
  236. else:
  237. return IntentResult.failure(
  238. "Wiedergabe fehlgeschlagen",
  239. response_text="Die Wiedergabe konnte nicht gestartet werden.",
  240. )
  241. async def _search_and_play(
  242. self, query: str, source: str, satellite_id: str
  243. ) -> IntentResult:
  244. """Sucht nach Musik und startet die Wiedergabe."""
  245. tracks: list[Track] = []
  246. # Quelle bestimmen
  247. search_youtube = source in ("auto", "youtube") and self._youtube
  248. search_local = source in ("auto", "lokal", "local") and self._local
  249. # Lokal suchen
  250. if search_local and self._local:
  251. local_results = await self._local.search(query, limit=10)
  252. tracks.extend(local_results)
  253. # YouTube suchen (wenn lokal nichts/wenig gefunden oder explizit YouTube)
  254. if search_youtube and self._youtube and (len(tracks) < 3 or source == "youtube"):
  255. yt_results = await self._youtube.search(query, limit=5)
  256. tracks.extend(yt_results)
  257. if not tracks:
  258. return IntentResult.failure(
  259. f"Keine Ergebnisse fuer: {query}",
  260. response_text=f"Ich konnte keine Musik fuer '{query}' finden.",
  261. )
  262. # Ersten Track vorbereiten und abspielen
  263. first_track = tracks[0]
  264. if first_track.source_type == "youtube" and self._youtube:
  265. pinfo(f"MusicPlugin: Lade YouTube-Track: {first_track.title}")
  266. prepared = await self._youtube.prepare_track(first_track)
  267. if not prepared:
  268. return IntentResult.failure(
  269. "YouTube-Download fehlgeschlagen",
  270. response_text=f"Der Download von '{first_track.title}' ist fehlgeschlagen.",
  271. )
  272. service = self._music_service
  273. if not service:
  274. return IntentResult.failure(
  275. "MusicPlayerService nicht verfuegbar",
  276. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  277. )
  278. satellite_ids = self._get_target_satellites(satellite_id)
  279. success = await service.play_track(first_track, satellite_ids)
  280. if not success:
  281. return IntentResult.failure(
  282. "Wiedergabe fehlgeschlagen",
  283. response_text="Die Wiedergabe konnte nicht gestartet werden.",
  284. )
  285. # Weitere Tracks in die Queue (im Hintergrund vorbereiten)
  286. if len(tracks) > 1:
  287. import asyncio
  288. asyncio.create_task(
  289. self._queue_remaining_tracks(tracks[1:], satellite_ids)
  290. )
  291. # Antwort
  292. artist = first_track.metadata.display_artist
  293. title = first_track.metadata.display_title
  294. if len(tracks) > 1:
  295. if artist and artist != "Unbekannter Kuenstler":
  296. return IntentResult.success_with_response(
  297. f"Spiele '{title}' von {artist}. "
  298. f"{len(tracks) - 1} weitere Titel in der Warteschlange."
  299. )
  300. return IntentResult.success_with_response(
  301. f"Spiele '{title}'. "
  302. f"{len(tracks) - 1} weitere Titel in der Warteschlange."
  303. )
  304. else:
  305. if artist and artist != "Unbekannter Kuenstler":
  306. return IntentResult.success_with_response(
  307. f"Spiele '{title}' von {artist}."
  308. )
  309. return IntentResult.success_with_response(f"Spiele '{title}'.")
  310. async def _queue_remaining_tracks(
  311. self, tracks: list, satellite_ids: list[str],
  312. ) -> None:
  313. """Bereitet weitere Tracks vor und fuegt sie zur Queue hinzu."""
  314. service = self._music_service
  315. if not service:
  316. return
  317. for track in tracks:
  318. try:
  319. if track.source_type == "youtube" and self._youtube:
  320. prepared = await self._youtube.prepare_track(track)
  321. if not prepared:
  322. pdebug(f"MusicPlugin: Skip '{track.title}' (Download fehlgeschlagen)")
  323. continue
  324. if hasattr(service, "_player") and service._player:
  325. await service._player.add_to_queue(track)
  326. else:
  327. pdebug(f"MusicPlugin: Kein Player fuer Queue")
  328. pdebug(f"MusicPlugin: Zur Queue: {track.title}")
  329. except Exception as e:
  330. pdebug(f"MusicPlugin: Queue-Fehler fuer '{track.title}': {e}")
  331. def _get_music_dirs(self, local_config: dict) -> list[Path]:
  332. """Ermittelt die Musik-Verzeichnisse."""
  333. dirs: list[Path] = []
  334. # Konfigurierte Verzeichnisse
  335. for dir_str in local_config.get("music_dirs", []):
  336. path = Path(dir_str)
  337. if path.exists() and path.is_dir():
  338. dirs.append(path)
  339. # Assets-Musik als Fallback
  340. if hasattr(self.application, "server_config"):
  341. assets_dir = Path(self.application.server_config.assets_directory)
  342. profile = self.application.server_config.profile
  343. for search_dir in [
  344. assets_dir / profile / "music",
  345. assets_dir / "default" / "music",
  346. ]:
  347. if search_dir.exists() and search_dir not in dirs:
  348. dirs.append(search_dir)
  349. return dirs
  350. def _get_target_satellites(self, satellite_id: str) -> list[str]:
  351. """Ermittelt Ziel-Satellites fuer die Wiedergabe."""
  352. # Anfragenden Satellite bevorzugen
  353. if satellite_id:
  354. satellites = getattr(self.application, "satellites", None)
  355. if satellites:
  356. sat = satellites.get(satellite_id)
  357. if sat and sat.is_connected and sat.sockets.music_out:
  358. return [satellite_id]
  359. # Fallback: Alle verbundenen Satellites mit Musik-Socket
  360. satellites = getattr(self.application, "satellites", None)
  361. if satellites:
  362. return [
  363. sat.id for sat in satellites.all()
  364. if sat.is_connected and sat.sockets.music_out
  365. ]
  366. return []