From 4cc00618d1876833cae71bbd169c880c291c44ef Mon Sep 17 00:00:00 2001 From: lgoose Date: Fri, 21 Aug 2026 01:10:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(music):=20stan=20serwera,=20tryb=20zap?= =?UTF-8?q?=C4=99tlania=20i=20budowanie=20powiadomie=C5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GuildMusicState zbiera stan jednego serwera w jedną strukturę. Dotąd było to dziewięć równoległych słowników indeksowanych guild_id (queues, currents, volumes, loop_currents, loop_playlists, last_channels, history, queue_indices, jump_flag), co wymagało pamiętania, że przy każdej zmianie trzeba ruszyć kilka z nich naraz. - LoopMode zastępuje parę booleanów loop_current/loop_playlist, których kombinacja "oba włączone" nie miała sensu, a mimo to była osiągalna. Wartości enuma (off/playlist/single) odpowiadają kontraktowi API panelu. - TrackEmbedFactory zbiera budowanie osadzeń Discorda, powielone dotąd w enqueue_track i play_next. - NotificationChannelResolver opisuje kolejność szukania kanału logów: zapamiętany -> z konfiguracji -> pierwszy zapisywalny na serwerze. Co-Authored-By: Claude Opus 5 --- zyrarbot/music/guild_music_state.py | 43 +++++++++++++++++ zyrarbot/music/loop_mode.py | 31 ++++++++++++ .../music/notification_channel_resolver.py | 48 +++++++++++++++++++ zyrarbot/music/track_embed_factory.py | 43 +++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 zyrarbot/music/guild_music_state.py create mode 100644 zyrarbot/music/loop_mode.py create mode 100644 zyrarbot/music/notification_channel_resolver.py create mode 100644 zyrarbot/music/track_embed_factory.py diff --git a/zyrarbot/music/guild_music_state.py b/zyrarbot/music/guild_music_state.py new file mode 100644 index 0000000..2a08d07 --- /dev/null +++ b/zyrarbot/music/guild_music_state.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +import discord + +from zyrarbot.audio.ytdl_source import YTDLSource +from zyrarbot.music.loop_mode import LoopMode + + +@dataclass +class GuildMusicState: + """Stan odtwarzania jednego serwera Discord. + + Kolejka jest nieusuwalna przy odtwarzaniu - pozycję śledzi ``index``, + dzięki czemu można swobodnie przeskakiwać i przewijać w tył. + """ + + guild_id: int + queue: list[YTDLSource] = field(default_factory=list) + current: YTDLSource | None = None + index: int = -1 + volume: float = 1.0 + loop_mode: LoopMode = LoopMode.OFF + jump_pending: bool = False + notification_channel: discord.abc.Messageable | None = None + + def reset(self) -> None: + self.queue.clear() + self.current = None + self.index = -1 + + def find(self, track_id: str) -> YTDLSource | None: + return next((track for track in self.queue if track.id == track_id), None) + + def position_of(self, track_id: str) -> int: + return next((idx for idx, track in enumerate(self.queue) if track.id == track_id), -1) + + def consume_jump(self) -> bool: + """Zwraca i czyści flagę przeskoku ustawioną przez panel webowy.""" + pending = self.jump_pending + self.jump_pending = False + return pending diff --git a/zyrarbot/music/loop_mode.py b/zyrarbot/music/loop_mode.py new file mode 100644 index 0000000..cd7186e --- /dev/null +++ b/zyrarbot/music/loop_mode.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from enum import Enum + + +class LoopMode(Enum): + """Tryb zapętlania w cyklu: wyłączone -> cała kolejka -> jeden utwór.""" + + OFF = "off" + QUEUE = "playlist" + TRACK = "single" + + @property + def loops_track(self) -> bool: + return self is LoopMode.TRACK + + @property + def loops_queue(self) -> bool: + return self is LoopMode.QUEUE + + def next(self) -> LoopMode: + order = (LoopMode.OFF, LoopMode.QUEUE, LoopMode.TRACK) + return order[(order.index(self) + 1) % len(order)] + + @property + def label(self) -> str: + return { + LoopMode.OFF: "➡️ Tryb zapętlania: **Wyłączone**", + LoopMode.QUEUE: "🔁 Tryb zapętlania: **Cała kolejka (playlista)**", + LoopMode.TRACK: "🔂 Tryb zapętlania: **Jeden utwór**", + }[self] diff --git a/zyrarbot/music/notification_channel_resolver.py b/zyrarbot/music/notification_channel_resolver.py new file mode 100644 index 0000000..68370ca --- /dev/null +++ b/zyrarbot/music/notification_channel_resolver.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import discord +from discord.ext import commands + +from zyrarbot.storage.guild_config_repository import GuildConfigRepository + + +class NotificationChannelResolver: + """Wskazuje kanał tekstowy, na który trafiają powiadomienia o odtwarzaniu. + + Kolejność: kanał zapamiętany w stanie -> kanał z konfiguracji -> pierwszy + kanał serwera, na którym bot może pisać. + """ + + def __init__(self, bot: commands.Bot, config: GuildConfigRepository): + self._bot = bot + self._config = config + + def resolve( + self, guild_id: int, remembered: discord.abc.Messageable | None + ) -> discord.abc.Messageable | None: + if remembered: + return remembered + + saved_id = self._config.get_notification_channel_id(guild_id) + if saved_id: + channel = self._bot.get_channel(saved_id) + if channel: + return channel + + return self.first_writable(self._bot.get_guild(guild_id)) + + @staticmethod + def first_writable(guild: discord.Guild | None) -> discord.TextChannel | None: + for channel in NotificationChannelResolver.writable_channels(guild): + return channel + return None + + @staticmethod + def writable_channels(guild: discord.Guild | None) -> list[discord.TextChannel]: + if not guild or not guild.me: + return [] + return [ + channel + for channel in guild.text_channels + if channel.permissions_for(guild.me).send_messages + ] diff --git a/zyrarbot/music/track_embed_factory.py b/zyrarbot/music/track_embed_factory.py new file mode 100644 index 0000000..893390e --- /dev/null +++ b/zyrarbot/music/track_embed_factory.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import discord + +from zyrarbot.audio.ytdl_source import YTDLSource + +AUTHOR_ICON = "https://cdn-icons-png.flaticon.com/512/3844/3844724.png" + + +class TrackEmbedFactory: + """Buduje osadzenia Discorda opisujące utwór dodany do kolejki lub odtwarzany.""" + + def queued(self, track: YTDLSource, *, position: int, from_web: bool) -> discord.Embed: + embed = self._base(track, discord.Color.blue()) + source = "Dodano do kolejki z panelu Web" if from_web else "Dodano do kolejki" + embed.set_author(name=source, icon_url=AUTHOR_ICON) + embed.set_footer(text=f"Pozycja w kolejce: {position}") + return embed + + def now_playing(self, track: YTDLSource, *, position: int, total: int) -> discord.Embed: + embed = self._base(track, discord.Color.green(), prefix="▶️") + embed.set_footer(text=f"Pozycja w kolejce: {position} / {total}") + return embed + + def _base(self, track: YTDLSource, color: discord.Color, *, prefix: str = "🎵") -> discord.Embed: + title = discord.utils.escape_markdown(track.title or "Nieznany tytuł") + url = track.display_url + icon = "📁" if track.is_local else prefix + description = ( + f"[Kliknij tutaj, aby otworzyć link]({url})" + if track.has_web_link + else "Plik lokalny / brak linku" + ) + embed = discord.Embed( + title=f"{icon} {title}", + url=url if track.has_web_link else None, + description=description, + color=color, + ) + if track.duration: + minutes, seconds = divmod(int(track.duration), 60) + embed.add_field(name="Czas trwania", value=f"{minutes}:{seconds:02d}", inline=True) + return embed