feat: restore the last queue per server
This commit is contained in:
18 files changed
+237
-3
No files matched your search
@@ -20,6 +20,7 @@ from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
||||
from zyrarbot.storage.history_repository import HistoryRepository
|
||||
from zyrarbot.storage.json_file_store import JsonFileStore
|
||||
from zyrarbot.storage.queue_repository import QueueRepository
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
from zyrarbot.ui.api.factory import mount_api
|
||||
from zyrarbot.ui.bot.client import MusicBot
|
||||
@@ -42,6 +43,7 @@ class Application:
|
||||
JsonFileStore(self.settings.history_file), limit=self.settings.history_limit
|
||||
)
|
||||
self.uploads = UploadsRepository(self.settings.uploads_dir)
|
||||
self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues"))
|
||||
self.tracks = TrackFactory(self.uploads, MediaProbe())
|
||||
|
||||
self.bot = MusicBot(self.settings, self.tracks, self.uploads, self.config, self.history)
|
||||
@@ -53,6 +55,7 @@ class Application:
|
||||
config=self.config,
|
||||
channels=NotificationChannelResolver(self.bot, self.config),
|
||||
embeds=TrackEmbedFactory(),
|
||||
queues=self.queues,
|
||||
)
|
||||
self.bot.player = self.player
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from zyrarbot.music.notification_channel_resolver import NotificationChannelReso
|
||||
from zyrarbot.music.track_embed_factory import TrackEmbedFactory
|
||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
||||
from zyrarbot.storage.history_repository import HistoryRepository
|
||||
from zyrarbot.storage.queue_repository import QueueRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +39,7 @@ class MusicPlayer:
|
||||
config: GuildConfigRepository,
|
||||
channels: NotificationChannelResolver,
|
||||
embeds: TrackEmbedFactory | None = None,
|
||||
queues: QueueRepository | None = None,
|
||||
):
|
||||
self._bot = bot
|
||||
self._tracks = track_factory
|
||||
@@ -45,7 +47,9 @@ class MusicPlayer:
|
||||
self._config = config
|
||||
self._channels = channels
|
||||
self._embeds = embeds or TrackEmbedFactory()
|
||||
self._queues = queues
|
||||
self._states: dict[int, GuildMusicState] = {}
|
||||
self._playback_tokens: dict[int, int] = {}
|
||||
self.on_state_change: StateChangeHook | None = None
|
||||
|
||||
# --- Stan ---------------------------------------------------------------
|
||||
@@ -89,8 +93,78 @@ class MusicPlayer:
|
||||
return bool(voice_client and (voice_client.is_playing() or voice_client.is_paused()))
|
||||
|
||||
def clear(self, guild_id: int) -> None:
|
||||
self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1
|
||||
self.state(guild_id).reset()
|
||||
|
||||
def has_saved_queue(self, guild_id: int) -> bool:
|
||||
return bool(self._queues and self._queues.exists(guild_id))
|
||||
|
||||
def save_queue(self, guild_id: int) -> None:
|
||||
if not self._queues:
|
||||
return
|
||||
state = self.state(guild_id)
|
||||
self._queues.save(guild_id, [self._track_snapshot(track) for track in state.queue])
|
||||
|
||||
async def restore_queue(self, guild_id: int) -> int:
|
||||
if not self._queues:
|
||||
return 0
|
||||
|
||||
state = self.state(guild_id)
|
||||
playback_token = self._playback_tokens.get(guild_id, 0) + 1
|
||||
self._playback_tokens[guild_id] = playback_token
|
||||
voice_client = self.voice_client(guild_id)
|
||||
if voice_client and (voice_client.is_playing() or voice_client.is_paused()):
|
||||
voice_client.stop()
|
||||
state.queue = []
|
||||
state.current = None
|
||||
state.index = -1
|
||||
state.jump_pending = False
|
||||
|
||||
restored: list[YTDLSource] = []
|
||||
for snapshot in self._queues.load(guild_id):
|
||||
try:
|
||||
if snapshot.get("is_local"):
|
||||
track = await self._tracks.from_file(
|
||||
str(snapshot.get("filepath") or ""),
|
||||
title=snapshot.get("title"),
|
||||
)
|
||||
else:
|
||||
track = await self._tracks.from_url(
|
||||
str(snapshot.get("url") or ""), loop=self._bot.loop
|
||||
)
|
||||
if snapshot.get("id"):
|
||||
track.id = str(snapshot["id"])
|
||||
track.plays_left = snapshot.get("plays_left")
|
||||
restored.append(track)
|
||||
except Exception as error: # noqa: BLE001 - pomijamy niedostępny utwór
|
||||
logger.error(
|
||||
"Nie udało się przywrócić utworu %s na serwerze %s: %s",
|
||||
snapshot.get("title", ""),
|
||||
guild_id,
|
||||
error,
|
||||
)
|
||||
|
||||
state.queue = restored
|
||||
state.current = None
|
||||
state.index = -1
|
||||
state.jump_pending = False
|
||||
if restored and self.voice_client(guild_id):
|
||||
await self.play_next(guild_id, advance=False)
|
||||
await self.trigger_state_change()
|
||||
return len(restored)
|
||||
|
||||
@staticmethod
|
||||
def _track_snapshot(track: YTDLSource) -> dict[str, Any]:
|
||||
return {
|
||||
"id": track.id,
|
||||
"title": track.title,
|
||||
"url": track.display_url,
|
||||
"duration": track.duration,
|
||||
"plays_left": track.plays_left,
|
||||
"is_local": track.is_local,
|
||||
"filepath": track.filepath,
|
||||
}
|
||||
|
||||
def set_volume(self, guild_id: int, volume: float) -> None:
|
||||
self.state(guild_id).volume = volume
|
||||
voice_client = self.voice_client(guild_id)
|
||||
@@ -191,6 +265,7 @@ class MusicPlayer:
|
||||
async def play_next(self, guild_id: int, advance: bool = True, _failures: int = 0) -> None:
|
||||
"""Przechodzi do kolejnego utworu; ``_failures`` chroni przed pętlą pomijania."""
|
||||
state = self.state(guild_id)
|
||||
playback_token = self._playback_tokens.get(guild_id, 0)
|
||||
voice_client = self.voice_client(guild_id)
|
||||
if not voice_client:
|
||||
state.current = None
|
||||
@@ -258,7 +333,10 @@ class MusicPlayer:
|
||||
self._record_history(guild_id, track)
|
||||
await self.trigger_state_change()
|
||||
|
||||
voice_client.play(track, after=lambda error: self._schedule_next(guild_id, error))
|
||||
voice_client.play(
|
||||
track,
|
||||
after=lambda error: self._schedule_next(guild_id, error, playback_token),
|
||||
)
|
||||
|
||||
total = len(state.queue)
|
||||
embed = self._embeds.now_playing(track, position=next_index + 1, total=total)
|
||||
@@ -290,7 +368,9 @@ class MusicPlayer:
|
||||
return (index + 1) % len(state.queue)
|
||||
return index + 1
|
||||
|
||||
def _schedule_next(self, guild_id: int, error: Exception | None) -> None:
|
||||
def _schedule_next(self, guild_id: int, error: Exception | None, playback_token: int) -> None:
|
||||
if self._playback_tokens.get(guild_id) != playback_token:
|
||||
return
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from zyrarbot.storage.json_file_store import JsonFileStore
|
||||
|
||||
|
||||
class QueueRepository:
|
||||
"""Persists the last queue snapshot in a separate file per Discord guild."""
|
||||
|
||||
def __init__(self, directory: str):
|
||||
self._directory = directory
|
||||
self._stores: dict[int, JsonFileStore] = {}
|
||||
|
||||
def _store(self, guild_id: int) -> JsonFileStore:
|
||||
if guild_id not in self._stores:
|
||||
self._stores[guild_id] = JsonFileStore(
|
||||
os.path.join(self._directory, f"{guild_id}.json")
|
||||
)
|
||||
return self._stores[guild_id]
|
||||
|
||||
def save(self, guild_id: int, tracks: list[dict[str, Any]]) -> None:
|
||||
self._store(guild_id).set("tracks", tracks)
|
||||
|
||||
def load(self, guild_id: int) -> list[dict[str, Any]]:
|
||||
tracks = self._store(guild_id).get("tracks", [])
|
||||
return tracks if isinstance(tracks, list) else []
|
||||
|
||||
def exists(self, guild_id: int) -> bool:
|
||||
return bool(self.load(guild_id))
|
||||
@@ -133,6 +133,7 @@ async def stop(
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
player.save_queue(guild.id)
|
||||
if player.is_active(guild.id):
|
||||
player.voice_client(guild.id).stop()
|
||||
player.clear(guild.id)
|
||||
|
||||
@@ -23,6 +23,19 @@ from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
||||
router = APIRouter(prefix="/api", tags=["queue"])
|
||||
|
||||
|
||||
@router.post("/queue/restore")
|
||||
async def restore_queue(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, int | str]:
|
||||
count = await player.restore_queue(guild.id)
|
||||
if count == 0:
|
||||
raise HTTPException(status_code=404, detail="No saved queue for this server")
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "restored", "count": count}
|
||||
|
||||
|
||||
@router.post("/default_plays")
|
||||
async def set_default_plays(
|
||||
request: DefaultPlaysRequest,
|
||||
|
||||
@@ -90,6 +90,7 @@ async def disconnect(
|
||||
if not voice_client:
|
||||
raise HTTPException(status_code=400, detail="Bot not connected to voice on this server")
|
||||
|
||||
player.save_queue(guild.id)
|
||||
await voice_client.disconnect()
|
||||
player.clear(guild.id)
|
||||
await broadcaster.broadcast()
|
||||
|
||||
@@ -31,6 +31,7 @@ class StatusService:
|
||||
"default_plays": self._player.guild_default_plays(guild.id)
|
||||
if guild
|
||||
else self._player.default_plays,
|
||||
"saved_queue_available": self._player.has_saved_queue(guild.id) if guild else False,
|
||||
}
|
||||
if guild is None:
|
||||
payload.update(self._idle_payload())
|
||||
@@ -55,6 +56,7 @@ class StatusService:
|
||||
"active_voice_channel": None,
|
||||
"text_channels": [],
|
||||
"active_text_channel_id": None,
|
||||
"saved_queue_available": False,
|
||||
}
|
||||
|
||||
def _guild_payload(self, guild: discord.Guild) -> dict[str, Any]:
|
||||
|
||||
@@ -49,6 +49,7 @@ class ControlCog(MusicCogBase, name="Control"):
|
||||
async def stop(self, interaction: discord.Interaction) -> None:
|
||||
guild_id = interaction.guild.id
|
||||
voice_client = interaction.guild.voice_client
|
||||
self.player.save_queue(guild_id)
|
||||
if voice_client and voice_client.is_playing():
|
||||
voice_client.stop()
|
||||
self.player.clear(guild_id)
|
||||
|
||||
@@ -23,6 +23,7 @@ class VoiceCog(MusicCogBase, name="Voice"):
|
||||
and after.channel is None
|
||||
)
|
||||
if is_bot_leaving:
|
||||
self.player.save_queue(before.channel.guild.id)
|
||||
self.player.clear(before.channel.guild.id)
|
||||
await self.player.trigger_state_change()
|
||||
|
||||
@@ -33,6 +34,7 @@ class VoiceCog(MusicCogBase, name="Voice"):
|
||||
"Nie jestem na kanale głosowym.", ephemeral=True
|
||||
)
|
||||
return
|
||||
self.player.save_queue(interaction.guild.id)
|
||||
await interaction.guild.voice_client.disconnect()
|
||||
self.player.clear(interaction.guild.id)
|
||||
await interaction.response.send_message("Rozłączono z kanału głosowego.")
|
||||
Reference in new issue
Block a user