refactor(music): stan serwera, tryb zapętlania i budowanie powiadomień

- 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 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:10:11 +02:00
1 parent 53ffae1663
commit 4cc00618d1
4 files changed
+165

No files matched your search

+43
View File
@@ -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
+31
View File
@@ -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]
@@ -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
]
+43
View File
@@ -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