Files
zyrarbot/web.py
T
lgooseandClaude Bot 8d32d5afc6 feat(api): backend wystawia wyłącznie API i steruje botem
Panel działa w osobnym kontenerze, więc serwis Pythona przestaje serwować
pliki statyczne:

- usunięty mount StaticFiles na "/" wraz z importem
- tytuł aplikacji zmieniony na "ŻyrarBOT API"
- GET / zwraca opis usługi zamiast panelu, żeby wejście pod adres backendu
  nie kończyło się nic niemówiącym 404
- GET /api/health informuje, czy bot zdążył połączyć się z Discordem;
  przydaje się jako healthcheck kontenera i przy diagnozie proxy

Pozostałe endpointy oraz WebSocket pozostają bez zmian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:08:50 +02:00

757 lines
26 KiB
Python

from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File
from pydantic import BaseModel
from typing import List, Optional
import asyncio
import discord
import os
import shutil
# Import bot instance and YTDL class
from bot import bot
from ytdl import YTDLSource
# Initialize FastAPI application.
# The service exposes the API only - the web panel is a separate container.
app = FastAPI(title="ŻyrarBOT API")
# FastAPI Request Models
class PlayRequest(BaseModel):
query: str
class VolumeRequest(BaseModel):
volume: int
class LoopRequest(BaseModel):
loop: bool
class RemoveRequest(BaseModel):
id: str
class ReorderRequest(BaseModel):
queue_ids: List[str]
class JumpRequest(BaseModel):
index: int
class ConnectRequest(BaseModel):
channel_id: str
class TextChannelRequest(BaseModel):
channel_id: str
guild_id: Optional[str] = None
class PlayFileRequest(BaseModel):
filename: str
class DefaultPlaysRequest(BaseModel):
default_plays: int
class TrackPlaysRequest(BaseModel):
id: str
plays: int
# Connection Manager for WebSockets
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in list(self.active_connections):
try:
await connection.send_json(message)
except Exception:
self.disconnect(connection)
manager = ConnectionManager()
# Helper to determine active guild
def get_active_guild():
voice_client = bot.voice_clients[0] if bot.voice_clients else None
if voice_client:
return voice_client.guild
elif bot.guilds:
return bot.guilds[0]
return None
# FastAPI Endpoints
@app.get("/")
async def root():
"""Service descriptor - the panel is served by its own container."""
return {
"service": "zyrarbot-api",
"docs": "/docs",
"status": "/api/status",
"websocket": "/ws",
}
@app.get("/api/health")
async def health():
return {"status": "ok", "discord_ready": bot.is_ready()}
@app.get("/api/status")
async def get_status():
music_cog = bot.get_cog("MusicBot")
if not music_cog:
return {"error": "MusicBot cog not loaded"}
all_guilds = []
for g in bot.guilds:
g_tc = []
for tc in g.text_channels:
if tc.permissions_for(g.me).send_messages:
g_tc.append({
"channel_id": str(tc.id),
"channel_name": tc.name
})
active_notif = music_cog.get_notification_channel(g.id)
all_guilds.append({
"guild_id": str(g.id),
"guild_name": g.name,
"text_channels": g_tc,
"active_notification_channel_id": str(active_notif.id) if active_notif else None
})
all_voice_channels = []
for g in bot.guilds:
for vc in g.voice_channels:
all_voice_channels.append({
"guild_name": g.name,
"guild_id": str(g.id),
"channel_name": vc.name,
"channel_id": str(vc.id)
})
guild = get_active_guild()
if not guild:
return {
"playing": False,
"paused": False,
"loop_single": False,
"loop_playlist": False,
"volume": 100,
"current": None,
"queue": [],
"history": [],
"voice_channels": all_voice_channels,
"active_voice_channel": None,
"text_channels": [],
"active_text_channel_id": None,
"guilds": all_guilds,
"default_plays": music_cog.get_default_plays() if music_cog else 5
}
guild_id = guild.id
# Active voice channel
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
active_vc = None
if voice_client and voice_client.channel:
active_vc = {
"channel_name": voice_client.channel.name,
"channel_id": str(voice_client.channel.id),
"guild_name": voice_client.channel.guild.name,
"guild_id": str(voice_client.channel.guild.id)
}
# Gather text channels available to the bot on this active guild where it can write
text_channels = []
for channel in guild.text_channels:
if channel.permissions_for(guild.me).send_messages:
text_channels.append({
"guild_name": guild.name,
"guild_id": str(guild.id),
"channel_name": channel.name,
"channel_id": str(channel.id)
})
active_tc_id = None
active_tc = music_cog.get_notification_channel(guild_id)
if active_tc:
active_tc_id = str(active_tc.id)
playing = voice_client.is_playing() if voice_client else False
paused = voice_client.is_paused() if voice_client else False
current_track = music_cog.get_current(guild_id)
queue = music_cog.get_queue(guild_id)
return {
"playing": playing,
"paused": paused,
"loop_single": music_cog.get_loop_current(guild_id),
"loop_playlist": music_cog.get_loop_playlist(guild_id),
"volume": int(music_cog.get_volume(guild_id) * 100),
"current": {
"title": current_track.title,
"url": current_track.data.get("webpage_url") or current_track.url,
"id": current_track.id,
"duration": current_track.duration,
"elapsed": round(current_track.elapsed, 1),
"plays_left": getattr(current_track, 'plays_left', music_cog.get_default_plays())
} if current_track else None,
"queue": [
{
"title": item.title,
"url": item.data.get("webpage_url") or item.url,
"id": item.id,
"plays_left": getattr(item, 'plays_left', music_cog.get_default_plays())
} for item in queue
],
"queue_index": music_cog.get_queue_index(guild_id),
"default_plays": music_cog.get_default_plays(),
"voice_channels": all_voice_channels,
"active_voice_channel": active_vc,
"text_channels": text_channels,
"active_text_channel_id": active_tc_id,
"guilds": all_guilds,
"history": [
{
"id": item["id"],
"title": item["title"],
"url": item["url"],
"duration": item.get("duration"),
"is_local": item.get("is_local", False),
"filepath": item.get("filepath")
} for item in music_cog.load_history(guild_id)[:20]
]
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
# Immediately send status upon connection
status = await get_status()
await websocket.send_json(status)
while True:
# Maintain connection, listen for disconnect
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception as e:
print(f"WS connection error: {e}")
manager.disconnect(websocket)
async def broadcast_status():
if not manager.active_connections:
return
try:
status = await get_status()
await manager.broadcast(status)
except Exception as e:
print(f"Error in broadcast_status: {e}")
@app.post("/api/play")
async def api_play(req: PlayRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if not voice_client:
raise HTTPException(status_code=400, detail="Bot is not connected to a voice channel on this server")
try:
filepath = os.path.join("uploads", os.path.basename(req.query))
if os.path.exists(filepath) and not req.query.startswith("http"):
player = await YTDLSource.from_file(filepath, loop=bot.loop)
else:
player = await YTDLSource.from_url(req.query, loop=bot.loop, stream=True)
res = await music_cog.enqueue_track(guild_id, player, from_web=True)
await broadcast_status()
return res
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/pause")
async def api_pause():
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild.id)
if voice_client and voice_client.is_playing():
voice_client.pause()
await broadcast_status()
return {"status": "paused"}
raise HTTPException(status_code=400, detail="Bot is not playing on this server")
@app.post("/api/resume")
async def api_resume():
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild.id)
if voice_client and voice_client.is_paused():
voice_client.resume()
await broadcast_status()
return {"status": "resumed"}
raise HTTPException(status_code=400, detail="Bot is not paused on this server")
@app.post("/api/skip")
async def api_skip():
music_cog = bot.get_cog("MusicBot")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
if music_cog and music_cog.get_loop_current(guild.id):
music_cog.set_loop_current(guild.id, False)
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild.id)
if voice_client and (voice_client.is_playing() or voice_client.is_paused()):
voice_client.stop()
await broadcast_status()
return {"status": "skipped"}
raise HTTPException(status_code=400, detail="Bot is not playing anything on this server")
@app.post("/api/jump")
async def api_jump(req: JumpRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
queue = music_cog.get_queue(guild_id)
if req.index < 0 or req.index >= len(queue):
raise HTTPException(status_code=400, detail="Invalid track index")
if music_cog.get_loop_current(guild_id):
music_cog.set_loop_current(guild_id, False)
music_cog.set_queue_index(guild_id, req.index)
music_cog.jump_flag[guild_id] = True
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if voice_client and (voice_client.is_playing() or voice_client.is_paused()):
voice_client.stop()
else:
await music_cog.play_next(guild_id, advance=False)
await broadcast_status()
return {"status": "jumped"}
@app.post("/api/stop")
async def api_stop():
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
return {"status": "stopped"}
guild_id = guild.id
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if voice_client and (voice_client.is_playing() or voice_client.is_paused()):
voice_client.stop()
music_cog.clear_queue(guild_id)
await broadcast_status()
return {"status": "stopped"}
@app.post("/api/volume")
async def api_volume(req: VolumeRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
if 0 <= req.volume <= 100:
music_cog.set_volume(guild_id, req.volume / 100)
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if voice_client and voice_client.source:
voice_client.source.volume = music_cog.get_volume(guild_id)
await broadcast_status()
return {"status": "success", "volume": req.volume}
raise HTTPException(status_code=400, detail="Volume must be between 0 and 100")
@app.post("/api/loop")
async def api_loop(req: LoopRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
music_cog.set_loop_current(guild.id, req.loop)
if req.loop:
music_cog.set_loop_playlist(guild.id, False)
await broadcast_status()
return {"status": "success", "loop": req.loop}
@app.post("/api/loop_playlist")
async def api_loop_playlist():
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
current_state = music_cog.get_loop_playlist(guild.id)
new_state = not current_state
music_cog.set_loop_playlist(guild.id, new_state)
if new_state:
music_cog.set_loop_current(guild.id, False)
await broadcast_status()
return {"status": "success", "loop_playlist": new_state}
@app.post("/api/loop_cycle")
async def api_loop_cycle():
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
loop_current = music_cog.get_loop_current(guild.id)
loop_pl = music_cog.get_loop_playlist(guild.id)
if not loop_current and not loop_pl:
music_cog.set_loop_playlist(guild.id, True)
music_cog.set_loop_current(guild.id, False)
mode = "playlist"
elif loop_pl and not loop_current:
music_cog.set_loop_playlist(guild.id, False)
music_cog.set_loop_current(guild.id, True)
mode = "single"
else:
music_cog.set_loop_playlist(guild.id, False)
music_cog.set_loop_current(guild.id, False)
mode = "off"
await broadcast_status()
return {"status": "success", "mode": mode}
@app.post("/api/remove")
async def api_remove(req: RemoveRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
queue = music_cog.get_queue(guild_id)
current_idx = music_cog.get_queue_index(guild_id)
removed_idx = -1
for idx, item in enumerate(queue):
if item.id == req.id:
removed_idx = idx
break
if removed_idx == -1:
raise HTTPException(status_code=404, detail="Track not found in queue")
if removed_idx == current_idx:
new_queue = [item for idx, item in enumerate(queue) if idx != removed_idx]
music_cog.queues[guild_id] = new_queue
music_cog.set_queue_index(guild_id, current_idx)
music_cog.jump_flag[guild_id] = True
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if voice_client and (voice_client.is_playing() or voice_client.is_paused()):
voice_client.stop()
else:
await music_cog.play_next(guild_id, advance=False)
else:
new_queue = [item for idx, item in enumerate(queue) if idx != removed_idx]
music_cog.queues[guild_id] = new_queue
if removed_idx < current_idx:
music_cog.set_queue_index(guild_id, current_idx - 1)
await broadcast_status()
return {"status": "removed"}
@app.post("/api/reorder")
async def api_reorder(req: ReorderRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
queue = music_cog.get_queue(guild_id)
current_idx = music_cog.get_queue_index(guild_id)
curr_item = queue[current_idx] if 0 <= current_idx < len(queue) else None
queue_map = {item.id: item for item in queue}
new_queue = []
for track_id in req.queue_ids:
if track_id in queue_map:
new_queue.append(queue_map[track_id])
# Retain any items not present in the reordered IDs list
for item in queue:
if item.id not in req.queue_ids:
new_queue.append(item)
music_cog.queues[guild_id] = new_queue
if curr_item:
try:
music_cog.set_queue_index(guild_id, new_queue.index(curr_item))
except ValueError:
pass
await broadcast_status()
return {"status": "success"}
@app.get("/api/uploads")
async def get_uploads():
uploads_dir = "uploads"
os.makedirs(uploads_dir, exist_ok=True)
files = []
for f in os.listdir(uploads_dir):
filepath = os.path.join(uploads_dir, f)
if os.path.isfile(filepath) and not f.startswith("."):
size_mb = round(os.path.getsize(filepath) / (1024 * 1024), 2)
mtime = os.path.getmtime(filepath)
files.append({
"filename": f,
"size_mb": size_mb,
"mtime": mtime
})
files.sort(key=lambda x: x["mtime"], reverse=True)
return {"files": files}
@app.post("/api/upload")
async def api_upload_file(file: UploadFile = File(...)):
uploads_dir = "uploads"
os.makedirs(uploads_dir, exist_ok=True)
filename = os.path.basename(file.filename)
filepath = os.path.join(uploads_dir, filename)
try:
with open(filepath, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {"status": "success", "filename": filename}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Błąd zapisu pliku: {e}")
@app.delete("/api/uploads/{filename}")
async def api_delete_file(filename: str):
uploads_dir = "uploads"
filepath = os.path.join(uploads_dir, os.path.basename(filename))
if os.path.exists(filepath):
try:
os.remove(filepath)
return {"status": "deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=404, detail="Plik nie istnieje")
@app.post("/api/play_file")
async def api_play_file(req: PlayFileRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if not voice_client:
raise HTTPException(status_code=400, detail="Bot is not connected to a voice channel on this server")
filepath = os.path.join("uploads", os.path.basename(req.filename))
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Plik nie istnieje na serwerze")
try:
os.utime(filepath, None)
player = await YTDLSource.from_file(filepath, loop=bot.loop)
res = await music_cog.enqueue_track(guild_id, player, from_web=True)
await broadcast_status()
return res
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/connect")
async def api_connect(req: ConnectRequest):
try:
channel_id = int(req.channel_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid channel ID")
channel = bot.get_channel(channel_id)
if not channel:
raise HTTPException(status_code=404, detail="Voice channel not found")
guild_id = channel.guild.id
# Only disconnect voice clients from the SAME guild!
for vc in bot.voice_clients:
if vc.guild.id == guild_id:
await vc.disconnect()
await channel.connect()
music_cog = bot.get_cog("MusicBot")
if music_cog and not music_cog.get_notification_channel(guild_id):
# Save first accessible text channel to send updates
for tc in channel.guild.text_channels:
if tc.permissions_for(channel.guild.me).send_messages:
music_cog.last_channels[guild_id] = tc
break
await broadcast_status()
return {"status": "connected", "channel": channel.name}
@app.post("/api/disconnect")
async def api_disconnect():
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
voice_client = discord.utils.get(bot.voice_clients, guild__id=guild_id)
if voice_client:
await voice_client.disconnect()
music_cog = bot.get_cog("MusicBot")
if music_cog:
music_cog.clear_queue(guild_id)
await broadcast_status()
return {"status": "disconnected"}
raise HTTPException(status_code=400, detail="Bot not connected to voice on this server")
@app.post("/api/settings/text_channel")
async def api_set_text_channel(req: TextChannelRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
if not req.channel_id or req.channel_id == "0" or req.channel_id == "":
if not req.guild_id:
raise HTTPException(status_code=400, detail="guild_id required to reset channel")
guild_id = int(req.guild_id)
if guild_id in music_cog.last_channels:
del music_cog.last_channels[guild_id]
music_cog.save_channel_id(guild_id, None)
await broadcast_status()
return {"status": "success", "channel_id": None, "guild_id": str(guild_id)}
channel = bot.get_channel(int(req.channel_id))
if not channel:
raise HTTPException(status_code=404, detail="Text channel not found")
guild_id = channel.guild.id
if req.guild_id and int(req.guild_id) != guild_id:
raise HTTPException(status_code=400, detail="Channel does not belong to the specified guild")
music_cog.last_channels[guild_id] = channel
music_cog.save_channel_id(guild_id, int(req.channel_id))
# Send test message to confirm
try:
await channel.send(f"🔔 Ten kanał został ustawiony jako główny kanał powiadomień dla ŻyrarBOT na serwerze **{channel.guild.name}** z panelu webowego.")
except Exception as e:
print(f"Failed to send test message: {e}")
await broadcast_status()
return {"status": "success", "channel_id": req.channel_id, "guild_id": str(guild_id)}
@app.post("/api/settings/default_plays")
async def api_set_default_plays(req: DefaultPlaysRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
if req.default_plays < 1 or req.default_plays > 999:
raise HTTPException(status_code=400, detail="Invalid default plays value")
music_cog.set_default_plays(req.default_plays)
await broadcast_status()
return {"status": "success", "default_plays": req.default_plays}
@app.post("/api/queue/plays")
async def api_set_track_plays(req: TrackPlaysRequest):
music_cog = bot.get_cog("MusicBot")
if not music_cog:
raise HTTPException(status_code=500, detail="MusicBot cog not loaded")
guild = get_active_guild()
if not guild:
raise HTTPException(status_code=400, detail="Bot has no active server context")
guild_id = guild.id
queue = music_cog.get_queue(guild_id)
if req.plays < 1 or req.plays > 999:
raise HTTPException(status_code=400, detail="Invalid plays value")
found = False
for item in queue:
if item.id == req.id:
item.plays_left = req.plays
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Track not found in queue")
await broadcast_status()
return {"status": "success", "id": req.id, "plays": req.plays}
# WebSocket Background Loop and Startup Hook
async def status_broadcast_loop():
while True:
try:
await asyncio.sleep(1)
if manager.active_connections:
await broadcast_status()
except asyncio.CancelledError:
break
except Exception as e:
print(f"Error in status_broadcast_loop: {e}")
async def wait_and_register_callback():
for _ in range(30): # Try for 30 seconds
await asyncio.sleep(1)
music_cog = bot.get_cog("MusicBot")
if music_cog:
music_cog.on_state_change_callback = broadcast_status
print("Registered WebSocket state change callback on MusicBot cog (delayed)")
break
@app.on_event("startup")
async def startup_event():
music_cog = bot.get_cog("MusicBot")
if music_cog:
music_cog.on_state_change_callback = broadcast_status
print("Registered WebSocket state change callback on MusicBot cog")
else:
asyncio.create_task(wait_and_register_callback())
asyncio.create_task(status_broadcast_loop())