feat: restore the last queue per server
Backend / Lint i formatowanie (pull_request) Successful in 3s
Backend / Testy i kontrakt API (pull_request) Successful in 10s
Panel / Lint, typy i build (pull_request) Successful in 18s
Backend / Testowy build obrazu (pull_request) Successful in 15s

This commit is contained in:
mmacierzanka committed 2026-08-21 17:10:40 +02:00
1 parent 3624c152f5
commit d6bd302165
18 files changed
+237 -3

No files matched your search

+3
View File
@@ -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
+82 -2
View File
@@ -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)
+31
View File
@@ -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)
+13
View File
@@ -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,
+1
View File
@@ -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()
+2
View File
@@ -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]:
+1
View File
@@ -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)
+2
View File
@@ -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.")