diff --git a/bot.py b/bot.py index e672f76..37f16a2 100644 --- a/bot.py +++ b/bot.py @@ -135,7 +135,70 @@ class MusicBot(commands.Cog): self.last_channels[guild_id] = interaction.channel try: - player = await YTDLSource.from_url(query, loop=self.bot.loop, stream=True) + filepath = os.path.join("uploads", os.path.basename(query)) + if os.path.exists(filepath) and not query.startswith("http"): + player = await YTDLSource.from_file(filepath, loop=self.bot.loop) + else: + player = await YTDLSource.from_url(query, loop=self.bot.loop, stream=True) + + if interaction.guild.voice_client.is_playing(): + self.get_queue(guild_id).append(player) + await self.trigger_state_change() + await interaction.followup.send( + f"Dodano do kolejki: {discord.utils.escape_markdown(player.title)}", + ephemeral=False + ) + else: + self.set_current(guild_id, player) + await self.trigger_state_change() + interaction.guild.voice_client.play( + player, + after=lambda e: asyncio.run_coroutine_threadsafe( + self.play_next(guild_id), + self.bot.loop + ) + ) + await interaction.followup.send( + f"Teraz odtwarzane: {discord.utils.escape_markdown(player.title)}", + ephemeral=False + ) + except Exception as e: + await interaction.followup.send( + f"Wystąpił błąd: {e}", + ephemeral=True + ) + + async def uploads_autocomplete( + self, + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + uploads_dir = "uploads" + if not os.path.exists(uploads_dir): + return [] + files = [f for f in os.listdir(uploads_dir) if os.path.isfile(os.path.join(uploads_dir, f)) and not f.startswith(".")] + filtered = [f for f in files if current.lower() in f.lower()][:25] + return [app_commands.Choice(name=f, value=f) for f in filtered] + + @app_commands.command(name="playfile", description="Odtwórz plik przesłany na serwer") + @app_commands.describe(filename="Wybierz plik z listy przesłanych") + @app_commands.autocomplete(filename=uploads_autocomplete) + async def playfile(self, interaction: discord.Interaction, filename: str): + """Odtwórz plik przesłany na serwer""" + if not await self.ensure_voice(interaction): + return + + await interaction.response.defer() + guild_id = interaction.guild.id + self.last_channels[guild_id] = interaction.channel + + filepath = os.path.join("uploads", os.path.basename(filename)) + if not os.path.exists(filepath): + await interaction.followup.send("Nie znaleziono pliku na serwerze.", ephemeral=True) + return + + try: + player = await YTDLSource.from_file(filepath, loop=self.bot.loop) if interaction.guild.voice_client.is_playing(): self.get_queue(guild_id).append(player) @@ -175,8 +238,11 @@ class MusicBot(commands.Cog): if loop_current and current: try: - url = current.data.get('webpage_url') or current.url - player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) + if current.data.get('is_local'): + player = await YTDLSource.from_file(current.data['filepath'], loop=self.bot.loop, title=current.title) + else: + url = current.data.get('webpage_url') or current.url + player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) self.set_current(guild_id, player) await self.trigger_state_change() voice_client.play( diff --git a/compose.yaml b/compose.yaml index cb9419d..bec4dd0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,4 +10,5 @@ services: - DISCORD_TOKEN=${DISCORD_TOKEN} volumes: - ./config:/app/config + - ./uploads:/app/uploads restart: unless-stopped diff --git a/static/app.js b/static/app.js index 68c50a8..eb0a99c 100644 --- a/static/app.js +++ b/static/app.js @@ -42,6 +42,14 @@ const queueList = document.getElementById('queue-list'); const emptyQueueMsg = document.getElementById('empty-queue-msg'); const queueCountBadge = document.getElementById('queue-count'); const toastContainer = document.getElementById('toast-container'); +const fileInput = document.getElementById('file-input'); +const btnSelectFile = document.getElementById('btn-select-file'); +const uploadProgressContainer = document.getElementById('upload-progress-container'); +const uploadProgressFill = document.getElementById('upload-progress-fill'); +const uploadStatusText = document.getElementById('upload-status-text'); +const uploadsList = document.getElementById('uploads-list'); +const emptyUploadsMsg = document.getElementById('empty-uploads-msg'); +const uploadsCountBadge = document.getElementById('uploads-count'); // Progress Bar DOM Elements const progressContainer = document.getElementById('progress-container'); @@ -100,6 +108,7 @@ function connectWebSocket() { document.addEventListener('DOMContentLoaded', () => { // Connect to WebSocket instead of polling connectWebSocket(); + fetchUploads(); // Start Smooth Progress Tracker (every 100ms) setInterval(() => { @@ -148,6 +157,12 @@ function bindEvents() { // Text Channel Select textChannelSelect.addEventListener('change', changeTextChannel); + + // Upload Events + if (btnSelectFile && fileInput) { + btnSelectFile.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', handleFileUpload); + } } // Show Toast Notification @@ -719,3 +734,131 @@ async function changeTextChannel() { showToast('Nie udało się zapisać kanału powiadomień', 'error'); } } + +// Fetch Uploaded Files +async function fetchUploads() { + try { + const res = await fetch('/api/uploads'); + if (!res.ok) return; + const data = await res.json(); + renderUploads(data.files || []); + } catch (err) { + console.error('Błąd pobierania plików:', err); + } +} + +// Render Uploaded Files +function renderUploads(files) { + if (!uploadsList) return; + const items = uploadsList.querySelectorAll('.queue-item'); + items.forEach(el => el.remove()); + + if (!files || files.length === 0) { + emptyUploadsMsg.style.display = 'flex'; + uploadsCountBadge.textContent = '0 plików'; + return; + } + + emptyUploadsMsg.style.display = 'none'; + uploadsCountBadge.textContent = `${files.length} ${files.length === 1 ? 'plik' : files.length < 5 ? 'pliki' : 'plików'}`; + + files.forEach(file => { + const li = document.createElement('li'); + li.className = 'queue-item'; + + const safeFilename = file.filename.replace(/'/g, "\\'"); + + li.innerHTML = ` +
+
+
${file.filename}
+
${file.size_mb} MB
+
+
+ + +
+ `; + uploadsList.appendChild(li); + }); + lucide.createIcons(); +} + +// Handle File Upload +async function handleFileUpload(e) { + const files = e.target.files; + if (!files || files.length === 0) return; + + uploadProgressContainer.style.display = 'block'; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const formData = new FormData(); + formData.append('file', file); + + uploadStatusText.textContent = `Przesyłanie: ${file.name}...`; + uploadProgressFill.style.width = '50%'; + + try { + const res = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.detail || 'Błąd przesyłania'); + } + showToast(`Przesłano plik: ${file.name}`, 'success'); + } catch (err) { + showToast(`Błąd przesyłania ${file.name}: ${err.message}`, 'error'); + } + } + + uploadProgressFill.style.width = '100%'; + setTimeout(() => { + uploadProgressContainer.style.display = 'none'; + uploadProgressFill.style.width = '0%'; + fileInput.value = ''; + fetchUploads(); + }, 500); +} + +// Play Uploaded File +async function playUploadedFile(filename) { + if (!currentStatus || !currentStatus.active_voice_channel) { + showToast('Bot musi być połączony z kanałem głosowym!', 'error'); + return; + } + + try { + const res = await fetch('/api/play_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: filename }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Błąd odtwarzania pliku'); + showToast(`Dodano plik do kolejki!`, 'success'); + } catch (err) { + showToast(`Błąd odtwarzania: ${err.message}`, 'error'); + } +} + +// Delete Uploaded File +async function deleteUploadedFile(filename) { + try { + const res = await fetch(`/api/uploads/${encodeURIComponent(filename)}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error('Błąd usuwania'); + showToast('Usunięto plik z serwera', 'info'); + fetchUploads(); + } catch (err) { + showToast('Nie udało się usunąć pliku', 'error'); + } +} + diff --git a/static/index.html b/static/index.html index 9ee970f..9b6a410 100644 --- a/static/index.html +++ b/static/index.html @@ -148,6 +148,36 @@ + +
+
+

Serwerowa baza plików

+ 0 plików +
+ +
+ + + +
+ +
+ +
+
+
diff --git a/web.py b/web.py index f482259..04591b4 100644 --- a/web.py +++ b/web.py @@ -1,9 +1,11 @@ -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File from fastapi.staticfiles import StaticFiles 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 @@ -34,6 +36,9 @@ class ConnectRequest(BaseModel): class TextChannelRequest(BaseModel): channel_id: str +class PlayFileRequest(BaseModel): + filename: str + # Connection Manager for WebSockets class ConnectionManager: def __init__(self): @@ -199,7 +204,11 @@ async def api_play(req: PlayRequest): raise HTTPException(status_code=400, detail="Bot is not connected to a voice channel on this server") try: - player = await YTDLSource.from_url(req.query, loop=bot.loop, stream=True) + 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) url = player.data.get('webpage_url') or player.url title_clean = discord.utils.escape_markdown(player.title or "Nieznany tytuł") @@ -384,6 +393,109 @@ async def api_reorder(req: ReorderRequest): 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 sorted(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) + files.append({ + "filename": f, + "size_mb": size_mb + }) + 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: + player = await YTDLSource.from_file(filepath, loop=bot.loop) + title_clean = discord.utils.escape_markdown(player.title or "Nieznany tytuł") + + if voice_client.is_playing() or voice_client.is_paused(): + music_cog.get_queue(guild_id).append(player) + embed = discord.Embed( + title=f"📁 {title_clean}", + description="Plik lokalny z serwerowej bazy plików", + color=discord.Color.blue() + ) + embed.set_author(name="Dodano do kolejki z panelu Web", icon_url="https://cdn-icons-png.flaticon.com/512/3844/3844724.png") + if player.duration: + mins, secs = divmod(int(player.duration), 60) + embed.add_field(name="Czas trwania", value=f"{mins}:{secs:02d}", inline=True) + await music_cog.send_to_last_channel(guild_id, embed=embed) + await broadcast_status() + return {"status": "queued", "title": player.title} + else: + music_cog.set_current(guild_id, player) + voice_client.play( + player, + after=lambda e: asyncio.run_coroutine_threadsafe( + music_cog.play_next(guild_id), + bot.loop + ) + ) + embed = discord.Embed( + title=f"▶️ {title_clean}", + description="Plik lokalny z serwerowej bazy plików", + color=discord.Color.green() + ) + embed.set_author(name="Teraz odtwarzane (z panelu Web)", icon_url="https://cdn-icons-png.flaticon.com/512/3844/3844724.png") + if player.duration: + mins, secs = divmod(int(player.duration), 60) + embed.add_field(name="Czas trwania", value=f"{mins}:{secs:02d}", inline=True) + await music_cog.send_to_last_channel(guild_id, embed=embed) + await broadcast_status() + return {"status": "playing", "title": player.title} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.post("/api/connect") async def api_connect(req: ConnectRequest): try: diff --git a/ytdl.py b/ytdl.py index bc3a946..b9322fb 100644 --- a/ytdl.py +++ b/ytdl.py @@ -2,6 +2,7 @@ import discord import yt_dlp as youtube_dl import asyncio import uuid +import os ytdl_format_options = { 'format': 'bestaudio/best', @@ -25,6 +26,22 @@ ffmpeg_options = { ytdl = youtube_dl.YoutubeDL(ytdl_format_options) +async def get_file_duration(filepath): + try: + proc = await asyncio.create_subprocess_exec( + 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', filepath, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await proc.communicate() + if proc.returncode == 0: + return float(stdout.decode().strip()) + except Exception as e: + print(f"Error getting duration with ffprobe: {e}") + return 0.0 + + class YTDLSource(discord.PCMVolumeTransformer): def __init__(self, source, *, data, volume=0.5): super().__init__(source, volume) @@ -51,3 +68,24 @@ class YTDLSource(discord.PCMVolumeTransformer): filename = data['url'] if stream else ytdl.prepare_filename(data) return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), data=data) + + @classmethod + async def from_file(cls, filepath, *, loop=None, title=None): + loop = loop or asyncio.get_event_loop() + duration = await get_file_duration(filepath) + filename_only = os.path.basename(filepath) + display_title = title or os.path.splitext(filename_only)[0] + + data = { + 'title': display_title, + 'url': filepath, + 'webpage_url': None, + 'duration': duration, + 'is_local': True, + 'filepath': filepath + } + + local_ffmpeg_options = { + 'options': '-vn' + } + return cls(discord.FFmpegPCMAudio(filepath, **local_ffmpeg_options), data=data)