main.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. # -*- coding: utf-8 -*-
  2. """
  3. Music-Plugin fuer Trixy.
  4. Ermoeglicht Musikwiedergabe ueber YouTube, Spotify, Radio 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. from plugins.music.sources.spotify import SpotifySource
  20. from plugins.music.sources.soundcloud import SoundCloudSource
  21. from plugins.music.sources.deezer import DeezerSource
  22. from plugins.music.sources.radio import RadioSource
  23. class MusicPlugin(TrixyPlugin):
  24. """
  25. Music-Plugin.
  26. Bietet:
  27. - YouTube-Suche und -Download (optional, via yt-dlp)
  28. - Spotify-Suche und -Download (optional, via spotipy + spotdl)
  29. - SoundCloud-Suche und -Download (optional, via yt-dlp)
  30. - Deezer-Suche und -Download (optional, via aiohttp + yt-dlp)
  31. - Radio-Streams (Icecast/Shoutcast, via aiohttp)
  32. - Lokale Musikdateien
  33. - Intent-Handler fuer Sprachsteuerung
  34. """
  35. NAME = "music"
  36. VERSION = "3.0.0"
  37. DESCRIPTION = "Musikwiedergabe ueber YouTube, Spotify, SoundCloud, Deezer, Radio und lokale Dateien"
  38. AUTHOR = "Trixy"
  39. def __init__(self, application, plugin_path, config=None):
  40. super().__init__(application, plugin_path, config)
  41. self._youtube: YouTubeSource | None = None
  42. self._spotify: SpotifySource | None = None
  43. self._soundcloud: SoundCloudSource | None = None
  44. self._deezer: DeezerSource | None = None
  45. self._radio: RadioSource | None = None
  46. self._local: LocalFileSource | None = None
  47. self._registry = IntentRegistry.get_instance()
  48. @property
  49. def _music_service(self):
  50. """Zugriff auf den MusicPlayerService."""
  51. return self.application.services.get_service("music_player")
  52. async def on_load(self) -> None:
  53. """Initialisiert Quellen und registriert Intents."""
  54. pinfo(f"MusicPlugin: Lade Plugin v{self.VERSION}")
  55. yt_available = False
  56. spotify_available = False
  57. sc_available = False
  58. dz_available = False
  59. radio_available = False
  60. local_available = False
  61. # YouTube-Source initialisieren (optional)
  62. yt_config = self.get_config_value("youtube", {})
  63. if yt_config.get("enabled", True):
  64. self._youtube = YouTubeSource(
  65. cache_dir=yt_config.get("cache_dir", "cache/youtube"),
  66. max_cache_size_mb=yt_config.get("max_cache_size_mb", 500),
  67. preferred_format=yt_config.get(
  68. "preferred_format", "bestaudio[ext=m4a]/bestaudio/best"
  69. ),
  70. max_results=yt_config.get("max_results", 5),
  71. search_mode=yt_config.get("search_mode", "music_fallback"),
  72. )
  73. yt_available = await self._youtube.connect()
  74. if not yt_available:
  75. pwarn("MusicPlugin: YouTube nicht verfuegbar (yt-dlp nicht installiert?)")
  76. self._youtube = None
  77. # Spotify-Source initialisieren (optional)
  78. sp_config = self.get_config_value("spotify", {})
  79. if sp_config.get("enabled", False):
  80. self._spotify = SpotifySource(
  81. client_id=sp_config.get("client_id", ""),
  82. client_secret=sp_config.get("client_secret", ""),
  83. cache_dir=sp_config.get("cache_dir", "cache/spotify"),
  84. max_cache_size_mb=sp_config.get("max_cache_size_mb", 500),
  85. max_results=sp_config.get("max_results", 5),
  86. market=sp_config.get("market", "DE"),
  87. )
  88. spotify_available = await self._spotify.connect()
  89. if not spotify_available:
  90. pwarn("MusicPlugin: Spotify nicht verfuegbar")
  91. self._spotify = None
  92. # SoundCloud-Source initialisieren (optional)
  93. sc_config = self.get_config_value("soundcloud", {})
  94. if sc_config.get("enabled", False):
  95. self._soundcloud = SoundCloudSource(
  96. cache_dir=sc_config.get("cache_dir", "cache/soundcloud"),
  97. max_cache_size_mb=sc_config.get("max_cache_size_mb", 500),
  98. max_results=sc_config.get("max_results", 5),
  99. )
  100. sc_available = await self._soundcloud.connect()
  101. if not sc_available:
  102. pwarn("MusicPlugin: SoundCloud nicht verfuegbar")
  103. self._soundcloud = None
  104. # Deezer-Source initialisieren (optional)
  105. dz_config = self.get_config_value("deezer", {})
  106. if dz_config.get("enabled", False):
  107. self._deezer = DeezerSource(
  108. cache_dir=dz_config.get("cache_dir", "cache/deezer"),
  109. max_cache_size_mb=dz_config.get("max_cache_size_mb", 500),
  110. max_results=dz_config.get("max_results", 5),
  111. download_mode=dz_config.get("download_mode", "full"),
  112. )
  113. dz_available = await self._deezer.connect()
  114. if not dz_available:
  115. pwarn("MusicPlugin: Deezer nicht verfuegbar")
  116. self._deezer = None
  117. # Radio-Source initialisieren (optional)
  118. radio_config = self.get_config_value("radio", {})
  119. if radio_config.get("enabled", True):
  120. self._radio = RadioSource(
  121. favorites=radio_config.get("favorites", []),
  122. include_defaults=radio_config.get("include_defaults", True),
  123. icy_metadata=radio_config.get("icy_metadata", True),
  124. )
  125. radio_available = await self._radio.connect()
  126. if not radio_available:
  127. pwarn("MusicPlugin: Radio nicht verfuegbar")
  128. self._radio = None
  129. # Lokale Source initialisieren
  130. local_config = self.get_config_value("local", {})
  131. if local_config.get("enabled", True):
  132. music_dirs = self._get_music_dirs(local_config)
  133. if music_dirs:
  134. self._local = LocalFileSource(
  135. name="Lokale Musik",
  136. paths=music_dirs,
  137. recursive=True,
  138. )
  139. local_available = await self._local.connect()
  140. if local_available and local_config.get("scan_on_load", True):
  141. count = await self._local.scan()
  142. pdebug(f"MusicPlugin: {count} lokale Tracks gefunden")
  143. # Status-Uebersicht
  144. sources = []
  145. if yt_available: sources.append("YouTube")
  146. if spotify_available: sources.append("Spotify")
  147. if sc_available: sources.append("SoundCloud")
  148. if dz_available: sources.append("Deezer")
  149. if radio_available: sources.append(f"Radio({self._radio.station_count})")
  150. if local_available: sources.append("Lokal")
  151. pinfo(f"MusicPlugin: Geladen — Quellen: {', '.join(sources) or 'keine'}")
  152. async def on_unload(self) -> None:
  153. """Entlaedt das Plugin und deregistriert Intents."""
  154. # Intents deregistrieren
  155. self._registry.unregister_plugin(self.NAME)
  156. # Quellen trennen
  157. for source in (self._youtube, self._spotify, self._soundcloud, self._deezer, self._radio, self._local):
  158. if source:
  159. await source.disconnect()
  160. self._youtube = None
  161. self._spotify = None
  162. self._soundcloud = None
  163. self._deezer = None
  164. self._radio = None
  165. self._local = None
  166. pdebug("MusicPlugin: Entladen")
  167. async def on_enable(self) -> None:
  168. """Aktiviert das Plugin."""
  169. pinfo("MusicPlugin: Aktiviert")
  170. async def on_disable(self) -> None:
  171. """Deaktiviert das Plugin."""
  172. pinfo("MusicPlugin: Deaktiviert")
  173. # ==========================================================================
  174. # Intent-Handler
  175. # ==========================================================================
  176. @intent("play_music", description="Musik abspielen (mit optionalem Suchbegriff)")
  177. @pattern("(spiele|spiel|abspielen) {query}")
  178. @pattern("(spiele|spiel) [etwas] musik")
  179. @pattern("(spiele|spiel) [etwas] musik von {query}")
  180. @pattern("musik von {query}")
  181. @pattern("musik abspielen")
  182. @example("Spiele Bohemian Rhapsody", "Spiel Musik")
  183. @example("Spiele etwas von Mozart", "Musik abspielen")
  184. async def handle_play_music(self, data: IntentReceivedData, query: str = "", source: str = "auto") -> IntentResult:
  185. """Verarbeitet den play_music Intent."""
  186. service = self._music_service
  187. if not service:
  188. return IntentResult.failure(
  189. "MusicPlayerService nicht verfuegbar",
  190. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  191. )
  192. query = data.get_slot("query", "")
  193. source = data.get_slot("source", "auto")
  194. satellite_id = data.satellite_id
  195. # Falls kein query-Slot, aus artist/title/album/genre zusammenbauen
  196. if not query:
  197. parts = []
  198. for slot_name in ("artist", "title", "song", "album", "genre"):
  199. val = data.get_slot(slot_name, "")
  200. if val:
  201. parts.append(val)
  202. if parts:
  203. query = " ".join(parts)
  204. # Kein Query + Musik pausiert → Resume
  205. if not query and service.is_paused:
  206. await self.application.events.emit("music_resumed", {
  207. "satellite_id": satellite_id,
  208. })
  209. return IntentResult.success_with_response("Wird fortgesetzt.")
  210. # Kein Query → Shuffle lokale Bibliothek
  211. if not query:
  212. return await self._play_local_shuffle(satellite_id)
  213. # Query vorhanden → Suchen und abspielen
  214. return await self._search_and_play(query, source, satellite_id)
  215. @intent("next_track", description="Zum naechsten Track springen")
  216. @pattern("(naechster|naechstes) (song|lied|track)")
  217. @pattern("(skip|weiter)")
  218. @example("Naechster Song", "Skip", "Weiter", "Naechstes Lied")
  219. async def handle_next_track(self, data: IntentReceivedData) -> IntentResult:
  220. """Springt zum naechsten Track."""
  221. service = self._music_service
  222. if not service or not service.player:
  223. return IntentResult.failure(
  224. "Kein aktiver Player",
  225. response_text="Es laeuft gerade keine Musik.",
  226. )
  227. await self.application.events.emit("music_next", {
  228. "satellite_id": data.satellite_id,
  229. })
  230. return IntentResult.success_with_response("Naechster Song.")
  231. @intent("previous_track", description="Zum vorherigen Track springen")
  232. @pattern("(vorheriger|vorheriges) (song|lied|track)")
  233. @pattern("zurueck")
  234. @example("Zurueck", "Vorheriger Song", "Vorheriges Lied")
  235. async def handle_previous_track(self, data: IntentReceivedData) -> IntentResult:
  236. """Springt zum vorherigen Track."""
  237. service = self._music_service
  238. if not service or not service.player:
  239. return IntentResult.failure(
  240. "Kein aktiver Player",
  241. response_text="Es laeuft gerade keine Musik.",
  242. )
  243. await self.application.events.emit("music_previous", {
  244. "satellite_id": data.satellite_id,
  245. })
  246. return IntentResult.success_with_response("Vorheriger Song.")
  247. @intent("play_radio", description="Radio-Sender abspielen")
  248. @pattern("(spiele|spiel) radio {query}")
  249. @pattern("(spiele|spiel) [den] [sender] {query}")
  250. @pattern("radio {query} [einschalten|abspielen|spielen]")
  251. @pattern("mach [mal] {query} an")
  252. @pattern("(schalte|schalt) {query} ein")
  253. @example("Spiele Radio SWR3", "Spiel den Sender 1LIVE")
  254. @example("Radio Klassik Radio einschalten", "Mach mal Fritz an")
  255. async def handle_play_radio(self, data: IntentReceivedData, query: str = "") -> IntentResult:
  256. """Verarbeitet den play_radio Intent."""
  257. if not self._radio:
  258. return IntentResult.failure(
  259. "Radio nicht verfuegbar",
  260. response_text="Radio ist gerade nicht verfuegbar.",
  261. )
  262. query = data.get_slot("query", "")
  263. if not query:
  264. return IntentResult.failure(
  265. "Kein Sender angegeben",
  266. response_text="Welchen Sender moechtest du hoeren?",
  267. )
  268. return await self._search_and_play(query, "radio", data.satellite_id)
  269. @intent("what_is_playing", description="Aktuellen Track abfragen")
  270. @pattern("was laeuft [gerade]")
  271. @pattern("wie heisst [der] (song|lied|track)")
  272. @pattern("welches lied ist [das]")
  273. @pattern("was hoere ich [gerade]")
  274. @example("Was laeuft?", "Wie heisst der Song?")
  275. @example("Welches Lied ist das?", "Was hoere ich gerade?")
  276. async def handle_what_is_playing(self, data: IntentReceivedData) -> IntentResult:
  277. """Gibt Informationen zum aktuellen Track zurueck."""
  278. service = self._music_service
  279. if not service:
  280. return IntentResult.failure(
  281. "MusicPlayerService nicht verfuegbar",
  282. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  283. )
  284. track = service.current_track
  285. if not track:
  286. return IntentResult.success_with_response(
  287. "Es laeuft gerade keine Musik."
  288. )
  289. # Track-Info zusammenbauen
  290. title = track.metadata.display_title
  291. artist = track.metadata.display_artist
  292. duration = track.metadata.duration_formatted
  293. if artist and artist != "Unbekannter Kuenstler":
  294. response = f"Es laeuft '{title}' von {artist}."
  295. else:
  296. response = f"Es laeuft '{title}'."
  297. if duration and duration != "0:00":
  298. response += f" Dauer: {duration}."
  299. return IntentResult.success_with_response(response)
  300. # ==========================================================================
  301. # Hilfsmethoden
  302. # ==========================================================================
  303. async def _play_local_shuffle(self, satellite_id: str) -> IntentResult:
  304. """Spielt lokale Musik im Shuffle-Modus."""
  305. if not self._local or not self._local.tracks:
  306. # Auch Assets-Musik pruefen
  307. service = self._music_service
  308. if service:
  309. success = await service._start_assets_playlist(satellite_id)
  310. if success:
  311. return IntentResult.success_with_response(
  312. "Musik wird abgespielt."
  313. )
  314. return IntentResult.failure(
  315. "Keine lokale Musik gefunden",
  316. response_text="Ich habe keine Musik in der lokalen Bibliothek gefunden.",
  317. )
  318. # Playlist aus lokalen Tracks erstellen
  319. tracks = list(self._local.tracks)
  320. random.shuffle(tracks)
  321. playlist = Playlist(name="Lokale Musik (Shuffle)")
  322. playlist.shuffle_mode = ShuffleMode.ON
  323. for track in tracks:
  324. playlist.add(track)
  325. service = self._music_service
  326. if not service:
  327. return IntentResult.failure(
  328. "MusicPlayerService nicht verfuegbar",
  329. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  330. )
  331. satellite_ids = self._get_target_satellites(satellite_id)
  332. success = await service.play_playlist(playlist, satellite_ids)
  333. if success:
  334. return IntentResult.success_with_response("Musik wird abgespielt.")
  335. else:
  336. return IntentResult.failure(
  337. "Wiedergabe fehlgeschlagen",
  338. response_text="Die Wiedergabe konnte nicht gestartet werden.",
  339. )
  340. async def _search_and_play(
  341. self, query: str, source: str, satellite_id: str
  342. ) -> IntentResult:
  343. """Sucht nach Musik und startet die Wiedergabe."""
  344. tracks: list[Track] = []
  345. # Quelle bestimmen
  346. search_youtube = source in ("auto", "youtube") and self._youtube
  347. search_spotify = source in ("auto", "spotify") and self._spotify
  348. search_soundcloud = source in ("soundcloud",) and self._soundcloud
  349. search_deezer = source in ("auto", "deezer") and self._deezer
  350. search_local = source in ("auto", "lokal", "local") and self._local
  351. search_radio = source in ("radio",) and self._radio
  352. # Radio-Suche (wenn explizit "radio" als Quelle)
  353. if search_radio and self._radio:
  354. radio_results = await self._radio.search(query, limit=5)
  355. tracks.extend(radio_results)
  356. # SoundCloud-Suche (wenn explizit "soundcloud")
  357. if search_soundcloud and self._soundcloud:
  358. sc_results = await self._soundcloud.search(query, limit=5)
  359. tracks.extend(sc_results)
  360. # Lokal suchen
  361. if search_local and self._local:
  362. local_results = await self._local.search(query, limit=10)
  363. tracks.extend(local_results)
  364. # Deezer suchen (wenn lokal nichts/wenig oder explizit)
  365. if search_deezer and self._deezer and (len(tracks) < 3 or source == "deezer"):
  366. dz_results = await self._deezer.search(query, limit=5)
  367. tracks.extend(dz_results)
  368. # Spotify suchen (wenn immer noch nichts/wenig oder explizit)
  369. if search_spotify and self._spotify and (len(tracks) < 3 or source == "spotify"):
  370. sp_results = await self._spotify.search(query, limit=5)
  371. tracks.extend(sp_results)
  372. # YouTube suchen (wenn immer noch nichts/wenig oder explizit)
  373. if search_youtube and self._youtube and (len(tracks) < 3 or source == "youtube"):
  374. yt_results = await self._youtube.search(query, limit=5)
  375. tracks.extend(yt_results)
  376. if not tracks:
  377. return IntentResult.failure(
  378. f"Keine Ergebnisse fuer: {query}",
  379. response_text=f"Ich konnte keine Musik fuer '{query}' finden.",
  380. )
  381. # Ersten Track vorbereiten und abspielen
  382. first_track = tracks[0]
  383. prepared = await self._prepare_track(first_track)
  384. if not prepared:
  385. return IntentResult.failure(
  386. "Download fehlgeschlagen",
  387. response_text=f"Der Download von '{first_track.title}' ist fehlgeschlagen.",
  388. )
  389. service = self._music_service
  390. if not service:
  391. return IntentResult.failure(
  392. "MusicPlayerService nicht verfuegbar",
  393. response_text="Der Musik-Dienst ist gerade nicht verfuegbar.",
  394. )
  395. satellite_ids = self._get_target_satellites(satellite_id)
  396. success = await service.play_track(first_track, satellite_ids)
  397. if not success:
  398. return IntentResult.failure(
  399. "Wiedergabe fehlgeschlagen",
  400. response_text="Die Wiedergabe konnte nicht gestartet werden.",
  401. )
  402. # Weitere Tracks in die Queue (im Hintergrund vorbereiten)
  403. if len(tracks) > 1:
  404. import asyncio
  405. asyncio.create_task(
  406. self._queue_remaining_tracks(tracks[1:], satellite_ids)
  407. )
  408. # Antwort
  409. artist = first_track.metadata.display_artist
  410. title = first_track.metadata.display_title
  411. if len(tracks) > 1:
  412. if artist and artist != "Unbekannter Kuenstler":
  413. return IntentResult.success_with_response(
  414. f"Spiele '{title}' von {artist}. "
  415. f"{len(tracks) - 1} weitere Titel in der Warteschlange."
  416. )
  417. return IntentResult.success_with_response(
  418. f"Spiele '{title}'. "
  419. f"{len(tracks) - 1} weitere Titel in der Warteschlange."
  420. )
  421. else:
  422. if artist and artist != "Unbekannter Kuenstler":
  423. return IntentResult.success_with_response(
  424. f"Spiele '{title}' von {artist}."
  425. )
  426. return IntentResult.success_with_response(f"Spiele '{title}'.")
  427. async def _prepare_track(self, track: Track) -> bool:
  428. """Bereitet einen Track fuer die Wiedergabe vor (Download/Cache)."""
  429. source_map = {
  430. "youtube": self._youtube,
  431. "spotify": self._spotify,
  432. "soundcloud": self._soundcloud,
  433. "deezer": self._deezer,
  434. "radio": self._radio,
  435. }
  436. source = source_map.get(track.source_type)
  437. if source:
  438. if track.source_type not in ("radio",):
  439. pinfo(f"MusicPlugin: Lade {track.source_type.title()}-Track: {track.title}")
  440. return await source.prepare_track(track)
  441. elif track.source_type == "local":
  442. return True
  443. return True
  444. async def _queue_remaining_tracks(
  445. self, tracks: list, satellite_ids: list[str],
  446. ) -> None:
  447. """Bereitet weitere Tracks vor und fuegt sie zur Queue hinzu."""
  448. service = self._music_service
  449. if not service:
  450. return
  451. for track in tracks:
  452. try:
  453. prepared = await self._prepare_track(track)
  454. if not prepared:
  455. pdebug(f"MusicPlugin: Skip '{track.title}' (Download fehlgeschlagen)")
  456. continue
  457. if hasattr(service, "_player") and service._player:
  458. await service._player.add_to_queue(track)
  459. else:
  460. pdebug(f"MusicPlugin: Kein Player fuer Queue")
  461. pdebug(f"MusicPlugin: Zur Queue: {track.title}")
  462. except Exception as e:
  463. pdebug(f"MusicPlugin: Queue-Fehler fuer '{track.title}': {e}")
  464. def _get_music_dirs(self, local_config: dict) -> list[Path]:
  465. """Ermittelt die Musik-Verzeichnisse."""
  466. dirs: list[Path] = []
  467. # Konfigurierte Verzeichnisse
  468. for dir_str in local_config.get("music_dirs", []):
  469. path = Path(dir_str)
  470. if path.exists() and path.is_dir():
  471. dirs.append(path)
  472. # Assets-Musik als Fallback
  473. if hasattr(self.application, "server_config"):
  474. assets_dir = Path(self.application.server_config.assets_directory)
  475. profile = self.application.server_config.profile
  476. for search_dir in [
  477. assets_dir / profile / "music",
  478. assets_dir / "default" / "music",
  479. ]:
  480. if search_dir.exists() and search_dir not in dirs:
  481. dirs.append(search_dir)
  482. return dirs
  483. def _get_target_satellites(self, satellite_id: str) -> list[str]:
  484. """Ermittelt Ziel-Satellites fuer die Wiedergabe."""
  485. # Anfragenden Satellite bevorzugen
  486. if satellite_id:
  487. satellites = getattr(self.application, "satellites", None)
  488. if satellites:
  489. sat = satellites.get(satellite_id)
  490. if sat and sat.is_connected and sat.sockets.music_out:
  491. return [satellite_id]
  492. # Fallback: Alle verbundenen Satellites mit Musik-Socket
  493. satellites = getattr(self.application, "satellites", None)
  494. if satellites:
  495. return [
  496. sat.id for sat in satellites.all()
  497. if sat.is_connected and sat.sockets.music_out
  498. ]
  499. return []