refactor: track background tasks in application lifecycle and clean up codebase
This commit is contained in:
19 files changed
+71
-55
No files matched your search
@@ -76,6 +76,7 @@ class ApiSurfaceTest(unittest.TestCase):
|
||||
def test_restart_endpoint_calls_restart_state(self):
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from zyrarbot.ui.api.routers.status_router import restart_bot
|
||||
|
||||
mock_request = MagicMock()
|
||||
@@ -88,6 +89,7 @@ class ApiSurfaceTest(unittest.TestCase):
|
||||
|
||||
def test_voice_channels_payload_filters_unconnectable_channels(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from zyrarbot.ui.api.status_service import StatusService
|
||||
|
||||
mock_guild = MagicMock()
|
||||
|
||||
@@ -60,11 +60,10 @@ class TestAudioEffects(unittest.TestCase):
|
||||
fx = ToasterEffect(bit_depth=5, downsample=6)
|
||||
pcm = struct.pack("<hh", 12345, -23456)
|
||||
out = fx.process(pcm)
|
||||
l, r = struct.unpack("<hh", out)
|
||||
left, right = struct.unpack("<hh", out)
|
||||
# Low 11 bits are zeroed due to 5-bit quantization
|
||||
self.assertEqual(l & 0x07FF, 0)
|
||||
self.assertEqual(r & 0x07FF, 0)
|
||||
|
||||
self.assertEqual(left & 0x07FF, 0)
|
||||
self.assertEqual(right & 0x07FF, 0)
|
||||
|
||||
def test_pipeline_chains_multiple_effects_and_applies_volume(self):
|
||||
pipeline = AudioEffectsPipeline(create_default_effects())
|
||||
|
||||
@@ -40,8 +40,9 @@ class ShutdownVoiceCleanupTests(unittest.IsolatedAsyncioTestCase):
|
||||
mock_guild.me.voice.channel = MagicMock()
|
||||
mock_guild.change_voice_state = AsyncMock()
|
||||
|
||||
with patch.object(type(self.bot), "voice_clients", [mock_vc]), patch.object(
|
||||
type(self.bot), "guilds", [mock_guild]
|
||||
with (
|
||||
patch.object(type(self.bot), "voice_clients", [mock_vc]),
|
||||
patch.object(type(self.bot), "guilds", [mock_guild]),
|
||||
):
|
||||
await self.bot.disconnect_all_voice()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import discord
|
||||
|
||||
@@ -62,6 +61,5 @@ class TestYTDLSource(unittest.TestCase):
|
||||
self.assertEqual(right, -32768)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+13
-11
@@ -5,7 +5,8 @@ import logging
|
||||
import os
|
||||
import signal
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
@@ -67,6 +68,7 @@ class Application:
|
||||
queues=self.queues,
|
||||
)
|
||||
self.bot.player = self.player
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
self.web = FastAPI(title="ŻyrarBOT")
|
||||
self.web.state.restart = self.schedule_restart
|
||||
@@ -113,7 +115,9 @@ class Application:
|
||||
|
||||
def schedule_restart(self, delay: float = 0.5) -> None:
|
||||
"""Zaplanuj restart bota (zakończenie procesu Pythona)."""
|
||||
asyncio.create_task(self._restart_worker(delay))
|
||||
task = asyncio.create_task(self._restart_worker(delay))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def _restart_worker(self, delay: float) -> None:
|
||||
logger.info("Zaplanowano restart aplikacji za %.1f s...", delay)
|
||||
@@ -150,13 +154,13 @@ class Application:
|
||||
logger.info("Odebrano sygnał zakończenia, zamykanie serwera i bota...")
|
||||
server.should_exit = True
|
||||
if not self.bot.is_closed():
|
||||
asyncio.create_task(self.bot.close())
|
||||
task = asyncio.create_task(self.bot.close())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
with suppress(NotImplementedError, RuntimeError):
|
||||
loop.add_signal_handler(sig, _handle_shutdown_signal)
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
async with self.bot:
|
||||
server_task = asyncio.create_task(server.serve(), name="web-server")
|
||||
@@ -169,7 +173,7 @@ class Application:
|
||||
bot_task = asyncio.create_task(run_bot(), name="discord-bot")
|
||||
|
||||
try:
|
||||
done, pending = await asyncio.wait(
|
||||
done, _ = await asyncio.wait(
|
||||
[server_task, bot_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
@@ -181,7 +185,7 @@ class Application:
|
||||
await self.bot.close()
|
||||
try:
|
||||
await bot_task
|
||||
except (asyncio.CancelledError, Exception) as error:
|
||||
except (asyncio.CancelledError, Exception) as error: # noqa: BLE001
|
||||
logger.debug("Zakończenie zadania bota: %s", error)
|
||||
|
||||
elif bot_task in done:
|
||||
@@ -203,10 +207,8 @@ class Application:
|
||||
await server_task
|
||||
if not bot_task.done():
|
||||
bot_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await bot_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_shutdown(server: uvicorn.Server) -> None:
|
||||
|
||||
@@ -25,4 +25,3 @@ __all__ = [
|
||||
"create_default_effects",
|
||||
"get_available_effects_metadata",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,6 @@ class AudioEffect(ABC):
|
||||
"""Przetwarza ramkę surowego PCM (16-bit signed stereo little-endian)."""
|
||||
...
|
||||
|
||||
def reset(self) -> None:
|
||||
def reset(self) -> None: # noqa: B027
|
||||
"""Resetuje ewentualny stan wewnętrzny filtra/efektu."""
|
||||
pass
|
||||
@@ -7,7 +7,7 @@ from zyrarbot.audio.effects.base import AudioEffect
|
||||
|
||||
|
||||
class FourthDensityEffect(AudioEffect):
|
||||
"""Efekt 'czwartej gęstości' – płynna sinusoidalna modulacja wysokości dźwięku (pitch) z okresem ~1s."""
|
||||
"""Efekt 'czwartej gęstości' - modulacja wysokości dźwięku (pitch) z okresem ~1s."""
|
||||
|
||||
id = "fourth_density"
|
||||
name = "Czwarta gęstość"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
from collections.abc import Iterable
|
||||
|
||||
from zyrarbot.audio.effects.base import AudioEffect
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ def create_default_effects() -> list[AudioEffect]:
|
||||
]
|
||||
|
||||
|
||||
|
||||
def get_available_effects_metadata() -> list[dict[str, Any]]:
|
||||
"""Zwraca metadane wszystkich dostępnych w systemie efektów."""
|
||||
return [
|
||||
|
||||
@@ -7,7 +7,7 @@ from zyrarbot.audio.effects.base import AudioEffect
|
||||
|
||||
|
||||
class RobotEffect(AudioEffect):
|
||||
"""Efekt metalicznego, robotycznego głosu oparty na ring modulation (modulacja pierścieniowa ~65Hz)."""
|
||||
"""Efekt metalicznego głosu cyborga oparty na ring modulation (~65Hz)."""
|
||||
|
||||
id = "robot"
|
||||
name = "Robot"
|
||||
|
||||
@@ -6,7 +6,7 @@ from zyrarbot.audio.effects.base import AudioEffect
|
||||
|
||||
|
||||
class ToasterEffect(AudioEffect):
|
||||
"""Efekt 'dźwięku z tostera / mikrofalówki' – memiczny lo-fi bitcrusher (5-bit quant + downsampling)."""
|
||||
"""Efekt 'dźwięku z tostera' - memiczny lo-fi bitcrusher (5-bit quant + downsample)."""
|
||||
|
||||
id = "toaster"
|
||||
name = "Dźwięk z tostera"
|
||||
|
||||
@@ -5,12 +5,11 @@ from typing import Any
|
||||
|
||||
import discord
|
||||
|
||||
FRAME_LENGTH_SECONDS = 0.02
|
||||
|
||||
|
||||
from zyrarbot.audio.effects.pipeline import AudioEffectsPipeline
|
||||
from zyrarbot.audio.effects.registry import create_default_effects
|
||||
|
||||
FRAME_LENGTH_SECONDS = 0.02
|
||||
|
||||
try:
|
||||
import audioop
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -41,7 +40,9 @@ class YTDLSource(discord.PCMVolumeTransformer):
|
||||
self.plays_left: int | None = data.get("plays_left")
|
||||
self.played: bool = False
|
||||
self.effects_pipeline: AudioEffectsPipeline = (
|
||||
effects_pipeline if effects_pipeline is not None else AudioEffectsPipeline(create_default_effects())
|
||||
effects_pipeline
|
||||
if effects_pipeline is not None
|
||||
else AudioEffectsPipeline(create_default_effects())
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -75,4 +76,3 @@ class YTDLSource(discord.PCMVolumeTransformer):
|
||||
if audioop is not None and self._volume != 1.0:
|
||||
return audioop.mul(data, 2, self._volume)
|
||||
return data
|
||||
|
||||
@@ -10,6 +10,7 @@ from zyrarbot.music.loop_mode import LoopMode
|
||||
if TYPE_CHECKING:
|
||||
from zyrarbot.audio.ytdl_source import YTDLSource
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuildMusicState:
|
||||
"""Stan odtwarzania jednego serwera Discord.
|
||||
@@ -34,7 +35,6 @@ class GuildMusicState:
|
||||
self.index = -1
|
||||
self.active_effects.clear()
|
||||
|
||||
|
||||
def find(self, track_id: str) -> YTDLSource | None:
|
||||
return next((track for track in self.queue if track.id == track_id), None)
|
||||
|
||||
|
||||
@@ -241,8 +241,6 @@ class MusicPlayer:
|
||||
self.state(guild_id).active_effects.clear()
|
||||
self._apply_volume(guild_id)
|
||||
|
||||
|
||||
|
||||
def set_loop_mode(self, guild_id: int, mode: LoopMode) -> None:
|
||||
self.state(guild_id).loop_mode = mode
|
||||
|
||||
|
||||
@@ -194,7 +194,6 @@ async def toggle_effect(
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router.post("/loop")
|
||||
async def set_loop(
|
||||
request: LoopRequest,
|
||||
|
||||
+35
-18
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
@@ -52,13 +54,13 @@ class MusicBot(commands.Bot):
|
||||
self.player: MusicPlayer | None = None
|
||||
self._restart_callback = restart_callback
|
||||
self._is_closing = False
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
def schedule_restart(self) -> None:
|
||||
"""Żąda restartu bota za pomocą zarejestrowanego wywołania zwrotnego."""
|
||||
if self._restart_callback is not None:
|
||||
self._restart_callback()
|
||||
else:
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
async def _default_restart() -> None:
|
||||
@@ -66,7 +68,9 @@ class MusicBot(commands.Bot):
|
||||
await self.close()
|
||||
os._exit(0)
|
||||
|
||||
asyncio.create_task(_default_restart())
|
||||
task = asyncio.create_task(_default_restart())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
def _create_music_cogs(self) -> tuple[commands.Cog, ...]:
|
||||
if self.player is None:
|
||||
@@ -112,30 +116,42 @@ class MusicBot(commands.Bot):
|
||||
try:
|
||||
self.player.save_queue(guild_id)
|
||||
self.player.clear(guild_id)
|
||||
except Exception as err:
|
||||
logger.warning("Błąd podczas czyszczenia odtwarzacza dla serwera %s: %s", guild_id, err)
|
||||
except Exception as err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Błąd podczas czyszczenia odtwarzacza dla serwera %s: %s",
|
||||
guild_id,
|
||||
err,
|
||||
)
|
||||
if vc.is_connected():
|
||||
await asyncio.wait_for(vc.disconnect(force=False), timeout=2.0)
|
||||
except Exception as error:
|
||||
logger.warning("Błąd podczas łagodnego odłączania z kanału %s, wymuszanie: %s", getattr(vc, "channel", None), error)
|
||||
try:
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Błąd podczas łagodnego odłączania z kanału %s, wymuszanie: %s",
|
||||
getattr(vc, "channel", None),
|
||||
error,
|
||||
)
|
||||
with suppress(Exception):
|
||||
await vc.disconnect(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.voice_clients:
|
||||
await asyncio.gather(*[_disconnect_vc(vc) for vc in list(self.voice_clients)], return_exceptions=True)
|
||||
await asyncio.gather(
|
||||
*[_disconnect_vc(vc) for vc in list(self.voice_clients)], return_exceptions=True
|
||||
)
|
||||
|
||||
async def _clear_guild_voice(guild: discord.Guild) -> None:
|
||||
try:
|
||||
if guild.me and guild.me.voice and guild.me.voice.channel:
|
||||
await guild.change_voice_state(channel=None)
|
||||
except Exception as error:
|
||||
logger.warning("Błąd podczas czyszczenia stanu głosowego na serwerze %s: %s", guild.name, error)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Błąd podczas czyszczenia stanu głosowego na serwerze %s: %s", guild.name, error
|
||||
)
|
||||
|
||||
# Dla każdego serwera upewnij się przez gateway, że stan głosowy bota to None
|
||||
if self.guilds:
|
||||
await asyncio.gather(*[_clear_guild_voice(guild) for guild in self.guilds], return_exceptions=True)
|
||||
await asyncio.gather(
|
||||
*[_clear_guild_voice(guild) for guild in self.guilds], return_exceptions=True
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Zamykanie bota: najpierw odłącz się od wszystkich kanałów głosowych."""
|
||||
@@ -144,7 +160,7 @@ class MusicBot(commands.Bot):
|
||||
self._is_closing = True
|
||||
try:
|
||||
await self.disconnect_all_voice()
|
||||
except Exception as error:
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Błąd podczas odłączania bota przed zamknięciem: %s", error)
|
||||
await super().close()
|
||||
|
||||
@@ -155,12 +171,13 @@ class MusicBot(commands.Bot):
|
||||
self.config.migrate_legacy(first_guild_id)
|
||||
self.history.migrate_legacy(first_guild_id)
|
||||
|
||||
# Jeśli po uruchomieniu bot z jakiegoś powodu znajduje się w kanale głosowym (np. po awaryjnym restarcie),
|
||||
# odłącz go, aby stan był czysty i zgodny z API.
|
||||
# Jeśli po uruchomieniu bot z jakiegoś powodu znajduje się w kanale głosowym,
|
||||
# odłącz go, aby stan był czysty i zgodny z API (np. po awaryjnym restarcie).
|
||||
for guild in self.guilds:
|
||||
if guild.me and guild.me.voice and guild.me.voice.channel:
|
||||
logger.warning(
|
||||
"Wykryto wiszące połączenie głosowe po starcie na serwerze %s (%s). Odłączanie...",
|
||||
"Wykryto wiszące połączenie głosowe po starcie na serwerze %s (%s). "
|
||||
"Odłączanie...",
|
||||
guild.name,
|
||||
guild.me.voice.channel.name,
|
||||
)
|
||||
@@ -169,7 +186,7 @@ class MusicBot(commands.Bot):
|
||||
await guild.voice_client.disconnect(force=True)
|
||||
else:
|
||||
await guild.change_voice_state(channel=None)
|
||||
except Exception as error:
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Błąd podczas czyszczenia wiszącego kanału głosowego: %s", error)
|
||||
|
||||
if self.user:
|
||||
|
||||
@@ -25,7 +25,10 @@ class MusicCogBase(commands.Cog):
|
||||
voice_client = interaction.guild.voice_client
|
||||
if voice_client is None:
|
||||
user_channel = interaction.user.voice.channel
|
||||
if interaction.guild.me and not user_channel.permissions_for(interaction.guild.me).connect:
|
||||
if (
|
||||
interaction.guild.me
|
||||
and not user_channel.permissions_for(interaction.guild.me).connect
|
||||
):
|
||||
await interaction.response.send_message(
|
||||
embed=message_embed(
|
||||
"Brak uprawnień",
|
||||
|
||||
@@ -136,7 +136,6 @@ class ControlCog(MusicCogBase, name="Control"):
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app_commands.command(name="nowplaying", description="Pokaż aktualnie odtwarzany utwór")
|
||||
async def now_playing(self, interaction: discord.Interaction) -> None:
|
||||
current = self.player.state(interaction.guild.id).current
|
||||
|
||||
Reference in new issue
Block a user