diff --git a/zyrarbot/music/music_player.py b/zyrarbot/music/music_player.py new file mode 100644 index 0000000..a7a0a99 --- /dev/null +++ b/zyrarbot/music/music_player.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Awaitable, Callable + +import discord +from discord.ext import commands + +from zyrarbot.audio.track_factory import TrackFactory +from zyrarbot.audio.ytdl_source import YTDLSource +from zyrarbot.music.guild_music_state import GuildMusicState +from zyrarbot.music.history_entry import HistoryEntry +from zyrarbot.music.loop_mode import LoopMode +from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver +from zyrarbot.music.track_embed_factory import TrackEmbedFactory +from zyrarbot.storage.guild_config_repository import GuildConfigRepository +from zyrarbot.storage.history_repository import HistoryRepository + +logger = logging.getLogger(__name__) + +StateChangeHook = Callable[[], Awaitable[None] | None] + + +class MusicPlayer: + """Silnik odtwarzania: kolejki, przechodzenie między utworami i powiadomienia. + + Jest wspólny dla komend Discorda i panelu webowego - obie warstwy operują + wyłącznie na tym obiekcie, nigdy bezpośrednio na stanie serwerów. + """ + + def __init__( + self, + bot: commands.Bot, + track_factory: TrackFactory, + history: HistoryRepository, + config: GuildConfigRepository, + channels: NotificationChannelResolver, + embeds: TrackEmbedFactory | None = None, + ): + self._bot = bot + self._tracks = track_factory + self._history = history + self._config = config + self._channels = channels + self._embeds = embeds or TrackEmbedFactory() + self._states: dict[int, GuildMusicState] = {} + self.on_state_change: StateChangeHook | None = None + + # --- Stan --------------------------------------------------------------- + + def state(self, guild_id: int) -> GuildMusicState: + if guild_id not in self._states: + self._states[guild_id] = GuildMusicState(guild_id=guild_id, volume=1.0) + return self._states[guild_id] + + @property + def default_plays(self) -> int: + return self._config.get_default_plays() + + def set_default_plays(self, value: int) -> None: + self._config.set_default_plays(value) + + def plays_left(self, track: YTDLSource) -> int: + return track.plays_left if track.plays_left is not None else self.default_plays + + def history(self, guild_id: int) -> list[HistoryEntry]: + return self._history.list(guild_id) + + def find_history_entry(self, guild_id: int, entry_id: str) -> HistoryEntry | None: + return self._history.find(guild_id, entry_id) + + def voice_client(self, guild_id: int) -> discord.VoiceClient | None: + return discord.utils.get(self._bot.voice_clients, guild__id=guild_id) + + def is_active(self, guild_id: int) -> bool: + voice_client = self.voice_client(guild_id) + return bool(voice_client and (voice_client.is_playing() or voice_client.is_paused())) + + def clear(self, guild_id: int) -> None: + self.state(guild_id).reset() + + def set_volume(self, guild_id: int, volume: float) -> None: + self.state(guild_id).volume = volume + voice_client = self.voice_client(guild_id) + if voice_client and voice_client.source: + voice_client.source.volume = volume + + def set_loop_mode(self, guild_id: int, mode: LoopMode) -> None: + self.state(guild_id).loop_mode = mode + + def cycle_loop_mode(self, guild_id: int) -> LoopMode: + state = self.state(guild_id) + state.loop_mode = state.loop_mode.next() + return state.loop_mode + + def notification_channel(self, guild_id: int) -> discord.abc.Messageable | None: + state = self.state(guild_id) + channel = self._channels.resolve(guild_id, state.notification_channel) + state.notification_channel = channel + return channel + + def set_notification_channel( + self, guild_id: int, channel: discord.abc.Messageable | None + ) -> None: + self.state(guild_id).notification_channel = channel + + # --- Kolejka ------------------------------------------------------------ + + async def enqueue( + self, + guild_id: int, + track: YTDLSource, + *, + channel: discord.abc.Messageable | None = None, + from_web: bool = False, + ) -> dict[str, Any]: + state = self.state(guild_id) + if channel: + state.notification_channel = channel + if track.plays_left is None: + track.plays_left = self.default_plays + + state.queue.append(track) + + if not self.is_active(guild_id): + await self.play_next(guild_id, advance=False) + return {"status": "playing", "title": track.title} + + embed = self._embeds.queued(track, position=len(state.queue), from_web=from_web) + await self.notify(guild_id, embed=embed) + await self.trigger_state_change() + return {"status": "queued", "title": track.title} + + async def remove(self, guild_id: int, track_id: str) -> bool: + """Usuwa utwór z kolejki; usunięcie odtwarzanego przechodzi do następnego.""" + state = self.state(guild_id) + position = state.position_of(track_id) + if position == -1: + return False + + state.queue.pop(position) + if position < state.index: + state.index -= 1 + elif position == state.index: + # Indeks zostaje - wskazuje teraz utwór, który wskoczył na zwolnione miejsce. + state.jump_pending = True + await self._restart_playback(guild_id) + return True + + def reorder(self, guild_id: int, track_ids: list[str]) -> None: + state = self.state(guild_id) + playing = state.queue[state.index] if 0 <= state.index < len(state.queue) else None + + by_id = {track.id: track for track in state.queue} + reordered = [by_id[track_id] for track_id in track_ids if track_id in by_id] + reordered += [track for track in state.queue if track.id not in track_ids] + + state.queue = reordered + if playing is not None and playing in reordered: + state.index = reordered.index(playing) + + async def jump(self, guild_id: int, index: int) -> None: + state = self.state(guild_id) + if state.loop_mode.loops_track: + state.loop_mode = LoopMode.OFF + state.index = index + state.jump_pending = True + await self._restart_playback(guild_id) + + async def _restart_playback(self, guild_id: int) -> None: + voice_client = self.voice_client(guild_id) + if voice_client and (voice_client.is_playing() or voice_client.is_paused()): + voice_client.stop() # `after` wywoła play_next, flaga przeskoku zablokuje advance + else: + await self.play_next(guild_id, advance=False) + + # --- Odtwarzanie -------------------------------------------------------- + + async def play_next(self, guild_id: int, advance: bool = True) -> None: + state = self.state(guild_id) + voice_client = self.voice_client(guild_id) + if not voice_client: + state.current = None + return + + if state.consume_jump(): + advance = False + + if not state.queue: + state.index = -1 + state.current = None + return + + next_index = self._next_index(state, advance=advance) + if next_index >= len(state.queue): + state.index = next_index + state.current = None + await self.trigger_state_change() + await self.notify(guild_id, "Kolejka zakończona.") + return + + state.index = next_index + track = state.queue[next_index] + + if track.played: + try: + track = await self._tracks.recreate(track, loop=self._bot.loop) + except Exception as error: # noqa: BLE001 - dowolny błąd sieci/ffmpeg pomija utwór + logger.error("Nie udało się odświeżyć utworu %s: %s", track.title, error) + await self.notify( + guild_id, f"⚠️ Nie udało się odtworzyć: {track.title} ({error}). Pomijam." + ) + await self.play_next(guild_id, advance=True) + return + + track.played = True + track.plays_left = self.plays_left(track) - 1 + + if track.plays_left <= 0: + state.queue.pop(next_index) + state.index = next_index - 1 + if state.loop_mode.loops_track: + state.loop_mode = LoopMode.OFF + else: + state.queue[next_index] = track + + state.current = track + self._record_history(guild_id, track) + await self.trigger_state_change() + + voice_client.play(track, after=lambda error: self._schedule_next(guild_id, error)) + + total = len(state.queue) + embed = self._embeds.now_playing(track, position=next_index + 1, total=total) + await self.notify(guild_id, embed=embed) + + def _next_index(self, state: GuildMusicState, *, advance: bool) -> int: + index = state.index if state.index != -1 else 0 + + if not advance: + if 0 <= index < len(state.queue) and state.current is not None: + return index + return len(state.queue) - 1 + + if state.loop_mode.loops_track: + return index + if state.loop_mode.loops_queue: + return (index + 1) % len(state.queue) + return index + 1 + + def _schedule_next(self, guild_id: int, error: Exception | None) -> None: + if error: + logger.error("Odtwarzanie zakończone błędem na serwerze %s: %s", guild_id, error) + asyncio.run_coroutine_threadsafe( + self.play_next(guild_id, advance=True), self._bot.loop + ) + + def _record_history(self, guild_id: int, track: YTDLSource) -> None: + if not track.title: + return + self._history.add( + guild_id, + HistoryEntry( + id=track.id, + title=track.title, + url=track.display_url, + duration=track.duration, + is_local=track.is_local, + filepath=track.filepath, + ), + ) + + # --- Powiadomienia ------------------------------------------------------ + + async def notify( + self, guild_id: int, text: str | None = None, embed: discord.Embed | None = None + ) -> None: + channel = self.notification_channel(guild_id) + if not channel: + return + try: + await channel.send(content=text, embed=embed) + except discord.DiscordException as error: + logger.error("Nie udało się wysłać powiadomienia: %s", error) + + async def trigger_state_change(self) -> None: + if not self.on_state_change: + return + try: + result = self.on_state_change() + if asyncio.iscoroutine(result): + await result + except Exception as error: # noqa: BLE001 - hook nie może przerwać odtwarzania + logger.error("Błąd hooka on_state_change: %s", error)