refactor(music): MusicPlayer jako jedyny właściciel stanu odtwarzania

Logika odtwarzania wyprowadzona z coga Discorda do osobnej klasy. Dotąd
kolejkę modyfikowały dwie niezależne ścieżki: metody MusicBot oraz
endpointy w web.py sięgające po music_cog.queues[guild_id] i ustawiające
music_cog.jump_flag wprost. Teraz obie warstwy wołają MusicPlayer.

Przeniesione bez zmiany zachowania: enqueue, play_next wraz z arytmetyką
indeksu i limitem odtworzeń, remove, reorder, jump, sterowanie głośnością
oraz hook on_state_change rozgłaszający stan po WebSockecie.

Wybór kolejnego indeksu wydzielony do _next_index, dzięki czemu reguły
trybów zapętlania i przeskoku dają się czytać bez śledzenia całej metody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:10:28 +02:00
1 parent 4cc00618d1
commit a143cb21d3
1 file changed
+295
+295
View File
@@ -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)