refactor: track background tasks in application lifecycle and clean up codebase
Backend / Lint i formatowanie (pull_request) Successful in 15s
Backend / Testy i kontrakt API (pull_request) Successful in 26s
Panel / Lint, typy i build (pull_request) Failing after 36s
Backend / Testowy build obrazu (pull_request) Successful in 1m34s

This commit is contained in:
mmacierzanka committed 2026-09-13 16:58:46 +02:00
1 parent 4f39e9acb1
commit dadb5f8b0e
19 files changed
+71 -55

No files matched your search

+2
View File
@@ -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()
+3 -4
View File
@@ -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())
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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:
-1
View File
@@ -25,4 +25,3 @@ __all__ = [
"create_default_effects",
"get_available_effects_metadata",
]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Iterable
from collections.abc import Iterable
from zyrarbot.audio.effects.base import AudioEffect
-1
View File
@@ -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 [
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 -5
View File
@@ -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
+1 -1
View File
@@ -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)
-2
View File
@@ -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
View File
@@ -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:
+4 -1
View File
@@ -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ń",
-1
View File
@@ -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