42 files changed
+1563
-72
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user