diff --git a/bot.py b/bot.py index 37f16a2..8bce716 100644 --- a/bot.py +++ b/bot.py @@ -20,8 +20,57 @@ class MusicBot(commands.Cog): self.volumes = {} # guild_id -> volume float self.loop_currents = {} # guild_id -> bool self.last_channels = {} # guild_id -> text channel + self.history = {} # guild_id -> list of dicts self.on_state_change_callback = None + def load_history(self, guild_id: int): + if guild_id in self.history: + return self.history[guild_id] + config_path = "config/history.json" + if os.path.exists(config_path): + try: + import json + with open(config_path, "r") as f: + data = json.load(f) + self.history[guild_id] = data.get(str(guild_id), []) + except Exception as e: + print(f"Error loading history: {e}") + self.history[guild_id] = [] + else: + self.history[guild_id] = [] + return self.history[guild_id] + + def save_history(self, guild_id: int): + config_path = "config/history.json" + try: + import json + os.makedirs(os.path.dirname(config_path), exist_ok=True) + data = {} + if os.path.exists(config_path): + with open(config_path, "r") as f: + data = json.load(f) + data[str(guild_id)] = self.history.get(guild_id, []) + with open(config_path, "w") as f: + json.dump(data, f, indent=4) + except Exception as e: + print(f"Error saving history: {e}") + + def add_to_history(self, guild_id: int, player): + if not player or not player.title: + return + history = self.load_history(guild_id) + entry = { + "id": player.id, + "title": player.title, + "url": player.data.get("webpage_url") or player.url, + "duration": player.duration, + "is_local": player.data.get("is_local", False), + "filepath": player.data.get("filepath") + } + history.insert(0, entry) + self.history[guild_id] = history[:50] + self.save_history(guild_id) + def get_queue(self, guild_id: int): if guild_id not in self.queues: self.queues[guild_id] = [] @@ -150,6 +199,7 @@ class MusicBot(commands.Cog): ) else: self.set_current(guild_id, player) + self.add_to_history(guild_id, player) await self.trigger_state_change() interaction.guild.voice_client.play( player, @@ -209,6 +259,7 @@ class MusicBot(commands.Cog): ) else: self.set_current(guild_id, player) + self.add_to_history(guild_id, player) await self.trigger_state_change() interaction.guild.voice_client.play( player, @@ -261,6 +312,7 @@ class MusicBot(commands.Cog): if len(queue) > 0: next_player = queue.pop(0) self.set_current(guild_id, next_player) + self.add_to_history(guild_id, next_player) await self.trigger_state_change() voice_client.play( next_player, @@ -301,6 +353,94 @@ class MusicBot(commands.Cog): ephemeral=False ) + async def history_autocomplete( + self, + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + guild_id = interaction.guild.id + history = self.load_history(guild_id) + filtered = [h for h in history if current.lower() in h["title"].lower()][:25] + return [app_commands.Choice(name=h["title"][:100], value=str(h["id"])) for h in filtered] + + @app_commands.command(name="history", description="Pokaż historię ostatnio odtwarzanych utworów") + async def show_history(self, interaction: discord.Interaction): + """Pokaż historię odtwarzania""" + guild_id = interaction.guild.id + history = self.load_history(guild_id) + if not history: + await interaction.response.send_message( + "Historia odtwarzania jest pusta.", + ephemeral=True + ) + return + + history_list = "\n".join([f"{i + 1}. {discord.utils.escape_markdown(item['title'])}" for i, item in enumerate(history[:15])]) + await interaction.response.send_message( + f"📜 Historia odtwarzania (ostatnie 15):\n{history_list}", + ephemeral=False + ) + + @app_commands.command(name="replay", description="Odtwórz ponownie utwór z historii") + @app_commands.describe(track_id="Wybierz utwór z historii odtwarzania") + @app_commands.autocomplete(track_id=history_autocomplete) + async def replay(self, interaction: discord.Interaction, track_id: str): + """Odtwórz z historii""" + if not await self.ensure_voice(interaction): + return + + await interaction.response.defer() + guild_id = interaction.guild.id + self.last_channels[guild_id] = interaction.channel + + history = self.load_history(guild_id) + entry = next((item for item in history if str(item["id"]) == track_id), None) + + # If not found by ID, maybe user typed an index number 1..15 + if not entry and track_id.isdigit(): + idx = int(track_id) - 1 + if 0 <= idx < len(history): + entry = history[idx] + + if not entry: + await interaction.followup.send("Nie znaleziono utworu w historii.", ephemeral=True) + return + + try: + if entry.get("is_local"): + filepath = entry.get("filepath") or entry.get("url") + if not os.path.exists(filepath): + await interaction.followup.send("Plik lokalny z historii już nie istnieje na serwerze.", ephemeral=True) + return + player = await YTDLSource.from_file(filepath, loop=self.bot.loop, title=entry["title"]) + else: + player = await YTDLSource.from_url(entry["url"], 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 z historii: {discord.utils.escape_markdown(player.title)}", + ephemeral=False + ) + else: + self.set_current(guild_id, player) + self.add_to_history(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 z historii: {discord.utils.escape_markdown(player.title)}", + ephemeral=False + ) + except Exception as e: + await interaction.followup.send(f"Wystąpił błąd: {e}", ephemeral=True) + @app_commands.command(name="skip", description="Pomiń aktualny utwór") async def skip(self, interaction: discord.Interaction): """Pomiń aktualny utwór""" diff --git a/static/app.js b/static/app.js index eb0a99c..2c510fa 100644 --- a/static/app.js +++ b/static/app.js @@ -50,6 +50,9 @@ 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'); +const historyList = document.getElementById('history-list'); +const emptyHistoryMsg = document.getElementById('empty-history-msg'); +const historyCountBadge = document.getElementById('history-count'); // Progress Bar DOM Elements const progressContainer = document.getElementById('progress-container'); @@ -390,6 +393,7 @@ function updateUI(status) { // 6. Queue render (if not currently dragging) if (!isDragging) { renderQueue(status.queue); + renderHistory(status.history || []); } // Re-trigger lucide icons update @@ -862,3 +866,75 @@ async function deleteUploadedFile(filename) { } } +// Render History +function renderHistory(history) { + if (!historyList) return; + const items = historyList.querySelectorAll('.queue-item'); + items.forEach(el => el.remove()); + + if (!history || history.length === 0) { + if (emptyHistoryMsg) emptyHistoryMsg.style.display = 'flex'; + if (historyCountBadge) historyCountBadge.textContent = '0 utworów'; + return; + } + + if (emptyHistoryMsg) emptyHistoryMsg.style.display = 'none'; + if (historyCountBadge) historyCountBadge.textContent = `${history.length} ${history.length === 1 ? 'utwór' : history.length < 5 ? 'utwory' : 'utworów'}`; + + history.forEach((item, index) => { + const li = document.createElement('li'); + li.className = 'queue-item'; + + let durationStr = '--:--'; + if (item.duration) { + const mins = Math.floor(item.duration / 60); + const secs = Math.floor(item.duration % 60); + durationStr = `${mins}:${secs < 10 ? '0' : ''}${secs}`; + } + + const safeTitle = (item.title || 'Nieznany tytuł').replace(/'/g, "\\'"); + const safeQuery = (item.is_local ? (item.filepath || item.title) : item.url).replace(/'/g, "\\'"); + + li.innerHTML = ` +