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:
1 parent
4cc00618d1
commit
a143cb21d3
1 file changed
+295
@@ -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)
|
||||||
Reference in new issue
Block a user