release: ŻyrarBOT 5.0 (#5)
Panel / Lint, typy i build (push) Successful in 26s
Backend / Testy i kontrakt API (push) Successful in 30s
Backend / Lint i formatowanie (push) Successful in 31s
Backend / Testowy build obrazu (push) Successful in 1m31s

This commit was merged in pull request #5.
This commit is contained in:
mmacierzanka committed 2026-09-13 17:33:17 +02:00
commit d9565f6eae
42 files changed
+1563 -72

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
image:
name: Testowy build obrazu
runs-on: ubuntu-latest
runs-on: builder
needs: [lint, test]
steps:
- uses: actions/checkout@v4
+6
View File
@@ -40,6 +40,7 @@ function post<T>(path: string, body?: unknown): Promise<T> {
export const api = {
status: () => request<BotStatus>('/api/status'),
restart: () => post<{ status: string }>('/api/restart'),
connect: (channelId: string) => post('/api/connect', { channel_id: channelId }),
disconnect: () => post('/api/disconnect'),
@@ -54,6 +55,11 @@ export const api = {
skip: () => post('/api/skip'),
stop: () => post('/api/stop'),
setVolume: (volume: number) => post('/api/volume', { volume }),
toggleEffect: (effectId: string, enabled?: boolean) =>
post<{ status: string; effect_id: string; active: boolean; active_effects: string[] }>(
'/api/effects/toggle',
{ effect_id: effectId, enabled },
),
cycleLoop: () => post<{ mode: 'off' | 'playlist' | 'single' }>('/api/loop_cycle'),
jump: (index: number) => post('/api/jump', { index }),
+1 -1
View File
@@ -25,7 +25,7 @@ async function submit() {
busy.value = true
const guildId = props.status.active_voice_channel.guild_id
const pendingId = addPendingTrack(guildId, trimmed)
const added = await run(() => api.play(trimmed), 'Dodano utwór do kolejki!', 'Błąd dodawania')
const added = await run(() => api.play(trimmed), '', 'Błąd dodawania')
removePendingTrack(pendingId)
if (added) query.value = ''
busy.value = false
+88 -3
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { RotateCw } from 'lucide-vue-next'
import type { BotStatus, GuildSettings } from '@/types/status'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
const props = defineProps<{
status: BotStatus | null
@@ -9,6 +12,11 @@ const props = defineProps<{
}>()
defineEmits<{ selectGuild: [guildId: string] }>()
const { notify } = useToasts()
const isRestarting = ref(false)
const restartInitiatedAt = ref<number | null>(null)
let restartTimer: number | null = null
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
const label = computed(() =>
@@ -16,6 +24,43 @@ const label = computed(() =>
? `Połączony: ${activeChannel.value.channel_name}`
: 'Rozłączony',
)
async function handleRestart() {
if (isRestarting.value) return
if (!window.confirm('Czy na pewno chcesz zrestartować bota?')) return
isRestarting.value = true
restartInitiatedAt.value = Date.now()
notify('Wysyłanie polecenia restartu...', 'info')
try {
await api.restart()
notify('Wysłano polecenie. Restartowanie bota...', 'info')
} catch {
notify('Wysłano polecenie restartu.', 'info')
}
if (restartTimer !== null) clearTimeout(restartTimer)
restartTimer = window.setTimeout(() => {
isRestarting.value = false
restartInitiatedAt.value = null
}, 15000)
}
watch(
() => props.status,
() => {
if (isRestarting.value && restartInitiatedAt.value && Date.now() - restartInitiatedAt.value > 3000) {
isRestarting.value = false
restartInitiatedAt.value = null
if (restartTimer !== null) {
clearTimeout(restartTimer)
restartTimer = null
}
notify('Bot został uruchomiony ponownie!', 'success')
}
},
)
</script>
<template>
@@ -56,6 +101,19 @@ const label = computed(() =>
/>
<span>{{ label }}</span>
</div>
<button
type="button"
class="btn-secondary btn-restart"
:disabled="isRestarting"
:title="isRestarting ? 'Restartowanie bota...' : 'Zrestartuj bota'"
@click="handleRestart"
>
<RotateCw
:size="16"
:class="{ 'spin-icon': isRestarting }"
/>
<span>{{ isRestarting ? 'Restart...' : 'Restart' }}</span>
</button>
</div>
</header>
</template>
@@ -67,10 +125,32 @@ const label = computed(() =>
gap: 16px;
}
.btn-secondary {
display: flex;
.btn-restart {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 30px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition-smooth);
}
.btn-restart:hover:not(:disabled) {
border-color: rgba(239, 68, 68, 0.4);
color: #fca5a5;
background: rgba(239, 68, 68, 0.12);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.2);
}
.btn-restart:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.spin-icon {
animation: spin 1s linear infinite;
}
@media (max-width: 768px) {
@@ -85,5 +165,10 @@ const label = computed(() =>
flex-direction: column;
align-items: flex-start;
}
.btn-restart {
width: 100%;
justify-content: center;
}
}
</style>
+1 -1
View File
@@ -34,7 +34,7 @@ async function replay(entry: HistoryTrack) {
notify('Ten wpis historii nie ma źródła do odtworzenia', 'error')
return
}
await run(() => api.play(entry.url as string), 'Dodano z historii do kolejki!', 'Błąd')
await run(() => api.play(entry.url as string), '', 'Błąd')
} finally {
removePendingTrack(pendingId)
}
+178 -14
View File
@@ -1,23 +1,32 @@
<script setup lang="ts">
import { computed, ref, toRef, watch } from 'vue'
import { computed, ref, toRef, watch, type Component } from 'vue'
import {
Activity,
Bot,
ExternalLink,
Music4,
Pause,
Play,
Radio,
Repeat,
Repeat1,
SkipBack,
SkipForward,
SlidersHorizontal,
Sparkles,
Square,
Volume,
Volume1,
Volume2,
VolumeX,
Waves,
Zap,
} from 'lucide-vue-next'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import { formatTime, useTrackProgress } from '@/composables/useTrackProgress'
import { loopModeOf, type BotStatus } from '@/types/status'
import { loopModeOf, type BotStatus, type PlayerEffect } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
@@ -86,6 +95,31 @@ async function commitVolume() {
draggedVolume.value = null
}
const EFFECT_ICONS: Record<string, Component> = {
zap: Zap,
sparkles: Sparkles,
waves: Waves,
activity: Activity,
bot: Bot,
radio: Radio,
}
function effectIcon(iconName: string) {
return EFFECT_ICONS[iconName] ?? Zap
}
const effects = computed<PlayerEffect[]>(() => props.status?.effects ?? [])
async function onToggleEffect(fx: PlayerEffect) {
const next = !fx.active
await run(
() => api.toggleEffect(fx.id, next),
'',
`Błąd przełączania efektu ${fx.name}`,
)
}
async function togglePlayback() {
if (!current.value) {
notify('Nic nie jest obecnie odtwarzane', 'info')
@@ -93,7 +127,7 @@ async function togglePlayback() {
}
await run(
() => (playing.value ? api.pause() : api.resume()),
playing.value ? 'Wstrzymano odtwarzanie' : 'Wznowiono odtwarzanie',
'',
'Błąd sterowania odtwarzaniem',
)
}
@@ -124,28 +158,32 @@ async function commitSeek() {
const position = seekPosition.value
seekSubmitting.value = true
try {
await run(() => api.seek(position), 'Przewinięto utwór', 'Błąd przewijania utworu')
await run(() => api.seek(position), '', 'Błąd przewijania utworu')
} finally {
seekSubmitting.value = false
seeking.value = false
}
}
const skip = () => run(() => api.skip(), 'Pominięto utwór', 'Błąd pomijania utworu')
async function restartCurrent() {
if (!current.value || duration.value <= 0 || seekSubmitting.value) return
seekPosition.value = 0
seekSubmitting.value = true
try {
await run(() => api.seek(0), '', 'Błąd uruchamiania od początku')
} finally {
seekSubmitting.value = false
seeking.value = false
}
}
const stop = () =>
run(() => api.stop(), 'Zatrzymano odtwarzanie i wyczyszczono kolejkę', 'Błąd zatrzymywania')
const skip = () => run(() => api.skip(), '', 'Błąd pomijania utworu')
const LOOP_MESSAGES = {
playlist: 'Zapętlanie kolejki włączone',
single: 'Zapętlanie utworu włączone',
off: 'Zapętlanie wyłączone',
} as const
const stop = () => run(() => api.stop(), '', 'Błąd zatrzymywania')
async function cycleLoop() {
try {
const { mode } = await api.cycleLoop()
notify(LOOP_MESSAGES[mode], 'info')
await api.cycleLoop()
} catch {
notify('Błąd ustawiania pętli', 'error')
}
@@ -248,6 +286,14 @@ async function cycleLoop() {
>
<Square />
</button>
<button
class="btn-icon btn-secondary"
:disabled="!current || duration <= 0 || seekSubmitting"
title="Od początku"
@click="restartCurrent"
>
<SkipBack />
</button>
<button
class="btn-icon btn-primary"
:disabled="!current"
@@ -291,6 +337,37 @@ async function cycleLoop() {
>
<span class="volume-value">{{ volume }}%</span>
</div>
<div
v-if="effects.length"
class="effects-container"
>
<div class="effects-header">
<SlidersHorizontal
:size="13"
class="effects-header-icon"
/>
<span class="effects-header-title">Efekty odtwarzacza</span>
</div>
<div class="effects-grid">
<button
v-for="fx in effects"
:key="fx.id"
type="button"
class="btn-effect"
:class="{ active: fx.active }"
:title="fx.name"
@click="onToggleEffect(fx)"
>
<component
:is="effectIcon(fx.icon)"
:size="15"
class="effect-icon"
/>
<span class="effect-name">{{ fx.name }}</span>
</button>
</div>
</div>
</div>
</template>
@@ -299,4 +376,91 @@ async function cycleLoop() {
background: var(--primary);
color: var(--bg-card, var(--bg-dark));
}
.btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
transform: none;
}
.effects-container {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border-glass, rgba(255, 255, 255, 0.08));
display: flex;
flex-direction: column;
gap: 8px;
}
.effects-header {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.7px;
}
.effects-header-icon {
opacity: 0.7;
}
.effects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.btn-effect {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-glass, rgba(255, 255, 255, 0.08));
border-radius: 10px;
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition-smooth, all 0.2s ease);
}
.btn-effect:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.18);
color: var(--text-main);
transform: translateY(-1px);
}
.btn-effect.active {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15) 0%, rgba(239, 68, 68, 0.15) 100%);
border-color: rgba(245, 158, 11, 0.5);
color: #fbbf24;
box-shadow: 0 0 12px rgba(245, 158, 11, 0.2);
}
.btn-effect.active:hover {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.22) 0%, rgba(239, 68, 68, 0.22) 100%);
border-color: rgba(245, 158, 11, 0.7);
}
.effect-icon {
flex-shrink: 0;
transition: transform 0.2s ease;
}
.btn-effect.active .effect-icon {
transform: scale(1.15);
filter: drop-shadow(0 0 6px rgba(245, 158, 11, 0.6));
}
.effect-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+53 -11
View File
@@ -25,7 +25,7 @@ const draggedId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const sendOrder = (ids: string[]) =>
run(() => api.reorder(ids), 'Zmieniono kolejność utworów!', 'Nie udało się zapisać kolejności')
run(() => api.reorder(ids), '', 'Nie udało się zapisać kolejności')
function reordered(from: number, to: number): string[] {
const ids = queue.value.map((track) => track.id)
@@ -69,22 +69,35 @@ function moveDown(index: number) {
}
const jump = (index: number) =>
run(() => api.jump(index), 'Zmieniono odtwarzany utwór', 'Nie udało się zmienić utworu')
run(() => api.jump(index), '', 'Nie udało się zmienić utworu')
const remove = (id: string) =>
run(() => api.remove(id), 'Usunięto utwór z kolejki', 'Nie udało się usunąć utworu')
run(() => api.remove(id), '', 'Nie udało się usunąć utworu')
const setPlays = (id: string, plays: number) =>
run(() => api.setTrackPlays(id, plays), '', 'Błąd aktualizacji odtworzeń')
const warnInvalidPlays = () => notify('Wartość musi być z przedziału 1-999', 'error')
const restoreQueue = () =>
run(
() => api.restoreQueue(),
'Przywrócono ostatnią kolejkę',
'Nie udało się przywrócić ostatniej kolejki',
)
const restoring = ref(false)
async function restoreQueue() {
if (restoring.value) return
if (!props.status?.active_voice_channel) {
notify('Bot musi być połączony z kanałem głosowym!', 'error')
return
}
restoring.value = true
try {
await run(
() => api.restoreQueue(),
'Przywrócono ostatnią kolejkę',
'Nie udało się przywrócić ostatniej kolejki',
)
} finally {
restoring.value = false
}
}
</script>
<template>
@@ -146,11 +159,17 @@ const restoreQueue = () =>
</div>
<button
class="btn btn-secondary btn-block restore-queue-button"
:disabled="!savedQueueAvailable"
:class="{ loading: restoring }"
:disabled="!savedQueueAvailable || restoring"
title="Przywróć kolejkę zapisaną przed ostatnim zatrzymaniem lub rozłączeniem"
@click="restoreQueue"
>
<RotateCcw /> Przywróć ostatnią kolejkę
<LoaderCircle
v-if="restoring"
class="restore-spinner"
/>
<RotateCcw v-else />
<span>{{ restoring ? 'Przywracanie kolejki…' : 'Przywróć ostatnią kolejkę' }}</span>
</button>
</div>
</template>
@@ -158,6 +177,29 @@ const restoreQueue = () =>
<style scoped>
.restore-queue-button {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.restore-queue-button.loading {
opacity: 0.9;
cursor: wait;
border-color: var(--primary, #f59e0b);
color: var(--primary, #f59e0b);
box-shadow: 0 0 12px rgba(245, 158, 11, 0.2);
}
.restore-queue-button:disabled:not(.loading) {
opacity: 0.45;
cursor: not-allowed;
}
.restore-spinner {
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
.queue-item-pending {
+1 -1
View File
@@ -35,7 +35,7 @@ function play(filename: string) {
<div class="glass-card uploads-panel">
<div class="queue-header">
<h2 class="card-title">
<FolderUp /> Serwerowa baza plików
<FolderUp /> Pliki serwera
</h2>
<span class="queue-count">{{ filesLabel(files.length) }}</span>
</div>
@@ -52,14 +52,14 @@ watch(
async function toggle() {
if (active.value) {
await run(() => api.disconnect(), 'Rozłączono z kanału głosowego', 'Błąd rozłączania')
await run(() => api.disconnect(), '', 'Błąd rozłączania')
return
}
if (!selected.value) {
notify('Wybierz kanał głosowy z listy!', 'error')
return
}
await run(() => api.connect(selected.value), 'Połączono z kanałem głosowym!', 'Nie udało się połączyć')
await run(() => api.connect(selected.value), '', 'Nie udało się połączyć')
}
async function setNotificationChannel() {
@@ -102,7 +102,7 @@ async function saveDefaultPlays() {
:key="channel.channel_id"
:value="channel.channel_id"
>
{{ channel.guild_name }} - {{ channel.channel_name }}
{{ channel.channel_name }}
</option>
</select>
<button
-9
View File
@@ -1,6 +1,5 @@
import { onUnmounted, ref } from 'vue'
import type { BotStatus } from '@/types/status'
import { useToasts } from '@/composables/useToasts'
import { getGuildId, setGuildId } from '@/api/client'
const RECONNECT_DELAY_MS = 3000
@@ -13,13 +12,10 @@ const pendingTracks = ref<{ id: string; guildId: string; title: string }[]>([])
let socket: WebSocket | null = null
let reconnectTimer: number | null = null
let connectedOnce = false
let subscribers = 0
/** Jedno połączenie WebSocket na aplikację, niezależnie od liczby komponentów. */
export function useBotStatus() {
const { notify } = useToasts()
function open() {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const guild = getGuildId()
@@ -30,11 +26,6 @@ export function useBotStatus() {
currentSocket.onopen = () => {
connected.value = true
notify(
connectedOnce ? 'Połączenie WebSocket przywrócone (Live)!' : 'WebSocket zsynchronizowany (Live)!',
'success',
)
connectedOnce = true
}
currentSocket.onmessage = (event) => {
+1 -1
View File
@@ -40,7 +40,7 @@ export function useUploads() {
}
async function enqueue(filename: string) {
await run(() => api.playFile(filename), 'Dodano plik do kolejki!', 'Błąd odtwarzania')
await run(() => api.playFile(filename), '', 'Błąd odtwarzania')
await refresh()
}
+10
View File
@@ -50,12 +50,21 @@ export interface UploadedFile {
mtime: number
}
export interface PlayerEffect {
id: string
name: string
icon: string
active: boolean
}
export interface BotStatus {
playing: boolean
paused: boolean
loop_single: boolean
loop_playlist: boolean
volume: number
effects?: PlayerEffect[]
active_effects?: string[]
current: CurrentTrack | null
queue: QueueTrack[]
queue_index: number
@@ -69,6 +78,7 @@ export interface BotStatus {
saved_queue_available: boolean
}
export type LoopMode = 'off' | 'playlist' | 'single'
export function loopModeOf(status: BotStatus | null): LoopMode {
+48
View File
@@ -11,6 +11,7 @@ from zyrarbot.settings import Settings
EXPECTED_ENDPOINTS = {
("get", "/api/status"),
("get", "/api/health"),
("post", "/api/restart"),
("post", "/api/play"),
("post", "/api/play_file"),
("post", "/api/pause"),
@@ -19,6 +20,7 @@ EXPECTED_ENDPOINTS = {
("post", "/api/skip"),
("post", "/api/stop"),
("post", "/api/volume"),
("post", "/api/effects/toggle"),
("post", "/api/loop"),
("post", "/api/loop_playlist"),
("post", "/api/loop_cycle"),
@@ -71,6 +73,52 @@ class ApiSurfaceTest(unittest.TestCase):
self.assertTrue(self.spec["openapi"].startswith("3."))
self.assertIn("ŻyrarBOT", self.spec["info"]["title"])
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()
mock_restart = MagicMock()
mock_request.app.state.restart = mock_restart
result = asyncio.run(restart_bot(mock_request))
self.assertEqual(result, {"status": "restarting"})
mock_restart.assert_called_once()
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()
mock_guild.id = 123
mock_guild.name = "Test Guild"
ch1 = MagicMock()
ch1.id = 1
ch1.name = "Allowed Voice"
ch1.permissions_for.return_value.connect = True
ch2 = MagicMock()
ch2.id = 2
ch2.name = "Forbidden Voice"
ch2.permissions_for.return_value.connect = False
mock_guild.voice_channels = [ch1, ch2]
mock_guild.me = MagicMock()
mock_guilds_resolver = MagicMock()
mock_guilds_resolver.guilds.return_value = [mock_guild]
service = StatusService(MagicMock(), mock_guilds_resolver)
channels = service._voice_channels_payload(mock_guild)
self.assertEqual(len(channels), 1)
self.assertEqual(channels[0]["channel_id"], "1")
self.assertEqual(channels[0]["channel_name"], "Allowed Voice")
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import struct
import unittest
from zyrarbot.audio.effects import (
AudioEffectsPipeline,
DistortionEffect,
FourthDensityEffect,
MuffledEffect,
RobotEffect,
ToasterEffect,
TremoloEffect,
create_default_effects,
)
class TestAudioEffects(unittest.TestCase):
def test_distortion_clips_pcm(self):
fx = DistortionEffect(multiplier=10.0)
pcm = struct.pack("<hh", 10000, -10000)
out = fx.process(pcm)
left, right = struct.unpack("<hh", out)
self.assertEqual(left, 32767)
self.assertEqual(right, -32768)
def test_muffled_filters_pcm(self):
fx = MuffledEffect(alpha=0.1)
# Step input
pcm = struct.pack("<hh", 10000, 10000)
out = fx.process(pcm)
left, right = struct.unpack("<hh", out)
# With alpha=0.1, output sample should be smoothed to 1000
self.assertEqual(left, 1000)
self.assertEqual(right, 1000)
def test_tremolo_modulates_amplitude(self):
fx = TremoloEffect(freq_hz=4.0, depth=0.6)
pcm = struct.pack("<hh", 10000, 10000)
out = fx.process(pcm)
left, right = struct.unpack("<hh", out)
# At phase 0, sin(0) = 0 -> lfo = 0.5 -> factor = 1.0 - 0.6*0.5 = 0.7
self.assertAlmostEqual(left, 7000, delta=100)
self.assertAlmostEqual(right, 7000, delta=100)
def test_fourth_density_processes_without_error(self):
fx = FourthDensityEffect()
pcm = (struct.pack("<hh", 1000, -1000)) * 960
out = fx.process(pcm)
self.assertEqual(len(out), len(pcm))
def test_robot_modulates_ring(self):
fx = RobotEffect(freq_hz=65.0, mix=0.85)
pcm = (struct.pack("<hh", 10000, 10000)) * 960
out = fx.process(pcm)
self.assertEqual(len(out), len(pcm))
self.assertNotEqual(out, pcm)
def test_toaster_bitcrush_pcm(self):
fx = ToasterEffect(bit_depth=5, downsample=6)
pcm = struct.pack("<hh", 12345, -23456)
out = fx.process(pcm)
left, right = struct.unpack("<hh", out)
# Low 11 bits are zeroed due to 5-bit quantization
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())
pcm = (struct.pack("<hh", 1000, -1000)) * 960
# When no effects active and volume=1.0, data is unchanged
out = pipeline.process(pcm, volume=1.0)
self.assertEqual(out, pcm)
# Activate fourth_density and distortion simultaneously
pipeline.set_active("fourth_density", True)
pipeline.set_active("distortion", True)
self.assertTrue(pipeline.is_active("fourth_density"))
self.assertTrue(pipeline.is_active("distortion"))
out_mult = pipeline.process(pcm, volume=0.5)
self.assertEqual(len(out_mult), len(pcm))
self.assertNotEqual(out_mult, pcm)
# Toggle off
toggled = pipeline.toggle("distortion")
self.assertFalse(toggled)
self.assertFalse(pipeline.is_active("distortion"))
def test_effects_metadata_structure(self):
from zyrarbot.audio.effects.registry import get_available_effects_metadata
metadata = get_available_effects_metadata()
self.assertGreater(len(metadata), 0)
for entry in metadata:
self.assertEqual(set(entry.keys()), {"id", "name", "icon"})
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -235,6 +235,33 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(track.volume, 0.25)
async def test_effects_management_in_player(self):
player, _ = build_player()
self.assertFalse(player.is_effect_active(GUILD, "distortion"))
# Enable effect
res = player.set_effect(GUILD, "distortion", True)
self.assertTrue(res)
self.assertTrue(player.is_effect_active(GUILD, "distortion"))
self.assertIn("distortion", player.state(GUILD).active_effects)
# Toggle effect off
toggled = player.toggle_effect(GUILD, "distortion")
self.assertFalse(toggled)
self.assertFalse(player.is_effect_active(GUILD, "distortion"))
self.assertNotIn("distortion", player.state(GUILD).active_effects)
# Multiple simultaneous effects
player.set_effect(GUILD, "distortion", True)
player.set_effect(GUILD, "fourth_density", True)
self.assertEqual(player.state(GUILD).active_effects, {"distortion", "fourth_density"})
# Stopping/clearing the player disables all active effects
player.clear(GUILD)
self.assertEqual(player.state(GUILD).active_effects, set())
self.assertFalse(player.is_effect_active(GUILD, "distortion"))
self.assertFalse(player.is_effect_active(GUILD, "fourth_density"))
async def test_default_plays_are_applied_per_guild(self):
player, _ = build_player()
player._config.get_default_plays.side_effect = lambda guild_id: {1: 2, 2: 7}.get(
@@ -296,6 +323,36 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
with self.assertRaisesRegex(ValueError, "not connected"):
await player.restore_queue(GUILD)
def test_save_queue_does_not_overwrite_with_empty_queue(self):
with tempfile.TemporaryDirectory() as directory:
player, _ = build_player()
player._queues = QueueRepository(directory)
saved = [{"id": "saved", "title": "Saved", "plays_left": 2}]
player._queues.save(GUILD, saved)
# Kolejka w stanie odtwarzacza jest pusta
player.state(GUILD).queue = []
player.save_queue(GUILD)
# Zapisana wcześniej kolejka powinna pozostać nienaruszona
self.assertEqual(player._queues.load(GUILD), saved)
def test_save_queue_does_not_save_when_all_plays_consumed(self):
with tempfile.TemporaryDirectory() as directory:
player, _ = build_player()
player._queues = QueueRepository(directory)
saved = [{"id": "saved", "title": "Saved", "plays_left": 2}]
player._queues.save(GUILD, saved)
# Wszystkie utwory w kolejce mają plays_left = 0
track = FakeTrack("consumed")
track.plays_left = 0
player.state(GUILD).queue = [track]
player.save_queue(GUILD)
# Zapisana wcześniej kolejka powinna pozostać nienaruszona
self.assertEqual(player._queues.load(GUILD), saved)
class LoopModeTest(unittest.TestCase):
def test_cycle_order(self):
+16
View File
@@ -20,6 +20,22 @@ class QueueRepositoryTest(unittest.TestCase):
self.assertTrue((Path(directory) / "10.json").exists())
self.assertTrue((Path(directory) / "20.json").exists())
def test_save_empty_tracks_does_nothing(self):
with tempfile.TemporaryDirectory() as directory:
repository = QueueRepository(directory)
tracks = [{"id": "first", "title": "First", "plays_left": 2}]
repository.save(10, tracks)
self.assertEqual(repository.load(10), tracks)
# Próba zapisania pustej listy nie powinna nadpisać poprzedniej
repository.save(10, [])
self.assertEqual(repository.load(10), tracks)
# Zapis pustej kolejki dla nowego serwera nie powinien tworzyć pliku
repository.save(20, [])
self.assertFalse(repository.exists(20))
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
import discord
from zyrarbot.settings import Settings
from zyrarbot.ui.bot.client import MusicBot
class ShutdownVoiceCleanupTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.settings = Settings(discord_token="fake_token")
self.tracks = MagicMock()
self.uploads = MagicMock()
self.config = MagicMock()
self.history = MagicMock()
self.bot = MusicBot(
self.settings,
self.tracks,
self.uploads,
self.config,
self.history,
)
self.player = MagicMock()
self.bot.player = self.player
async def test_disconnect_all_voice_disconnects_vcs_and_cleans_guild_state(self):
mock_vc = MagicMock()
mock_vc.guild.id = 12345
mock_vc.channel.name = "Test Channel"
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
mock_guild = MagicMock(spec=discord.Guild)
mock_guild.id = 12345
mock_guild.name = "Test Guild"
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]),
):
await self.bot.disconnect_all_voice()
self.player.save_queue.assert_called_once_with(12345)
self.player.clear.assert_called_once_with(12345)
mock_vc.disconnect.assert_awaited_once_with(force=False)
mock_guild.change_voice_state.assert_awaited_once_with(channel=None)
async def test_close_calls_disconnect_all_voice_before_super_close(self):
disconnect_mock = AsyncMock()
self.bot.disconnect_all_voice = disconnect_mock
with patch("discord.ext.commands.Bot.close", new_callable=AsyncMock) as super_close_mock:
await self.bot.close()
disconnect_mock.assert_awaited_once()
super_close_mock.assert_awaited_once()
# Calling close again should be a no-op due to _is_closing guard
await self.bot.close()
self.assertEqual(disconnect_mock.await_count, 1)
async def test_on_ready_cleans_dangling_voice_connection(self):
mock_guild = MagicMock(spec=discord.Guild)
mock_guild.id = 12345
mock_guild.name = "Test Guild"
mock_guild.me.voice.channel.name = "Ghost Channel"
mock_guild.voice_client = MagicMock(spec=discord.VoiceClient)
mock_guild.voice_client.disconnect = AsyncMock()
mock_guild.change_voice_state = AsyncMock()
with patch.object(type(self.bot), "guilds", [mock_guild]):
await self.bot.on_ready()
mock_guild.voice_client.disconnect.assert_awaited_once_with(force=True)
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import struct
import unittest
import discord
from zyrarbot.audio.ytdl_source import FRAME_LENGTH_SECONDS, YTDLSource
class DummyPCM(discord.AudioSource):
def __init__(self, data: bytes):
self._data = data
self.cleanup_called = False
def read(self) -> bytes:
data = self._data
self._data = b""
return data
def cleanup(self) -> None:
self.cleanup_called = True
class TestYTDLSource(unittest.TestCase):
def test_read_advances_elapsed_and_returns_data(self):
# 1 sample of 16-bit stereo = 4 bytes
pcm = struct.pack("<hh", 1000, 2000)
source = DummyPCM(pcm)
ytdl = YTDLSource(source, data={"title": "test"}, volume=1.0)
self.assertEqual(ytdl.elapsed, 0.0)
data = ytdl.read()
self.assertEqual(data, pcm)
self.assertAlmostEqual(ytdl.elapsed, FRAME_LENGTH_SECONDS)
# Subsequent read returns empty and does not advance elapsed further
empty = ytdl.read()
self.assertEqual(empty, b"")
self.assertAlmostEqual(ytdl.elapsed, FRAME_LENGTH_SECONDS)
def test_read_with_high_volume_does_not_clamp_to_2(self):
pcm = struct.pack("<hh", 5000, -5000)
source = DummyPCM(pcm)
ytdl = YTDLSource(source, data={"title": "distortion test"}, volume=5.0)
data = ytdl.read()
left, right = struct.unpack("<hh", data)
self.assertEqual(left, 25000)
self.assertEqual(right, -25000)
def test_read_distortion_effect_clips_at_pcm_limits(self):
pcm = struct.pack("<hh", 10000, -10000)
source = DummyPCM(pcm)
ytdl = YTDLSource(source, data={"title": "przester"}, volume=1.0)
ytdl.set_active_effects({"distortion"})
data = ytdl.read()
left, right = struct.unpack("<hh", data)
self.assertEqual(left, 32767)
self.assertEqual(right, -32768)
if __name__ == "__main__":
unittest.main()
+96 -9
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import asyncio
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
@@ -46,7 +48,15 @@ class Application:
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)
self._server: uvicorn.Server | None = None
self.bot = MusicBot(
self.settings,
self.tracks,
self.uploads,
self.config,
self.history,
restart_callback=self.schedule_restart,
)
self.guilds = GuildResolver(self.bot)
self.player = MusicPlayer(
bot=self.bot,
@@ -58,8 +68,10 @@ 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
broadcaster = mount_api(
self.web,
settings=self.settings,
@@ -80,6 +92,8 @@ class Application:
finally:
self.player.on_state_change = None
await broadcaster.stop()
if not self.bot.is_closed():
await self.bot.close()
self.web.router.lifespan_context = lifespan
@@ -99,6 +113,30 @@ class Application:
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
)
def schedule_restart(self, delay: float = 0.5) -> None:
"""Zaplanuj restart bota (zakończenie procesu Pythona)."""
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)
await asyncio.sleep(delay)
logger.info("Kończenie pracy aplikacji w celu restartu...")
try:
if not self.bot.is_closed():
await self.bot.close()
except Exception as error: # noqa: BLE001
logger.error("Błąd podczas zamykania bota: %s", error)
if self._server is not None:
self._server.should_exit = True
# Bezpiecznik: jeśli proces nie zakończy się w ciągu 2.0 s, wymuś wyjście
await asyncio.sleep(2.0)
logger.warning("Wymuszanie zakończenia procesu (os._exit)")
os._exit(0)
async def run(self) -> None:
"""Startuje serwer web, a następnie bota; panel działa też bez Discorda."""
config = uvicorn.Config(
@@ -108,20 +146,69 @@ class Application:
log_level="info",
)
server = uvicorn.Server(config)
self._server = server
loop = asyncio.get_running_loop()
def _handle_shutdown_signal() -> None:
logger.info("Odebrano sygnał zakończenia, zamykanie serwera i bota...")
server.should_exit = True
if not self.bot.is_closed():
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):
with suppress(NotImplementedError, RuntimeError):
loop.add_signal_handler(sig, _handle_shutdown_signal)
async with self.bot:
server_task = asyncio.create_task(server.serve())
try:
server_task = asyncio.create_task(server.serve(), name="web-server")
async def run_bot() -> None:
if not self.settings.discord_token:
raise RuntimeError("Brak DISCORD_TOKEN w środowisku")
await self.bot.start(self.settings.discord_token)
except Exception as error: # noqa: BLE001 - panel ma przetrwać awarię bota
logger.error("Nie udało się wystartować bota Discord: %s", error)
logger.info("Serwer webowy działa dalej...")
await self._wait_for_shutdown(server)
bot_task = asyncio.create_task(run_bot(), name="discord-bot")
try:
done, _ = await asyncio.wait(
[server_task, bot_task],
return_when=asyncio.FIRST_COMPLETED,
)
if server_task in done:
# Serwer webowy zakończył działanie (np. przez sygnał wyjścia lub restart)
logger.info("Serwer webowy zakończył pracę, zamykanie bota Discord...")
if not self.bot.is_closed():
await self.bot.close()
try:
await bot_task
except (asyncio.CancelledError, Exception) as error: # noqa: BLE001
logger.debug("Zakończenie zadania bota: %s", error)
elif bot_task in done:
# Bot Discord zakończył działanie (błąd startu lub wyjątek)
try:
bot_task.result()
except Exception as error: # noqa: BLE001
logger.error("Bot Discord zakończył pracę z błędem: %s", error)
logger.info("Serwer webowy działa dalej...")
await self._wait_for_shutdown(server)
except asyncio.CancelledError:
pass
finally:
server.should_exit = True
await server_task
if not self.bot.is_closed():
await self.bot.close()
if not server_task.done():
await server_task
if not bot_task.done():
bot_task.cancel()
with suppress(asyncio.CancelledError, Exception):
await bot_task
@staticmethod
async def _wait_for_shutdown(server: uvicorn.Server) -> None:
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from zyrarbot.audio.effects.base import AudioEffect
from zyrarbot.audio.effects.distortion import DistortionEffect
from zyrarbot.audio.effects.fourth_density import FourthDensityEffect
from zyrarbot.audio.effects.muffled import MuffledEffect
from zyrarbot.audio.effects.pipeline import AudioEffectsPipeline
from zyrarbot.audio.effects.registry import (
create_default_effects,
get_available_effects_metadata,
)
from zyrarbot.audio.effects.robot import RobotEffect
from zyrarbot.audio.effects.toaster import ToasterEffect
from zyrarbot.audio.effects.tremolo import TremoloEffect
__all__ = [
"AudioEffect",
"AudioEffectsPipeline",
"DistortionEffect",
"FourthDensityEffect",
"MuffledEffect",
"RobotEffect",
"ToasterEffect",
"TremoloEffect",
"create_default_effects",
"get_available_effects_metadata",
]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from abc import ABC, abstractmethod
class AudioEffect(ABC):
"""Bazowy interfejs efektu audio operującego na 16-bitowym PCM stereo (48000Hz)."""
id: str
name: str
icon: str
@abstractmethod
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
"""Przetwarza ramkę surowego PCM (16-bit signed stereo little-endian)."""
...
def reset(self) -> None: # noqa: B027
"""Resetuje ewentualny stan wewnętrzny filtra/efektu."""
pass
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from zyrarbot.audio.effects.base import AudioEffect
try:
import audioop
except ImportError: # pragma: no cover
try:
import audioop_lts as audioop
except ImportError:
audioop = None
class DistortionEffect(AudioEffect):
"""Efekt ekstremalnego przesteru (distortion / overdrive 1000%)."""
id = "distortion"
name = "Przester"
icon = "zap"
def __init__(self, multiplier: float = 10.0) -> None:
self.multiplier = multiplier
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
if not pcm_data or audioop is None or self.multiplier == 1.0:
return pcm_data
# audioop.mul multiplies 16-bit PCM and hard-clips at [-32768, 32767]
return audioop.mul(pcm_data, 2, self.multiplier)
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import array
import math
from zyrarbot.audio.effects.base import AudioEffect
class FourthDensityEffect(AudioEffect):
"""Efekt 'czwartej gęstości' - modulacja wysokości dźwięku (pitch) z okresem ~1s."""
id = "fourth_density"
name = "Czwarta gęstość"
icon = "sparkles"
def __init__(
self,
rate_hz: float = 1.0,
depth_samples: float = 385.0,
base_delay: float = 450.0,
buffer_size: int = 2400,
) -> None:
self.rate_hz = rate_hz
self.depth = depth_samples
self.base_delay = base_delay
self.buffer_size = buffer_size
self._buf_l = [0.0] * buffer_size
self._buf_r = [0.0] * buffer_size
self._write_ptr = 0
self._phase = 0.0
self._phase_inc = 2.0 * math.pi * rate_hz / 48000.0
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
if not pcm_data:
return pcm_data
samples = array.array("h", pcm_data)
num_frames = len(samples) // 2
buf_l = self._buf_l
buf_r = self._buf_r
buf_size = self.buffer_size
w = self._write_ptr
phase = self._phase
phase_inc = self._phase_inc
depth = self.depth
base_delay = self.base_delay
for i in range(num_frames):
in_l = samples[i * 2]
in_r = samples[i * 2 + 1]
buf_l[w] = in_l
buf_r[w] = in_r
# Modulacja opóźnienia generująca efekt vibrato / pitch wow
delay = base_delay + depth * math.sin(phase)
phase += phase_inc
if phase >= 2.0 * math.pi:
phase -= 2.0 * math.pi
r_idx = w - delay
while r_idx < 0:
r_idx += buf_size
idx0 = int(r_idx)
idx1 = (idx0 + 1) % buf_size
frac = r_idx - idx0
out_l = buf_l[idx0] + frac * (buf_l[idx1] - buf_l[idx0])
out_r = buf_r[idx0] + frac * (buf_r[idx1] - buf_r[idx0])
samples[i * 2] = max(-32768, min(32767, int(out_l)))
samples[i * 2 + 1] = max(-32768, min(32767, int(out_r)))
w = (w + 1) % buf_size
self._write_ptr = w
self._phase = phase
return samples.tobytes()
def reset(self) -> None:
self._buf_l = [0.0] * self.buffer_size
self._buf_r = [0.0] * self.buffer_size
self._write_ptr = 0
self._phase = 0.0
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import array
from zyrarbot.audio.effects.base import AudioEffect
class MuffledEffect(AudioEffect):
"""Efekt dźwięku zza ściany / pod wodą (filtr dolnoprzepustowy IIR)."""
id = "muffled"
name = "Pod wodą"
icon = "waves"
def __init__(self, alpha: float = 0.085) -> None:
self.alpha = alpha
self._prev_l = 0.0
self._prev_r = 0.0
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
if not pcm_data:
return pcm_data
samples = array.array("h", pcm_data)
alpha = self.alpha
prev_l = self._prev_l
prev_r = self._prev_r
# Przetwarzanie stereo (próbka lewa i prawa)
for i in range(0, len(samples), 2):
prev_l += alpha * (samples[i] - prev_l)
prev_r += alpha * (samples[i + 1] - prev_r)
samples[i] = int(prev_l)
samples[i + 1] = int(prev_r)
self._prev_l = prev_l
self._prev_r = prev_r
return samples.tobytes()
def reset(self) -> None:
self._prev_l = 0.0
self._prev_r = 0.0
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from collections.abc import Iterable
from zyrarbot.audio.effects.base import AudioEffect
try:
import audioop
except ImportError: # pragma: no cover
try:
import audioop_lts as audioop
except ImportError:
audioop = None
class AudioEffectsPipeline:
"""Łańcuch efektów audio przetwarzający ramki PCM w locie."""
def __init__(self, effects: Iterable[AudioEffect] | None = None) -> None:
self._effects: dict[str, AudioEffect] = {}
self._active_ids: set[str] = set()
if effects:
for effect in effects:
self.register(effect)
def register(self, effect: AudioEffect) -> None:
self._effects[effect.id] = effect
def get(self, effect_id: str) -> AudioEffect | None:
return self._effects.get(effect_id)
@property
def active_ids(self) -> set[str]:
return self._active_ids
@active_ids.setter
def active_ids(self, ids: Iterable[str]) -> None:
self._active_ids = set(ids)
def is_active(self, effect_id: str) -> bool:
return effect_id in self._active_ids
def set_active(self, effect_id: str, active: bool) -> bool:
if effect_id not in self._effects:
return False
if active:
self._active_ids.add(effect_id)
else:
self._active_ids.discard(effect_id)
self._effects[effect_id].reset()
return True
def toggle(self, effect_id: str) -> bool:
new_state = not self.is_active(effect_id)
self.set_active(effect_id, new_state)
return new_state
def process(self, pcm_data: bytes, volume: float = 1.0) -> bytes:
if not pcm_data:
return b""
# 1. Przetwarzanie aktywnych efektów w ustalonej kolejności rejestru
for effect in self._effects.values():
if effect.id in self._active_ids:
pcm_data = effect.process(pcm_data)
# 2. Skalowanie głośności głównej
if volume != 1.0 and audioop is not None:
pcm_data = audioop.mul(pcm_data, 2, volume)
return pcm_data
def reset(self) -> None:
for effect in self._effects.values():
effect.reset()
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any
from zyrarbot.audio.effects.base import AudioEffect
from zyrarbot.audio.effects.distortion import DistortionEffect
from zyrarbot.audio.effects.fourth_density import FourthDensityEffect
from zyrarbot.audio.effects.muffled import MuffledEffect
from zyrarbot.audio.effects.robot import RobotEffect
from zyrarbot.audio.effects.toaster import ToasterEffect
from zyrarbot.audio.effects.tremolo import TremoloEffect
def create_default_effects() -> list[AudioEffect]:
"""Tworzy domyślną listę efektów audio w zalecanej kolejności przetwarzania."""
return [
MuffledEffect(),
FourthDensityEffect(),
RobotEffect(),
ToasterEffect(),
DistortionEffect(),
TremoloEffect(),
]
def get_available_effects_metadata() -> list[dict[str, Any]]:
"""Zwraca metadane wszystkich dostępnych w systemie efektów."""
return [
{
"id": effect.id,
"name": effect.name,
"icon": effect.icon,
}
for effect in create_default_effects()
]
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import array
import math
from zyrarbot.audio.effects.base import AudioEffect
class RobotEffect(AudioEffect):
"""Efekt metalicznego głosu cyborga oparty na ring modulation (~65Hz)."""
id = "robot"
name = "Robot"
icon = "bot"
def __init__(self, freq_hz: float = 65.0, mix: float = 0.85) -> None:
self.freq_hz = freq_hz
self.mix = mix
self._phase = 0.0
self._phase_inc = 2.0 * math.pi * freq_hz / 48000.0
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
if not pcm_data:
return pcm_data
samples = array.array("h", pcm_data)
phase = self._phase
phase_inc = self._phase_inc
mix = self.mix
dry = 1.0 - mix
for i in range(0, len(samples), 2):
carrier = math.sin(phase)
phase += phase_inc
if phase >= 2.0 * math.pi:
phase -= 2.0 * math.pi
mod_factor = dry + mix * carrier
samples[i] = max(-32768, min(32767, int(samples[i] * mod_factor)))
samples[i + 1] = max(-32768, min(32767, int(samples[i + 1] * mod_factor)))
self._phase = phase
return samples.tobytes()
def reset(self) -> None:
self._phase = 0.0
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import array
from zyrarbot.audio.effects.base import AudioEffect
class ToasterEffect(AudioEffect):
"""Efekt 'dźwięku z tostera' - memiczny lo-fi bitcrusher (5-bit quant + downsample)."""
id = "toaster"
name = "Dźwięk z tostera"
icon = "radio"
def __init__(self, bit_depth: int = 5, downsample: int = 6) -> None:
self.bit_depth = bit_depth
self.downsample = downsample
self.shift = 16 - bit_depth
self._counter = 0
self._hold_l = 0
self._hold_r = 0
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
if not pcm_data:
return pcm_data
samples = array.array("h", pcm_data)
shift = self.shift
down = self.downsample
hold_l = self._hold_l
hold_r = self._hold_r
cnt = self._counter
for i in range(0, len(samples), 2):
if cnt == 0:
# Obniżenie rozdzielczości bitowej (bitcrush / kwantyzacja do 5 bitów)
sl = samples[i]
sr = samples[i + 1]
hold_l = (sl >> shift) << shift
hold_r = (sr >> shift) << shift
samples[i] = hold_l
samples[i + 1] = hold_r
cnt = (cnt + 1) % down
self._counter = cnt
self._hold_l = hold_l
self._hold_r = hold_r
return samples.tobytes()
def reset(self) -> None:
self._counter = 0
self._hold_l = 0
self._hold_r = 0
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import math
from zyrarbot.audio.effects.base import AudioEffect
try:
import audioop
except ImportError: # pragma: no cover
try:
import audioop_lts as audioop
except ImportError:
audioop = None
class TremoloEffect(AudioEffect):
"""Efekt pulsowania głośności (tremolo) z generatorem LFO."""
id = "tremolo"
name = "Tremolo"
icon = "activity"
def __init__(self, freq_hz: float = 4.0, depth: float = 0.6) -> None:
self.freq_hz = freq_hz
self.depth = depth
self._phase = 0.0
def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes:
if not pcm_data or audioop is None:
return pcm_data
# Obliczamy współczynnik modulacji na podstawie fazy LFO
lfo = 0.5 + 0.5 * math.sin(self._phase)
factor = 1.0 - (self.depth * lfo)
# 20ms = 0.02s
self._phase = (self._phase + 2.0 * math.pi * self.freq_hz * 0.02) % (2.0 * math.pi)
return audioop.mul(pcm_data, 2, factor)
def reset(self) -> None:
self._phase = 0.0
+36 -4
View File
@@ -5,13 +5,31 @@ from typing import Any
import discord
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
try:
import audioop_lts as audioop
except ImportError:
audioop = None
class YTDLSource(discord.PCMVolumeTransformer):
"""Źródło audio (strumień YouTube lub plik lokalny) z metadanymi utworu."""
def __init__(self, source: discord.AudioSource, *, data: dict[str, Any], volume: float = 0.5):
def __init__(
self,
source: discord.AudioSource,
*,
data: dict[str, Any],
volume: float = 0.5,
effects_pipeline: AudioEffectsPipeline | None = None,
):
super().__init__(source, volume)
self.data = data
self.title: str | None = data.get("title")
@@ -21,6 +39,11 @@ class YTDLSource(discord.PCMVolumeTransformer):
self.elapsed: float = 0.0
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())
)
@property
def is_local(self) -> bool:
@@ -39,8 +62,17 @@ class YTDLSource(discord.PCMVolumeTransformer):
url = self.display_url
return bool(url and str(url).startswith("http"))
def set_active_effects(self, effect_ids: Any) -> None:
if self.effects_pipeline:
self.effects_pipeline.active_ids = effect_ids
def read(self) -> bytes:
data = super().read()
if data:
self.elapsed += FRAME_LENGTH_SECONDS
data = self.original.read()
if not data:
return b""
self.elapsed += FRAME_LENGTH_SECONDS
if self.effects_pipeline is not None:
return self.effects_pipeline.process(data, volume=self._volume)
if audioop is not None and self._volume != 1.0:
return audioop.mul(data, 2, self._volume)
return data
+6 -1
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import discord
from zyrarbot.audio.ytdl_source import YTDLSource
from zyrarbot.music.loop_mode import LoopMode
if TYPE_CHECKING:
from zyrarbot.audio.ytdl_source import YTDLSource
@dataclass
class GuildMusicState:
@@ -21,6 +24,7 @@ class GuildMusicState:
current: YTDLSource | None = None
index: int = -1
volume: float = 1.0
active_effects: set[str] = field(default_factory=set)
loop_mode: LoopMode = LoopMode.OFF
jump_pending: bool = False
notification_channel: discord.abc.Messageable | None = None
@@ -29,6 +33,7 @@ class GuildMusicState:
self.queue.clear()
self.current = None
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)
+43 -11
View File
@@ -103,6 +103,7 @@ class MusicPlayer:
self._playback_tokens[guild_id] = self._playback_tokens.get(guild_id, 0) + 1
self._interrupted_playbacks.discard(guild_id)
self.state(guild_id).reset()
self._apply_volume(guild_id)
def has_saved_queue(self, guild_id: int) -> bool:
return bool(self._queues and self._queues.exists(guild_id))
@@ -111,14 +112,14 @@ class MusicPlayer:
if not self._queues:
return
state = self.state(guild_id)
self._queues.save(
guild_id,
[
self._track_snapshot(track)
for track in state.queue
if self.plays_left(track, guild_id) > 0
],
)
tracks = [
self._track_snapshot(track)
for track in state.queue
if self.plays_left(track, guild_id) > 0
]
if not tracks:
return
self._queues.save(guild_id, tracks)
def finish_current(self, guild_id: int) -> None:
"""Consume one play when playback ends outside the regular callback."""
@@ -207,11 +208,38 @@ class MusicPlayer:
"filepath": track.filepath,
}
def set_volume(self, guild_id: int, volume: float) -> None:
self.state(guild_id).volume = volume
def _apply_volume(self, guild_id: int) -> None:
state = self.state(guild_id)
voice_client = self.voice_client(guild_id)
if voice_client and voice_client.source:
voice_client.source.volume = volume
voice_client.source.volume = state.volume
if hasattr(voice_client.source, "set_active_effects"):
voice_client.source.set_active_effects(state.active_effects)
def set_volume(self, guild_id: int, volume: float) -> None:
self.state(guild_id).volume = volume
self._apply_volume(guild_id)
def set_effect(self, guild_id: int, effect_id: str, enabled: bool) -> bool:
state = self.state(guild_id)
if enabled:
state.active_effects.add(effect_id)
else:
state.active_effects.discard(effect_id)
self._apply_volume(guild_id)
return enabled
def toggle_effect(self, guild_id: int, effect_id: str) -> bool:
state = self.state(guild_id)
enabled = effect_id not in state.active_effects
return self.set_effect(guild_id, effect_id, enabled)
def is_effect_active(self, guild_id: int, effect_id: str) -> bool:
return effect_id in self.state(guild_id).active_effects
def clear_effects(self, guild_id: int) -> None:
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
@@ -321,6 +349,8 @@ class MusicPlayer:
track = await self._tracks.recreate(current, loop=self._bot.loop, start_at=position)
track.played = True
track.volume = state.volume
if hasattr(track, "set_active_effects"):
track.set_active_effects(state.active_effects)
track.plays_left = self.plays_left(current, guild_id)
track.elapsed = position
state.queue[state.index] = track
@@ -427,6 +457,8 @@ class MusicPlayer:
track.played = True
track.plays_left = self.plays_left(track, guild_id)
track.volume = state.volume
if hasattr(track, "set_active_effects"):
track.set_active_effects(state.active_effects)
state.queue[next_index] = track
if track.plays_left <= 0 and state.loop_mode.loops_track:
+2
View File
@@ -21,6 +21,8 @@ class QueueRepository:
return self._stores[guild_id]
def save(self, guild_id: int, tracks: list[dict[str, Any]]) -> None:
if not tracks:
return
self._store(guild_id).set("tracks", tracks)
def load(self, guild_id: int) -> list[dict[str, Any]]:
@@ -0,0 +1,8 @@
from __future__ import annotations
from pydantic import BaseModel
class EffectToggleRequest(BaseModel):
effect_id: str
enabled: bool | None = None
@@ -17,6 +17,7 @@ from zyrarbot.ui.api.dependencies import (
get_tracks,
get_uploads,
)
from zyrarbot.ui.api.requests.effect_toggle_request import EffectToggleRequest
from zyrarbot.ui.api.requests.loop_request import LoopRequest
from zyrarbot.ui.api.requests.play_file_request import PlayFileRequest
from zyrarbot.ui.api.requests.play_request import PlayRequest
@@ -173,6 +174,26 @@ async def set_volume(
return {"status": "success", "volume": request.volume}
@router.post("/effects/toggle")
async def toggle_effect(
request: EffectToggleRequest,
guild: discord.Guild = Depends(get_active_guild),
player: MusicPlayer = Depends(get_player),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
) -> dict[str, Any]:
if request.enabled is not None:
active = player.set_effect(guild.id, request.effect_id, request.enabled)
else:
active = player.toggle_effect(guild.id, request.effect_id)
await broadcaster.broadcast()
return {
"status": "success",
"effect_id": request.effect_id,
"active": active,
"active_effects": list(player.state(guild.id).active_effects),
}
@router.post("/loop")
async def set_loop(
request: LoopRequest,
+9 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any
import discord
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
@@ -23,3 +23,11 @@ async def get_status(
@router.get("/health")
async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any]:
return {"status": "ok", "ready": player.is_ready}
@router.post("/restart")
async def restart_bot(request: Request) -> dict[str, Any]:
restart_action = getattr(request.app.state, "restart", None)
if restart_action:
restart_action()
return {"status": "restarting"}
+6
View File
@@ -63,6 +63,12 @@ async def connect(
if not isinstance(channel, discord.VoiceChannel):
raise HTTPException(status_code=404, detail="Voice channel not found")
if channel.guild.me and not channel.permissions_for(channel.guild.me).connect:
raise HTTPException(
status_code=403,
detail=f"Bot nie ma uprawnień do połączenia z kanałem {channel.name}",
)
guild_id = channel.guild.id
# Rozłączamy wyłącznie na tym samym serwerze - inne serwery grają dalej.
existing = guilds.voice_client(guild_id)
+9
View File
@@ -4,6 +4,7 @@ from typing import Any
import discord
from zyrarbot.audio.effects import get_available_effects_metadata
from zyrarbot.audio.ytdl_source import YTDLSource
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
@@ -49,6 +50,8 @@ class StatusService:
"loop_single": False,
"loop_playlist": False,
"volume": 100,
"effects": [{**meta, "active": False} for meta in get_available_effects_metadata()],
"active_effects": [],
"current": None,
"queue": [],
"queue_index": -1,
@@ -70,6 +73,11 @@ class StatusService:
"loop_single": state.loop_mode.loops_track,
"loop_playlist": state.loop_mode.loops_queue,
"volume": int(state.volume * 100),
"effects": [
{**meta, "active": meta["id"] in state.active_effects}
for meta in get_available_effects_metadata()
],
"active_effects": list(state.active_effects),
"current": self._current_payload(state.current, guild.id),
"queue": [self._track_payload(track, guild.id) for track in state.queue],
"queue_index": state.index,
@@ -130,6 +138,7 @@ class StatusService:
for guild in self._guilds.guilds()
if selected_guild is None or guild.id == selected_guild.id
for channel in guild.voice_channels
if guild.me and channel.permissions_for(guild.me).connect
]
def _guilds_payload(self) -> list[dict[str, Any]]:
+106
View File
@@ -1,6 +1,10 @@
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
@@ -35,6 +39,7 @@ class MusicBot(commands.Bot):
uploads: UploadsRepository,
config: GuildConfigRepository,
history: HistoryRepository,
restart_callback: Callable[[], None] | None = None,
):
intents = discord.Intents.default()
intents.message_content = True
@@ -47,6 +52,25 @@ class MusicBot(commands.Bot):
self.config = config
self.history = history
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 os
async def _default_restart() -> None:
await asyncio.sleep(0.5)
await self.close()
os._exit(0)
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:
@@ -77,11 +101,93 @@ class MusicBot(commands.Bot):
await self.tree.sync()
logger.info("Zsynchronizowano komendy globalnie")
async def disconnect_all_voice(self) -> None:
"""Rozłącza bota ze wszystkich kanałów głosowych na wszystkich serwerach."""
if self.voice_clients:
logger.info(
"Odłączanie bota ze wszystkich kanałów głosowych (%d)...",
len(self.voice_clients),
)
async def _disconnect_vc(vc: discord.VoiceClient) -> None:
try:
guild_id = vc.guild.id
if self.player:
try:
self.player.save_queue(guild_id)
self.player.clear(guild_id)
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: # 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)
if self.voice_clients:
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: # 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
)
async def close(self) -> None:
"""Zamykanie bota: najpierw odłącz się od wszystkich kanałów głosowych."""
if getattr(self, "_is_closing", False) or self.is_closed():
return
self._is_closing = True
try:
await self.disconnect_all_voice()
except Exception as error: # noqa: BLE001
logger.error("Błąd podczas odłączania bota przed zamknięciem: %s", error)
await super().close()
async def on_ready(self) -> None:
if self.guilds:
first_guild_id = self.guilds[0].id
self.uploads.migrate_legacy(first_guild_id)
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,
# 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...",
guild.name,
guild.me.voice.channel.name,
)
try:
if guild.voice_client:
await guild.voice_client.disconnect(force=True)
else:
await guild.change_voice_state(channel=None)
except Exception as error: # noqa: BLE001
logger.error("Błąd podczas czyszczenia wiszącego kanału głosowego: %s", error)
if self.user:
logger.info("Zalogowano jako %s (%s)", self.user.name, self.user.id)
+14 -1
View File
@@ -24,7 +24,20 @@ class MusicCogBase(commands.Cog):
voice_client = interaction.guild.voice_client
if voice_client is None:
await interaction.user.voice.channel.connect()
user_channel = interaction.user.voice.channel
if (
interaction.guild.me
and not user_channel.permissions_for(interaction.guild.me).connect
):
await interaction.response.send_message(
embed=message_embed(
"Brak uprawnień",
f"Bot nie ma uprawnień do połączenia z kanałem **{user_channel.name}**.",
),
ephemeral=True,
)
return False
await user_channel.connect()
elif voice_client.channel != interaction.user.voice.channel:
await interaction.response.send_message(
embed=message_embed(
+33
View File
@@ -111,6 +111,31 @@ class ControlCog(MusicCogBase, name="Control"):
embed=message_embed("Głośność", f"Ustawiono głośność na {volume}%.")
)
@app_commands.command(name="efekt", description="Włącz lub wyłącz efekt dźwiękowy odtwarzacza")
@app_commands.describe(nazwa="Wybierz efekt audio do przełączenia")
@app_commands.choices(
nazwa=[
app_commands.Choice(name="Przester (1000%)", value="distortion"),
app_commands.Choice(name="Czwarta gęstość (~1s pitch)", value="fourth_density"),
app_commands.Choice(name="Robot (cyborg voice)", value="robot"),
app_commands.Choice(name="Dźwięk z tostera (8-bit lo-fi)", value="toaster"),
app_commands.Choice(name="Pod wodą (lowpass)", value="muffled"),
app_commands.Choice(name="Tremolo (pulsowanie)", value="tremolo"),
]
)
async def toggle_effect_command(
self, interaction: discord.Interaction, nazwa: app_commands.Choice[str]
) -> None:
active = self.player.toggle_effect(interaction.guild.id, nazwa.value)
await self.player.trigger_state_change()
status_text = "włączony ✅" if active else "wyłączony ❌"
await interaction.response.send_message(
embed=message_embed(
"Efekt odtwarzacza",
f"Efekt **{nazwa.name}** jest teraz **{status_text}**.",
)
)
@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
@@ -122,3 +147,11 @@ class ControlCog(MusicCogBase, name="Control"):
return
title = discord.utils.escape_markdown(current.title or "")
await interaction.response.send_message(embed=message_embed("Teraz odtwarzane", title))
@app_commands.command(name="restart", description="Zrestartuj bota (restart kontenera)")
async def restart(self, interaction: discord.Interaction) -> None:
await interaction.response.send_message(
embed=message_embed("Restartowanie", "Bot jest restartowany...")
)
if hasattr(self.bot, "schedule_restart"):
self.bot.schedule_restart()
+10
View File
@@ -34,6 +34,16 @@ class VoiceCog(MusicCogBase, name="Voice"):
async def connect(
self, interaction: discord.Interaction, channel: discord.VoiceChannel
) -> None:
if interaction.guild.me and not channel.permissions_for(interaction.guild.me).connect:
await interaction.response.send_message(
embed=message_embed(
"Brak uprawnień",
f"Bot nie ma uprawnień do połączenia z kanałem **{channel.name}**.",
),
ephemeral=True,
)
return
existing = self.player.voice_client(interaction.guild.id)
if existing:
await existing.disconnect()