refactor: isolate application state per Discord server

This commit is contained in:
mmacierzanka committed 2026-08-21 16:14:22 +02:00
1 parent 94be2c7c79
commit 64b428555c
30 files changed
+330 -471

No files matched your search

+4
View File
@@ -20,6 +20,10 @@ Wymagane zmienne środowiskowe (plik `.env`):
Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`. jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Istniejące dane ze
starego, wspólnego formatu są przypisywane do pierwszego użytego serwera.
## Testy ## Testy
```bash ```bash
-1
View File
@@ -62,7 +62,6 @@ src/
├── QueueItem.vue pozycja kolejki wraz z limitem odtworzeń ├── QueueItem.vue pozycja kolejki wraz z limitem odtworzeń
├── HistoryPanel.vue historia z ponownym dodaniem do kolejki ├── HistoryPanel.vue historia z ponownym dodaniem do kolejki
├── ModalShell.vue wspólna ramka okien modalnych ├── ModalShell.vue wspólna ramka okien modalnych
├── SettingsModal.vue ustawienia bota i kanałów logów
├── UploadsLibraryModal.vue pełna lista plików na serwerze ├── UploadsLibraryModal.vue pełna lista plików na serwerze
└── ToastHost.vue powiadomienia └── ToastHost.vue powiadomienia
``` ```
+4 -13
View File
@@ -109,6 +109,7 @@ const byTitle = (title) => app.querySelector(`[title="${title}"]`)
const results = [] const results = []
const check = (name, ok) => results.push([name, ok]) const check = (name, ok) => results.push([name, ok])
const hasCall = (expected) => calls.some((call) => call === expected || call.startsWith(`${expected}?`))
check('aplikacja zamontowana', app.children.length > 0) check('aplikacja zamontowana', app.children.length > 0)
check('nagłówek', text().includes('ŻyrarBOT')) check('nagłówek', text().includes('ŻyrarBOT'))
@@ -127,7 +128,7 @@ check('ikony renderowane jako SVG', app.querySelectorAll('svg').length > 10)
check('pozycje list', app.querySelectorAll('.queue-item').length === 4) check('pozycje list', app.querySelectorAll('.queue-item').length === 4)
check('podświetlona aktywna pozycja', app.querySelectorAll('.playing-item').length === 1) check('podświetlona aktywna pozycja', app.querySelectorAll('.playing-item').length === 1)
check('animacja winylu przy odtwarzaniu', app.querySelector('.vinyl-record.playing') !== null) check('animacja winylu przy odtwarzaniu', app.querySelector('.vinyl-record.playing') !== null)
check('pobrano listę plików na starcie', calls.includes('GET /api/uploads')) check('pobrano listę plików na starcie', hasCall('GET /api/uploads'))
// --- Interakcje: kliknięcie ma trafić w konkretny endpoint --------------- // --- Interakcje: kliknięcie ma trafić w konkretny endpoint ---------------
@@ -135,7 +136,8 @@ async function clickExpects(title, expected) {
const before = calls.length const before = calls.length
byTitle(title)?.click() byTitle(title)?.click()
await settle() await settle()
check(`${title} -> ${expected}`, calls.slice(before).includes(expected)) check(`${title} -> ${expected}`, calls.slice(before).some((call) =>
call === expected || call.startsWith(`${expected}?`)))
} }
await clickExpects('Wstrzymaj', 'POST /api/pause') await clickExpects('Wstrzymaj', 'POST /api/pause')
@@ -150,17 +152,6 @@ await clickExpects('Dodaj do kolejki', 'POST /api/play_file')
await clickExpects('Dodaj ponownie do kolejki', 'POST /api/play') await clickExpects('Dodaj ponownie do kolejki', 'POST /api/play')
await clickExpects('Usuń z serwera', 'DELETE /api/uploads/sample.mp3') await clickExpects('Usuń z serwera', 'DELETE /api/uploads/sample.mp3')
// Modal ustawień otwiera się i zapisuje domyślną liczbę odtworzeń.
;[...app.querySelectorAll('button')].find((b) => b.textContent.includes('Ustawienia'))?.click()
await settle()
check('modal ustawień pokazuje serwery', doc.body.textContent.includes('ID: 1'))
const before = calls.length
;[...doc.querySelectorAll('button')].find((b) => b.textContent.includes('Zapisz'))?.click()
await settle()
check('zapis ustawień -> POST /api/settings/default_plays',
calls.slice(before).includes('POST /api/settings/default_plays'))
const failed = results.filter(([, ok]) => !ok) const failed = results.filter(([, ok]) => !ok)
for (const [name, ok] of results) console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`) for (const [name, ok] of results) console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`)
console.log(failed.length ? `\n${failed.length} z ${results.length} niepowodzeń` : `\nOK - ${results.length} sprawdzeń`) console.log(failed.length ? `\n${failed.length} z ${results.length} niepowodzeń` : `\nOK - ${results.length} sprawdzeń`)
+23 -10
View File
@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref, watch } from 'vue'
import AddTrackPanel from '@/components/AddTrackPanel.vue' import AddTrackPanel from '@/components/AddTrackPanel.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import HistoryPanel from '@/components/HistoryPanel.vue' import HistoryPanel from '@/components/HistoryPanel.vue'
import PlayerPanel from '@/components/PlayerPanel.vue' import PlayerPanel from '@/components/PlayerPanel.vue'
import QueuePanel from '@/components/QueuePanel.vue' import QueuePanel from '@/components/QueuePanel.vue'
import SettingsModal from '@/components/SettingsModal.vue'
import ToastHost from '@/components/ToastHost.vue' import ToastHost from '@/components/ToastHost.vue'
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue' import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
import UploadsPanel from '@/components/UploadsPanel.vue' import UploadsPanel from '@/components/UploadsPanel.vue'
@@ -13,11 +12,28 @@ import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
import { useBotStatus } from '@/composables/useBotStatus' import { useBotStatus } from '@/composables/useBotStatus'
import { useUploads } from '@/composables/useUploads' import { useUploads } from '@/composables/useUploads'
const { status, connect } = useBotStatus() const { status, connect, selectGuild } = useBotStatus()
const { refresh } = useUploads() const { refresh } = useUploads()
const settingsOpen = ref(false)
const libraryOpen = ref(false) const libraryOpen = ref(false)
const selectedGuildId = ref<string | null>(null)
watch(
() => status.value?.guilds,
(guilds) => {
if (guilds?.length && !selectedGuildId.value) {
selectedGuildId.value = guilds[0].guild_id
selectGuild(guilds[0].guild_id)
void refresh()
}
},
)
async function changeGuild(guildId: string) {
selectedGuildId.value = guildId
selectGuild(guildId)
await refresh()
}
onMounted(() => { onMounted(() => {
connect() connect()
@@ -32,7 +48,9 @@ onMounted(() => {
<div class="app-container"> <div class="app-container">
<AppHeader <AppHeader
:status="status" :status="status"
@open-settings="settingsOpen = true" :guilds="status?.guilds ?? []"
:selected-guild-id="selectedGuildId"
@select-guild="changeGuild"
/> />
<main class="dashboard-grid"> <main class="dashboard-grid">
@@ -53,11 +71,6 @@ onMounted(() => {
</main> </main>
</div> </div>
<SettingsModal
v-if="settingsOpen"
:status="status"
@close="settingsOpen = false"
/>
<UploadsLibraryModal <UploadsLibraryModal
v-if="libraryOpen" v-if="libraryOpen"
:status="status" :status="status"
+16 -5
View File
@@ -2,10 +2,25 @@
import type { BotStatus, UploadedFile } from '@/types/status' import type { BotStatus, UploadedFile } from '@/types/status'
let selectedGuildId: string | null = null
export function getGuildId() {
return selectedGuildId
}
export function setGuildId(guildId: string) {
selectedGuildId = guildId
}
function withGuild(path: string) {
if (!selectedGuildId) return path
return `${path}${path.includes('?') ? '&' : '?'}guild_id=${encodeURIComponent(selectedGuildId ?? '')}`
}
export class ApiError extends Error {} export class ApiError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init) const response = await fetch(withGuild(path), init)
const payload = await response.json().catch(() => null) const payload = await response.json().catch(() => null)
if (!response.ok) { if (!response.ok) {
@@ -52,8 +67,4 @@ export const api = {
return request<{ filename: string }>('/api/upload', { method: 'POST', body: form }) return request<{ filename: string }>('/api/upload', { method: 'POST', body: form })
}, },
setDefaultPlays: (defaultPlays: number) =>
post('/api/settings/default_plays', { default_plays: defaultPlays }),
setLogChannel: (guildId: string, channelId: string) =>
post('/api/settings/text_channel', { guild_id: guildId, channel_id: channelId }),
} }
+2 -68
View File
@@ -988,7 +988,7 @@ body {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
} }
#btn-close-settings, #btn-close-uploads { #btn-close-uploads {
width: 42px; width: 42px;
height: 42px; height: 42px;
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
@@ -1002,7 +1002,7 @@ body {
cursor: pointer; cursor: pointer;
} }
#btn-close-settings:hover, #btn-close-uploads:hover { #btn-close-uploads:hover {
background: rgba(239, 68, 68, 0.25); background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.6); border-color: rgba(239, 68, 68, 0.6);
color: #ff6b6b; color: #ff6b6b;
@@ -1016,20 +1016,6 @@ body {
flex: 1; flex: 1;
} }
.settings-section {
margin-bottom: 24px;
}
.settings-section h3 {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-main);
margin-bottom: 8px;
}
.settings-desc { .settings-desc {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted); color: var(--text-muted);
@@ -1037,58 +1023,6 @@ body {
line-height: 1.5; line-height: 1.5;
} }
.guild-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-glass);
margin-bottom: 12px;
gap: 16px;
}
.guild-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.guild-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: rgba(139, 92, 246, 0.15);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.guild-name {
font-weight: 600;
color: var(--text-main);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-id {
font-size: 0.75rem;
color: var(--text-muted);
}
.guild-control {
width: 240px;
flex-shrink: 0;
}
/* Zapobieganie rozjeżdżaniu się layoutu przy długich tytułach nagrań */ /* Zapobieganie rozjeżdżaniu się layoutu przy długich tytułach nagrań */
.now-playing-info, .now-playing-info,
.track-title, .track-title,
+21 -9
View File
@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { Music, Settings } from 'lucide-vue-next' import { Music } from 'lucide-vue-next'
import type { BotStatus } from '@/types/status' import type { BotStatus, GuildSettings } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>() const props = defineProps<{
defineEmits<{ openSettings: [] }>() status: BotStatus | null
guilds: GuildSettings[]
selectedGuildId: string | null
}>()
defineEmits<{ selectGuild: [guildId: string] }>()
const activeChannel = computed(() => props.status?.active_voice_channel ?? null) const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
@@ -28,12 +32,20 @@ const label = computed(() =>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button <select
class="btn btn-secondary" v-if="guilds.length"
@click="$emit('openSettings')" :value="selectedGuildId ?? undefined"
class="premium-select"
@change="$emit('selectGuild', ($event.target as HTMLSelectElement).value)"
> >
<Settings /> Ustawienia <option
</button> v-for="guild in guilds"
:key="guild.guild_id"
:value="guild.guild_id"
>
{{ guild.guild_name }}
</option>
</select>
<div class="bot-status-badge"> <div class="bot-status-badge">
<span <span
class="status-indicator" class="status-indicator"
-173
View File
@@ -1,173 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Repeat, Save, Server, Settings, Shield } from 'lucide-vue-next'
import ModalShell from '@/components/ModalShell.vue'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import type { BotStatus } from '@/types/status'
const MIN_PLAYS = 1
const MAX_PLAYS = 999
const props = defineProps<{ status: BotStatus | null }>()
defineEmits<{ close: [] }>()
const { notify, run } = useToasts()
const defaultPlays = ref(props.status?.default_plays ?? 5)
const editing = ref(false)
const guilds = computed(() => props.status?.guilds ?? [])
watch(
() => props.status?.default_plays,
(value) => {
if (!editing.value && value !== undefined) defaultPlays.value = value
},
{ immediate: true },
)
async function saveDefaultPlays() {
const value = Number(defaultPlays.value)
if (!Number.isInteger(value) || value < MIN_PLAYS || value > MAX_PLAYS) {
notify('Wartość musi być z przedziału 1-999', 'error')
return
}
await run(
() => api.setDefaultPlays(value),
`Domyślna liczba odtworzeń: ${value}`,
'Błąd zapisu ustawienia',
)
}
const saveLogChannel = (guildId: string, channelId: string) =>
run(
() => api.setLogChannel(guildId, channelId),
'Zapisano kanał powiadomień dla serwera!',
'Nie udało się zapisać kanału powiadomień',
)
</script>
<template>
<ModalShell @close="$emit('close')">
<template #title>
<Settings /> Ustawienia ŻyrarBOT
</template>
<section class="settings-section divided">
<h3><Repeat /> Domyślna liczba odtworzeń w kolejce</h3>
<p class="settings-desc">
Ustaw domyślną liczbę odtworzeń dla każdego nowo dodawanego utworu do kolejki. Przy każdym
odtworzeniu utworu wartość jest pomniejszana o 1, a po spadku do 0 utwór usuwany jest
z kolejki.
</p>
<div class="plays-row">
<input
v-model.number="defaultPlays"
type="number"
class="plays-input"
:min="MIN_PLAYS"
:max="MAX_PLAYS"
@focus="editing = true"
@blur="editing = false"
>
<button
class="btn btn-primary"
@click="saveDefaultPlays"
>
<Save /> Zapisz
</button>
</div>
</section>
<section class="settings-section">
<h3><Server /> Konfiguracja Serwerów Discord (Kanały Logów)</h3>
<p class="settings-desc">
Wybierz kanał tekstowy na każdym serwerze Discord, na który bot ma wysyłać powiadomienia
o odtwarzanych utworach i zmianach w kolejce.
</p>
<div class="guilds-list">
<div
v-for="guild in guilds"
:key="guild.guild_id"
class="guild-setting-row glass-card-subtle"
>
<div class="guild-info">
<div class="guild-icon">
<Shield />
</div>
<div>
<div class="guild-name">
{{ guild.guild_name }}
</div>
<div class="guild-id">
ID: {{ guild.guild_id }}
</div>
</div>
</div>
<div class="guild-control">
<select
class="premium-select"
:value="guild.active_notification_channel_id ?? ''"
@change="saveLogChannel(guild.guild_id, ($event.target as HTMLSelectElement).value)"
>
<option value="">
-- Brak / Domyślny --
</option>
<option
v-for="channel in guild.text_channels"
:key="channel.channel_id"
:value="channel.channel_id"
>
#{{ channel.channel_name }}
</option>
</select>
</div>
</div>
<div
v-if="!guilds.length"
class="empty-queue-message"
>
<Server />
<p>Brak serwerów Discord w pamięci bota.</p>
</div>
</div>
</section>
</ModalShell>
</template>
<style scoped>
.settings-section.divided {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.plays-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.plays-input {
width: 100px;
text-align: center;
font-weight: bold;
font-size: 1.1rem;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
color: var(--text-main);
}
.plays-row .btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
}
</style>
+27 -6
View File
@@ -1,6 +1,7 @@
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import type { BotStatus } from '@/types/status' import type { BotStatus } from '@/types/status'
import { useToasts } from '@/composables/useToasts' import { useToasts } from '@/composables/useToasts'
import { getGuildId, setGuildId } from '@/api/client'
const RECONNECT_DELAY_MS = 3000 const RECONNECT_DELAY_MS = 3000
@@ -20,9 +21,13 @@ export function useBotStatus() {
function open() { function open() {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(`${scheme}//${window.location.host}/ws`) const guild = getGuildId()
const currentSocket = new WebSocket(
`${scheme}//${window.location.host}/ws${guild ? `?guild_id=${guild}` : ''}`,
)
socket = currentSocket
socket.onopen = () => { currentSocket.onopen = () => {
connected.value = true connected.value = true
notify( notify(
connectedOnce ? 'Połączenie WebSocket przywrócone (Live)!' : 'WebSocket zsynchronizowany (Live)!', connectedOnce ? 'Połączenie WebSocket przywrócone (Live)!' : 'WebSocket zsynchronizowany (Live)!',
@@ -31,7 +36,8 @@ export function useBotStatus() {
connectedOnce = true connectedOnce = true
} }
socket.onmessage = (event) => { currentSocket.onmessage = (event) => {
if (socket !== currentSocket) return
if (paused.value) return if (paused.value) return
try { try {
status.value = JSON.parse(event.data) as BotStatus status.value = JSON.parse(event.data) as BotStatus
@@ -40,7 +46,8 @@ export function useBotStatus() {
} }
} }
socket.onclose = () => { currentSocket.onclose = () => {
if (socket !== currentSocket) return
connected.value = false connected.value = false
socket = null socket = null
if (subscribers > 0) { if (subscribers > 0) {
@@ -48,7 +55,7 @@ export function useBotStatus() {
} }
} }
socket.onerror = () => socket?.close() currentSocket.onerror = () => currentSocket.close()
} }
function connect() { function connect() {
@@ -67,7 +74,21 @@ export function useBotStatus() {
socket = null socket = null
} }
function selectGuild(guildId: string) {
setGuildId(guildId)
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socket) {
const previousSocket = socket
socket = null
previousSocket.close()
open()
}
}
onUnmounted(disconnect) onUnmounted(disconnect)
return { status, connected, paused, connect, disconnect } return { status, connected, paused, connect, disconnect, selectGuild }
} }
-2
View File
@@ -30,8 +30,6 @@ EXPECTED_ENDPOINTS = {
("delete", "/api/uploads/{filename}"), ("delete", "/api/uploads/{filename}"),
("post", "/api/connect"), ("post", "/api/connect"),
("post", "/api/disconnect"), ("post", "/api/disconnect"),
("post", "/api/settings/text_channel"),
("post", "/api/settings/default_plays"),
} }
+32
View File
@@ -0,0 +1,32 @@
import json
import tempfile
import unittest
from pathlib import Path
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
from zyrarbot.storage.json_file_store import JsonFileStore
class GuildConfigRepositoryTest(unittest.TestCase):
def test_settings_are_isolated_and_legacy_default_moves_to_first_guild(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "config.json"
path.write_text(json.dumps({"default_plays": 7, "10": 123}), encoding="utf-8")
repository = GuildConfigRepository(JsonFileStore(str(path)), fallback_plays=5)
self.assertEqual(repository.get_default_plays(10), 7)
self.assertEqual(repository.get_notification_channel_id(10), 123)
self.assertEqual(repository.get_default_plays(20), 5)
repository.set_default_plays(20, 9)
self.assertEqual(repository.get_default_plays(10), 7)
self.assertEqual(repository.get_default_plays(20), 9)
saved = json.loads(path.read_text(encoding="utf-8"))
self.assertNotIn("default_plays", saved)
self.assertEqual(saved["10"]["default_plays"], 7)
self.assertEqual(saved["20"]["default_plays"], 9)
if __name__ == "__main__":
unittest.main()
+14 -7
View File
@@ -59,15 +59,22 @@ class MusicPlayer:
def is_ready(self) -> bool: def is_ready(self) -> bool:
return self._bot.is_ready() return self._bot.is_ready()
@property
@property @property
def default_plays(self) -> int: def default_plays(self) -> int:
"""Legacy fallback used when no guild context is available."""
return self._config.get_default_plays() return self._config.get_default_plays()
def set_default_plays(self, value: int) -> None: def guild_default_plays(self, guild_id: int) -> int:
self._config.set_default_plays(value) return self._config.get_default_plays(guild_id)
def plays_left(self, track: YTDLSource) -> int: def set_default_plays(self, guild_id: int, value: int) -> None:
return track.plays_left if track.plays_left is not None else self.default_plays self._config.set_default_plays(guild_id, value)
def plays_left(self, track: YTDLSource, guild_id: int | None = None) -> int:
return (
track.plays_left if track.plays_left is not None else self.guild_default_plays(guild_id)
)
def history(self, guild_id: int) -> list[HistoryEntry]: def history(self, guild_id: int) -> list[HistoryEntry]:
return self._history.list(guild_id) return self._history.list(guild_id)
@@ -124,7 +131,7 @@ class MusicPlayer:
if channel: if channel:
state.notification_channel = channel state.notification_channel = channel
if track.plays_left is None: if track.plays_left is None:
track.plays_left = self.default_plays track.plays_left = self.guild_default_plays(guild_id)
state.queue.append(track) state.queue.append(track)
@@ -200,7 +207,7 @@ class MusicPlayer:
retired_index: int | None = None retired_index: int | None = None
if advance and state.current is not None and 0 <= state.index < len(state.queue): if advance and state.current is not None and 0 <= state.index < len(state.queue):
current = state.queue[state.index] current = state.queue[state.index]
if current is state.current and self.plays_left(current) <= 0: if current is state.current and self.plays_left(current, guild_id) <= 0:
retired_index = state.index retired_index = state.index
state.queue.pop(state.index) state.queue.pop(state.index)
state.index -= 1 state.index -= 1
@@ -239,7 +246,7 @@ class MusicPlayer:
return return
track.played = True track.played = True
track.plays_left = self.plays_left(track) - 1 track.plays_left = self.plays_left(track, guild_id) - 1
track.volume = state.volume track.volume = state.volume
state.queue[next_index] = track state.queue[next_index] = track
+43 -10
View File
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
class GuildConfigRepository: class GuildConfigRepository:
"""Dostęp do config.json: kanały powiadomień per serwer + globalne ustawienia.""" """Dostęp do konfiguracji przechowywanej osobno dla każdego serwera."""
DEFAULT_PLAYS_KEY = "default_plays" DEFAULT_PLAYS_KEY = "default_plays"
@@ -16,21 +16,52 @@ class GuildConfigRepository:
self._store = store self._store = store
self._fallback_plays = fallback_plays self._fallback_plays = fallback_plays
def _guild_config(self, guild_id: int) -> dict[str, int | None]:
data = self._store.read()
key = str(guild_id)
value = data.get(key)
# Migrate the old format {"guild_id": channel_id} lazily.
if isinstance(value, dict):
return value
config: dict[str, int | None] = {}
if value is not None:
try:
config["notification_channel_id"] = int(value)
except (TypeError, ValueError):
logger.error(
"Nieprawidłowe ID kanału w konfiguracji serwera %s: %r", guild_id, value
)
# The old default was global. The first guild that is accessed owns it.
legacy_default = data.get(self.DEFAULT_PLAYS_KEY)
if legacy_default is not None:
try:
config["default_plays"] = int(legacy_default)
except (TypeError, ValueError):
logger.error("Nieprawidłowa wartość %s: %r", self.DEFAULT_PLAYS_KEY, legacy_default)
data.pop(self.DEFAULT_PLAYS_KEY, None)
data[key] = config
self._store.write(data)
return config
def get_notification_channel_id(self, guild_id: int) -> int | None: def get_notification_channel_id(self, guild_id: int) -> int | None:
value = self._store.get(str(guild_id)) value = self._guild_config(guild_id).get("notification_channel_id")
if value is None:
return None
try: try:
return int(value) return int(value) if value is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
logger.error("Nieprawidłowe ID kanału w konfiguracji serwera %s: %r", guild_id, value) logger.error("Nieprawidłowe ID kanału w konfiguracji serwera %s: %r", guild_id, value)
return None return None
def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None: def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None:
self._store.set(str(guild_id), channel_id) config = self._guild_config(guild_id)
config["notification_channel_id"] = channel_id
self._store.set(str(guild_id), config)
def get_default_plays(self) -> int: def get_default_plays(self, guild_id: int | None = None) -> int:
value = self._store.get(self.DEFAULT_PLAYS_KEY) if guild_id is None:
return self._fallback_plays
value = self._guild_config(guild_id).get("default_plays")
if value is None: if value is None:
return self._fallback_plays return self._fallback_plays
try: try:
@@ -39,5 +70,7 @@ class GuildConfigRepository:
logger.error("Nieprawidłowa wartość %s: %r", self.DEFAULT_PLAYS_KEY, value) logger.error("Nieprawidłowa wartość %s: %r", self.DEFAULT_PLAYS_KEY, value)
return self._fallback_plays return self._fallback_plays
def set_default_plays(self, value: int) -> None: def set_default_plays(self, guild_id: int, value: int) -> None:
self._store.set(self.DEFAULT_PLAYS_KEY, int(value)) config = self._guild_config(guild_id)
config["default_plays"] = int(value)
self._store.set(str(guild_id), config)
+13 -2
View File
@@ -5,12 +5,13 @@ from zyrarbot.storage.json_file_store import JsonFileStore
class HistoryRepository: class HistoryRepository:
"""Historia odtwarzania per serwer, buforowana w pamięci i utrwalana w history.json.""" """Historia odtwarzania per serwer, buforowana i utrwalana w history.json."""
def __init__(self, store: JsonFileStore, limit: int = 50): def __init__(self, store: JsonFileStore, limit: int = 50):
self._store = store self._store = store
self._limit = limit self._limit = limit
self._cache: dict[int, list[HistoryEntry]] = {} self._cache: dict[int, list[HistoryEntry]] = {}
self._legacy_guild_id: int | None = None
def list(self, guild_id: int) -> list[HistoryEntry]: def list(self, guild_id: int) -> list[HistoryEntry]:
if guild_id not in self._cache: if guild_id not in self._cache:
@@ -29,7 +30,17 @@ class HistoryRepository:
return next((item for item in self.list(guild_id) if item.id == entry_id), None) return next((item for item in self.list(guild_id) if item.id == entry_id), None)
def _load(self, guild_id: int) -> list[HistoryEntry]: def _load(self, guild_id: int) -> list[HistoryEntry]:
raw_entries = self._store.get(str(guild_id), []) or [] raw_entries = self._store.get(str(guild_id))
if raw_entries is None and self._legacy_guild_id is None:
legacy = self._store.get("history", [])
if isinstance(legacy, list):
raw_entries = legacy
self._legacy_guild_id = guild_id
self._store.set(str(guild_id), legacy)
data = self._store.read()
data.pop("history", None)
self._store.write(data)
raw_entries = raw_entries or []
entries: list[HistoryEntry] = [] entries: list[HistoryEntry] = []
seen: set[tuple[str, str | None]] = set() seen: set[tuple[str, str | None]] = set()
for raw in raw_entries: for raw in raw_entries:
+48 -16
View File
@@ -12,22 +12,51 @@ class UploadsRepository:
def __init__(self, directory: str): def __init__(self, directory: str):
self._directory = directory self._directory = directory
self._legacy_guild_id: int | None = None
@property @property
def directory(self) -> str: def directory(self) -> str:
return self._directory return self._directory
def resolve(self, filename: str) -> str: def _guild_directory(self, guild_id: int | None) -> str:
return os.path.join(self._directory, os.path.basename(filename)) if guild_id is None:
return self._directory
self._migrate_legacy(guild_id)
return os.path.join(self._directory, str(guild_id))
def exists(self, filename: str) -> bool: def _migrate_legacy(self, guild_id: int) -> None:
return os.path.isfile(self.resolve(filename)) """Przenosi stare, niesegregowane pliki do pierwszego użytego serwera."""
if self._legacy_guild_id is not None:
return
def list(self) -> list[UploadFileInfo]:
os.makedirs(self._directory, exist_ok=True) os.makedirs(self._directory, exist_ok=True)
legacy_files = [
name
for name in os.listdir(self._directory)
if os.path.isfile(os.path.join(self._directory, name)) and not name.startswith(".")
]
target = os.path.join(self._directory, str(guild_id))
if legacy_files:
os.makedirs(target, exist_ok=True)
for name in legacy_files:
shutil.move(
os.path.join(self._directory, name),
os.path.join(target, name),
)
self._legacy_guild_id = guild_id
def resolve(self, filename: str, guild_id: int | None = None) -> str:
return os.path.join(self._guild_directory(guild_id), os.path.basename(filename))
def exists(self, filename: str, guild_id: int | None = None) -> bool:
return os.path.isfile(self.resolve(filename, guild_id))
def list(self, guild_id: int | None = None) -> list[UploadFileInfo]:
directory = self._guild_directory(guild_id)
os.makedirs(directory, exist_ok=True)
files = [] files = []
for name in os.listdir(self._directory): for name in os.listdir(directory):
path = os.path.join(self._directory, name) path = os.path.join(directory, name)
if not os.path.isfile(path) or name.startswith("."): if not os.path.isfile(path) or name.startswith("."):
continue continue
files.append( files.append(
@@ -40,25 +69,28 @@ class UploadsRepository:
files.sort(key=lambda item: item.mtime, reverse=True) files.sort(key=lambda item: item.mtime, reverse=True)
return files return files
def search(self, query: str, limit: int = 25) -> list[str]: def search(self, query: str, limit: int = 25, guild_id: int | None = None) -> list[str]:
needle = query.lower() needle = query.lower()
return [item.filename for item in self.list() if needle in item.filename.lower()][:limit] return [item.filename for item in self.list(guild_id) if needle in item.filename.lower()][
:limit
]
def save(self, filename: str, source: BinaryIO) -> str: def save(self, filename: str, source: BinaryIO, guild_id: int | None = None) -> str:
os.makedirs(self._directory, exist_ok=True) directory = self._guild_directory(guild_id)
os.makedirs(directory, exist_ok=True)
name = os.path.basename(filename) name = os.path.basename(filename)
with open(self.resolve(name), "wb") as target: with open(self.resolve(name, guild_id), "wb") as target:
shutil.copyfileobj(source, target) shutil.copyfileobj(source, target)
return name return name
def delete(self, filename: str) -> bool: def delete(self, filename: str, guild_id: int | None = None) -> bool:
path = self.resolve(filename) path = self.resolve(filename, guild_id)
if not os.path.isfile(path): if not os.path.isfile(path):
return False return False
os.remove(path) os.remove(path)
return True return True
def touch(self, filename: str) -> None: def touch(self, filename: str, guild_id: int | None = None) -> None:
path = self.resolve(filename) path = self.resolve(filename, guild_id)
if os.path.isfile(path): if os.path.isfile(path):
os.utime(path, None) os.utime(path, None)
+11 -8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
@@ -9,23 +10,25 @@ class ConnectionManager:
"""Rejestr aktywnych połączeń WebSocket panelu webowego.""" """Rejestr aktywnych połączeń WebSocket panelu webowego."""
def __init__(self) -> None: def __init__(self) -> None:
self._connections: list[WebSocket] = [] self._connections: dict[WebSocket, int | None] = {}
@property @property
def has_listeners(self) -> bool: def has_listeners(self) -> bool:
return bool(self._connections) return bool(self._connections)
async def connect(self, websocket: WebSocket) -> None: async def connect(self, websocket: WebSocket, guild_id: int | None = None) -> None:
await websocket.accept() await websocket.accept()
self._connections.append(websocket) self._connections[websocket] = guild_id
def disconnect(self, websocket: WebSocket) -> None: def disconnect(self, websocket: WebSocket) -> None:
if websocket in self._connections: self._connections.pop(websocket, None)
self._connections.remove(websocket)
async def broadcast(self, message: dict[str, Any]) -> None: async def broadcast(
for connection in list(self._connections): self, message: dict[str, Any] | Callable[[int | None], dict[str, Any]]
) -> None:
for connection, guild_id in list(self._connections.items()):
try: try:
await connection.send_json(message) payload = message(guild_id) if callable(message) else message
await connection.send_json(payload)
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
self.disconnect(connection) self.disconnect(connection)
+8 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import discord import discord
from fastapi import HTTPException, Request, WebSocket from fastapi import HTTPException, Query, Request, WebSocket
from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.audio.track_factory import TrackFactory
from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.music_player import MusicPlayer
@@ -56,9 +56,12 @@ def get_socket_status_service(websocket: WebSocket) -> StatusService:
return websocket.app.state.status_service return websocket.app.state.status_service
def get_active_guild(request: Request) -> discord.Guild: def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild:
"""Serwer, którym steruje panel; brak kontekstu kończy się błędem 400.""" """Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
guild = get_guilds(request).active_guild() guilds = get_guilds(request)
guild = next((item for item in guilds.guilds() if item.id == guild_id), None)
if guild_id is None:
guild = guilds.active_guild()
if guild is None: if guild is None:
raise HTTPException(status_code=400, detail=NO_GUILD_DETAIL) raise HTTPException(status_code=404, detail="Unknown Discord server")
return guild return guild
-2
View File
@@ -11,7 +11,6 @@ from zyrarbot.ui.api.connection_manager import ConnectionManager
from zyrarbot.ui.api.routers import ( from zyrarbot.ui.api.routers import (
playback_router, playback_router,
queue_router, queue_router,
settings_router,
status_router, status_router,
uploads_router, uploads_router,
voice_router, voice_router,
@@ -27,7 +26,6 @@ ROUTERS = (
queue_router.router, queue_router.router,
voice_router.router, voice_router.router,
uploads_router.router, uploads_router.router,
settings_router.router,
websocket_router.router, websocket_router.router,
) )
@@ -1,7 +0,0 @@
from pydantic import BaseModel
class DefaultPlaysRequest(BaseModel):
"""Domyślna liczba odtworzeń przypisywana nowym utworom."""
default_plays: int
@@ -1,8 +0,0 @@
from pydantic import BaseModel
class TextChannelRequest(BaseModel):
"""Ustawienie kanału powiadomień; puste ``channel_id`` czyści ustawienie."""
channel_id: str
guild_id: str | None = None
+3 -3
View File
@@ -65,13 +65,13 @@ async def play_file(
broadcaster: StatusBroadcaster = Depends(get_broadcaster), broadcaster: StatusBroadcaster = Depends(get_broadcaster),
) -> dict[str, Any]: ) -> dict[str, Any]:
_require_voice(player, guild) _require_voice(player, guild)
if not uploads.exists(request.filename): if not uploads.exists(request.filename, guild.id):
raise HTTPException(status_code=404, detail="Plik nie istnieje na serwerze") raise HTTPException(status_code=404, detail="Plik nie istnieje na serwerze")
try: try:
# Odświeżenie mtime utrzymuje plik na szczycie listy ostatnio używanych. # Odświeżenie mtime utrzymuje plik na szczycie listy ostatnio używanych.
uploads.touch(request.filename) uploads.touch(request.filename, guild.id)
track = await tracks.from_file(uploads.resolve(request.filename)) track = await tracks.from_file(uploads.resolve(request.filename, guild.id))
result = await player.enqueue(guild.id, track, from_web=True) result = await player.enqueue(guild.id, track, from_web=True)
except Exception as error: except Exception as error:
logger.error("Nie udało się odtworzyć pliku z panelu: %s", error) logger.error("Nie udało się odtworzyć pliku z panelu: %s", error)
@@ -1,82 +0,0 @@
from __future__ import annotations
import logging
from typing import Any
import discord
from fastapi import APIRouter, Depends, HTTPException
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.settings import Settings
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
from zyrarbot.ui.api.dependencies import (
get_broadcaster,
get_config,
get_guilds,
get_player,
get_settings,
)
from zyrarbot.ui.api.requests.default_plays_request import DefaultPlaysRequest
from zyrarbot.ui.api.requests.text_channel_request import TextChannelRequest
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
from zyrarbot.ui.bot.guild_resolver import GuildResolver
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.post("/text_channel")
async def set_text_channel(
request: TextChannelRequest,
guilds: GuildResolver = Depends(get_guilds),
player: MusicPlayer = Depends(get_player),
config: GuildConfigRepository = Depends(get_config),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
) -> dict[str, Any]:
if request.channel_id in ("", "0"):
if not request.guild_id:
raise HTTPException(status_code=400, detail="guild_id required to reset channel")
guild_id = int(request.guild_id)
player.set_notification_channel(guild_id, None)
config.set_notification_channel_id(guild_id, None)
await broadcaster.broadcast()
return {"status": "success", "channel_id": None, "guild_id": str(guild_id)}
channel = guilds.channel(int(request.channel_id))
if not isinstance(channel, discord.TextChannel):
raise HTTPException(status_code=404, detail="Text channel not found")
guild_id = channel.guild.id
if request.guild_id and int(request.guild_id) != guild_id:
raise HTTPException(
status_code=400, detail="Channel does not belong to the specified guild"
)
player.set_notification_channel(guild_id, channel)
config.set_notification_channel_id(guild_id, channel.id)
try:
await channel.send(
"🔔 Ten kanał został ustawiony jako główny kanał powiadomień dla ŻyrarBOT "
f"na serwerze **{channel.guild.name}** z panelu webowego."
)
except discord.DiscordException as error:
logger.error("Nie udało się wysłać wiadomości testowej: %s", error)
await broadcaster.broadcast()
return {"status": "success", "channel_id": request.channel_id, "guild_id": str(guild_id)}
@router.post("/default_plays")
async def set_default_plays(
request: DefaultPlaysRequest,
player: MusicPlayer = Depends(get_player),
settings: Settings = Depends(get_settings),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
) -> dict[str, Any]:
if not settings.is_valid_plays(request.default_plays):
raise HTTPException(status_code=400, detail="Invalid default plays value")
player.set_default_plays(request.default_plays)
await broadcaster.broadcast()
return {"status": "success", "default_plays": request.default_plays}
+7 -3
View File
@@ -2,18 +2,22 @@ from __future__ import annotations
from typing import Any from typing import Any
import discord
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from zyrarbot.music.music_player import MusicPlayer from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.ui.api.dependencies import get_player, get_status_service from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
from zyrarbot.ui.api.status_service import StatusService from zyrarbot.ui.api.status_service import StatusService
router = APIRouter(prefix="/api", tags=["status"]) router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status") @router.get("/status")
async def get_status(status: StatusService = Depends(get_status_service)) -> dict[str, Any]: async def get_status(
return status.snapshot() guild: discord.Guild = Depends(get_active_guild),
status: StatusService = Depends(get_status_service),
) -> dict[str, Any]:
return status.snapshot(guild.id)
@router.get("/health") @router.get("/health")
+12 -6
View File
@@ -2,26 +2,31 @@ from __future__ import annotations
from typing import Any from typing import Any
import discord
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from zyrarbot.storage.uploads_repository import UploadsRepository from zyrarbot.storage.uploads_repository import UploadsRepository
from zyrarbot.ui.api.dependencies import get_uploads from zyrarbot.ui.api.dependencies import get_active_guild, get_uploads
router = APIRouter(prefix="/api", tags=["uploads"]) router = APIRouter(prefix="/api", tags=["uploads"])
@router.get("/uploads") @router.get("/uploads")
async def list_uploads(uploads: UploadsRepository = Depends(get_uploads)) -> dict[str, Any]: async def list_uploads(
return {"files": [item.to_dict() for item in uploads.list()]} guild: discord.Guild = Depends(get_active_guild),
uploads: UploadsRepository = Depends(get_uploads),
) -> dict[str, Any]:
return {"files": [item.to_dict() for item in uploads.list(guild.id)]}
@router.post("/upload") @router.post("/upload")
async def upload( async def upload(
file: UploadFile = File(...), file: UploadFile = File(...),
guild: discord.Guild = Depends(get_active_guild),
uploads: UploadsRepository = Depends(get_uploads), uploads: UploadsRepository = Depends(get_uploads),
) -> dict[str, str]: ) -> dict[str, str]:
try: try:
filename = uploads.save(file.filename or "plik", file.file) filename = uploads.save(file.filename or "plik", file.file, guild.id)
except OSError as error: except OSError as error:
raise HTTPException(status_code=500, detail=f"Błąd zapisu pliku: {error}") from error raise HTTPException(status_code=500, detail=f"Błąd zapisu pliku: {error}") from error
return {"status": "success", "filename": filename} return {"status": "success", "filename": filename}
@@ -30,12 +35,13 @@ async def upload(
@router.delete("/uploads/{filename}") @router.delete("/uploads/{filename}")
async def delete_upload( async def delete_upload(
filename: str, filename: str,
guild: discord.Guild = Depends(get_active_guild),
uploads: UploadsRepository = Depends(get_uploads), uploads: UploadsRepository = Depends(get_uploads),
) -> dict[str, str]: ) -> dict[str, str]:
if not uploads.exists(filename): if not uploads.exists(filename, guild.id):
raise HTTPException(status_code=404, detail="Plik nie istnieje") raise HTTPException(status_code=404, detail="Plik nie istnieje")
try: try:
uploads.delete(filename) uploads.delete(filename, guild.id)
except OSError as error: except OSError as error:
raise HTTPException(status_code=500, detail=str(error)) from error raise HTTPException(status_code=500, detail=str(error)) from error
return {"status": "deleted"} return {"status": "deleted"}
+4 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from zyrarbot.ui.api.connection_manager import ConnectionManager from zyrarbot.ui.api.connection_manager import ConnectionManager
from zyrarbot.ui.api.dependencies import get_connections, get_socket_status_service from zyrarbot.ui.api.dependencies import get_connections, get_socket_status_service
@@ -18,10 +18,11 @@ async def websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
connections: ConnectionManager = Depends(get_connections), connections: ConnectionManager = Depends(get_connections),
status: StatusService = Depends(get_socket_status_service), status: StatusService = Depends(get_socket_status_service),
guild_id: int | None = Query(default=None),
) -> None: ) -> None:
await connections.connect(websocket) await connections.connect(websocket, guild_id)
try: try:
await websocket.send_json(status.snapshot()) await websocket.send_json(status.snapshot(guild_id))
while True: while True:
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie. # Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
await websocket.receive_text() await websocket.receive_text()
+1 -1
View File
@@ -24,7 +24,7 @@ class StatusBroadcaster:
if not self._connections.has_listeners: if not self._connections.has_listeners:
return return
try: try:
await self._connections.broadcast(self._status.snapshot()) await self._connections.broadcast(self._status.snapshot)
except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli
logger.error("Błąd rozgłaszania stanu: %s", error) logger.error("Błąd rozgłaszania stanu: %s", error)
+18 -12
View File
@@ -19,14 +19,19 @@ class StatusService:
self._player = player self._player = player
self._guilds = guilds self._guilds = guilds
def snapshot(self) -> dict[str, Any]: def snapshot(self, guild_id: int | None = None) -> dict[str, Any]:
guild = (
next((item for item in self._guilds.guilds() if item.id == guild_id), None)
if guild_id is not None
else self._guilds.active_guild()
)
payload: dict[str, Any] = { payload: dict[str, Any] = {
"guilds": self._guilds_payload(), "guilds": self._guilds_payload(),
"voice_channels": self._voice_channels_payload(), "voice_channels": self._voice_channels_payload(guild),
"default_plays": self._player.default_plays, "default_plays": self._player.guild_default_plays(guild.id)
if guild
else self._player.default_plays,
} }
guild = self._guilds.active_guild()
if guild is None: if guild is None:
payload.update(self._idle_payload()) payload.update(self._idle_payload())
return payload return payload
@@ -63,8 +68,8 @@ class StatusService:
"loop_single": state.loop_mode.loops_track, "loop_single": state.loop_mode.loops_track,
"loop_playlist": state.loop_mode.loops_queue, "loop_playlist": state.loop_mode.loops_queue,
"volume": int(state.volume * 100), "volume": int(state.volume * 100),
"current": self._current_payload(state.current), "current": self._current_payload(state.current, guild.id),
"queue": [self._track_payload(track) for track in state.queue], "queue": [self._track_payload(track, guild.id) for track in state.queue],
"queue_index": state.index, "queue_index": state.index,
"active_voice_channel": self._active_voice_payload(voice_client), "active_voice_channel": self._active_voice_payload(voice_client),
"text_channels": [ "text_channels": [
@@ -79,7 +84,7 @@ class StatusService:
], ],
} }
def _current_payload(self, track: YTDLSource | None) -> dict[str, Any] | None: def _current_payload(self, track: YTDLSource | None, guild_id: int) -> dict[str, Any] | None:
if track is None: if track is None:
return None return None
return { return {
@@ -88,15 +93,15 @@ class StatusService:
"id": track.id, "id": track.id,
"duration": track.duration, "duration": track.duration,
"elapsed": round(track.elapsed, 1), "elapsed": round(track.elapsed, 1),
"plays_left": self._player.plays_left(track), "plays_left": self._player.plays_left(track, guild_id),
} }
def _track_payload(self, track: YTDLSource) -> dict[str, Any]: def _track_payload(self, track: YTDLSource, guild_id: int) -> dict[str, Any]:
return { return {
"title": track.title, "title": track.title,
"url": track.display_url, "url": track.display_url,
"id": track.id, "id": track.id,
"plays_left": self._player.plays_left(track), "plays_left": self._player.plays_left(track, guild_id),
} }
def _active_voice_payload( def _active_voice_payload(
@@ -112,7 +117,7 @@ class StatusService:
"guild_id": str(channel.guild.id), "guild_id": str(channel.guild.id),
} }
def _voice_channels_payload(self) -> list[dict[str, Any]]: def _voice_channels_payload(self, selected_guild: discord.Guild | None) -> list[dict[str, Any]]:
return [ return [
{ {
"guild_name": guild.name, "guild_name": guild.name,
@@ -121,6 +126,7 @@ class StatusService:
"channel_id": str(channel.id), "channel_id": str(channel.id),
} }
for guild in self._guilds.guilds() for guild in self._guilds.guilds()
if selected_guild is None or guild.id == selected_guild.id
for channel in guild.voice_channels for channel in guild.voice_channels
] ]
+15 -6
View File
@@ -32,7 +32,9 @@ class PlaybackCog(MusicCogBase, name="Playback"):
async def _uploads_autocomplete( async def _uploads_autocomplete(
self, interaction: discord.Interaction, current: str self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]: ) -> list[app_commands.Choice[str]]:
names = self._uploads.search(current, limit=AUTOCOMPLETE_LIMIT) names = self._uploads.search(
current, limit=AUTOCOMPLETE_LIMIT, guild_id=interaction.guild.id
)
return [app_commands.Choice(name=name, value=name) for name in names] return [app_commands.Choice(name=name, value=name) for name in names]
async def _history_autocomplete( async def _history_autocomplete(
@@ -52,7 +54,12 @@ class PlaybackCog(MusicCogBase, name="Playback"):
return return
await interaction.response.defer() await interaction.response.defer()
try: try:
track = await self._tracks.resolve(query, loop=self.bot.loop) if self._uploads.exists(query, interaction.guild.id):
track = await self._tracks.from_file(
self._uploads.resolve(query, interaction.guild.id)
)
else:
track = await self._tracks.resolve(query, loop=self.bot.loop)
await self._enqueue_and_reply(interaction, track) await self._enqueue_and_reply(interaction, track)
except Exception as error: # noqa: BLE001 except Exception as error: # noqa: BLE001
logger.error("Komenda /play nie powiodła się: %s", error) logger.error("Komenda /play nie powiodła się: %s", error)
@@ -65,11 +72,13 @@ class PlaybackCog(MusicCogBase, name="Playback"):
if not await self._ensure_voice(interaction): if not await self._ensure_voice(interaction):
return return
await interaction.response.defer() await interaction.response.defer()
if not self._uploads.exists(filename): if not self._uploads.exists(filename, interaction.guild.id):
await interaction.followup.send("Nie znaleziono pliku na serwerze.", ephemeral=True) await interaction.followup.send("Nie znaleziono pliku na serwerze.", ephemeral=True)
return return
try: try:
track = await self._tracks.from_file(self._uploads.resolve(filename)) track = await self._tracks.from_file(
self._uploads.resolve(filename, interaction.guild.id)
)
await self._enqueue_and_reply(interaction, track) await self._enqueue_and_reply(interaction, track)
except Exception as error: # noqa: BLE001 except Exception as error: # noqa: BLE001
logger.error("Komenda /playfile nie powiodła się: %s", error) logger.error("Komenda /playfile nie powiodła się: %s", error)
@@ -95,13 +104,13 @@ class PlaybackCog(MusicCogBase, name="Playback"):
try: try:
if entry.is_local: if entry.is_local:
filepath = entry.replay_source or "" filepath = entry.replay_source or ""
if not self._uploads.exists(filepath): if not self._uploads.exists(filepath, guild_id):
await interaction.followup.send( await interaction.followup.send(
"Plik lokalny z historii już nie istnieje na serwerze.", ephemeral=True "Plik lokalny z historii już nie istnieje na serwerze.", ephemeral=True
) )
return return
track = await self._tracks.from_file( track = await self._tracks.from_file(
self._uploads.resolve(filepath), title=entry.title self._uploads.resolve(filepath, guild_id), title=entry.title
) )
else: else:
track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop) track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop)
+3 -2
View File
@@ -29,7 +29,7 @@ class QueueCog(MusicCogBase, name="Queue"):
lines: list[str] = [] lines: list[str] = []
if state.current and not 0 <= state.index < len(state.queue): if state.current and not 0 <= state.index < len(state.queue):
title = discord.utils.escape_markdown(state.current.title or "") title = discord.utils.escape_markdown(state.current.title or "")
plays = self.player.plays_left(state.current) plays = self.player.plays_left(state.current, interaction.guild.id)
lines.append( lines.append(
f"▶️ **{title}** (obecnie odtwarzane - ostatnie odtworzenie) [🔁 {plays}]\n---" f"▶️ **{title}** (obecnie odtwarzane - ostatnie odtworzenie) [🔁 {plays}]\n---"
) )
@@ -38,7 +38,8 @@ class QueueCog(MusicCogBase, name="Queue"):
prefix = "▶️ **" if is_current else f"{position + 1}. " prefix = "▶️ **" if is_current else f"{position + 1}. "
suffix = "** (obecnie odtwarzane)" if is_current else "" suffix = "** (obecnie odtwarzane)" if is_current else ""
title = discord.utils.escape_markdown(track.title or "") title = discord.utils.escape_markdown(track.title or "")
lines.append(f"{prefix}{title}{suffix} [🔁 {self.player.plays_left(track)}]") plays = self.player.plays_left(track, interaction.guild.id)
lines.append(f"{prefix}{title}{suffix} [🔁 {plays}]")
preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT]) preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT])
if len(lines) > QUEUE_PREVIEW_LIMIT: if len(lines) > QUEUE_PREVIEW_LIMIT:
preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej." preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej."
+1 -1
View File
@@ -49,7 +49,7 @@ class SettingsCog(MusicCogBase, name="Settings"):
"❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True "❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True
) )
return return
self.player.set_default_plays(count) self.player.set_default_plays(interaction.guild.id, count)
await self.player.trigger_state_change() await self.player.trigger_state_change()
await interaction.response.send_message( await interaction.response.send_message(
f"✅ Domyślna liczba odtworzeń dla nowych utworów została ustawiona na: **{count}**." f"✅ Domyślna liczba odtworzeń dla nowych utworów została ustawiona na: **{count}**."