refactor: isolate application state per Discord server
This commit is contained in:
30 files changed
+330
-471
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 }),
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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}**."
|
||||||
|
|||||||
Reference in new issue
Block a user