.
This commit is contained in:
		
							
								
								
									
										322
									
								
								cogs/sing.py
									
									
									
									
									
								
							
							
						
						
									
										322
									
								
								cogs/sing.py
									
									
									
									
									
								
							| @@ -7,9 +7,137 @@ import regex | ||||
| from util.sing_util import Utility | ||||
| from discord.ext import bridge, commands | ||||
| from disc_havoc import Havoc | ||||
| import random | ||||
| import string | ||||
| import json | ||||
| import os | ||||
|  | ||||
| BOT_CHANIDS = [] | ||||
|  | ||||
| # Global storage for pagination data (survives across interactions) | ||||
| PAGINATION_DATA = {} | ||||
| PAGINATION_FILE = "pagination_data.json" | ||||
|  | ||||
| def load_pagination_data(): | ||||
|     """Load pagination data from JSON file""" | ||||
|     global PAGINATION_DATA | ||||
|     try: | ||||
|         if os.path.exists(PAGINATION_FILE): | ||||
|             with open(PAGINATION_FILE, 'r') as f: | ||||
|                 PAGINATION_DATA = json.load(f) | ||||
|     except Exception: | ||||
|         PAGINATION_DATA = {} | ||||
|  | ||||
| def save_pagination_data(): | ||||
|     """Save pagination data to JSON file (excluding non-serializable embeds)""" | ||||
|     try: | ||||
|         # Create serializable copy without embeds | ||||
|         serializable_data = {} | ||||
|         for key, value in PAGINATION_DATA.items(): | ||||
|             serializable_data[key] = { | ||||
|                 "pages": value["pages"], | ||||
|                 "song": value["song"],  | ||||
|                 "artist": value["artist"], | ||||
|                 "total_pages": value["total_pages"] | ||||
|             } | ||||
|         with open(PAGINATION_FILE, 'w') as f: | ||||
|             json.dump(serializable_data, f) | ||||
|     except Exception: | ||||
|         pass | ||||
| import logging | ||||
| from typing import Optional, Union | ||||
| from regex import Pattern | ||||
| import discord | ||||
| import regex | ||||
| from util.sing_util import Utility | ||||
| from discord.ext import bridge, commands | ||||
| from disc_havoc import Havoc | ||||
| import random | ||||
| import string | ||||
|  | ||||
| BOT_CHANIDS = [] | ||||
|  | ||||
| # Global storage for pagination data (survives across interactions) | ||||
| PAGINATION_DATA: dict[str, dict] = {} | ||||
|  | ||||
|  | ||||
| class LyricsPaginator(discord.ui.View): | ||||
|     def __init__(self, pages, song, artist): | ||||
|         super().__init__(timeout=None)  # No timeout - buttons work permanently | ||||
|         self.pages = pages | ||||
|         self.song = song | ||||
|         self.artist = artist | ||||
|         self.current_page = 0 | ||||
|          | ||||
|         # Generate a unique ID for this paginator instance   | ||||
|         self.custom_id_base = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) | ||||
|          | ||||
|         # Pre-generate all embeds to avoid creating them on each page turn | ||||
|         self.embeds = [] | ||||
|          | ||||
|         # URL encode artist and song for the link | ||||
|         import urllib.parse | ||||
|         encoded_artist = urllib.parse.quote(self.artist, safe='') | ||||
|         encoded_song = urllib.parse.quote(self.song, safe='') | ||||
|         lyrics_url = f"https://codey.lol/#{encoded_artist}/{encoded_song}" | ||||
|          | ||||
|         for i, page in enumerate(pages): | ||||
|             embed = discord.Embed(title=f"{self.song}", color=discord.Color.blue(), url=lyrics_url) | ||||
|             embed.set_author(name=self.artist) | ||||
|             embed.description = page | ||||
|              | ||||
|             # Set footer with just page info for multi-page, or no footer for single page | ||||
|             if len(pages) > 1: | ||||
|                 embed.set_footer(text=f"Page {i + 1} of {len(pages)}", icon_url="https://codey.lol/favicon.ico") | ||||
|             self.embeds.append(embed) | ||||
|  | ||||
|         # Store pagination data for persistence across bot restarts | ||||
|         if len(pages) > 1: | ||||
|             # Store data (embeds will be recreated as needed since they can't be JSON serialized) | ||||
|             serializable_data = { | ||||
|                 "pages": pages, | ||||
|                 "song": song,  | ||||
|                 "artist": artist, | ||||
|                 "total_pages": len(pages) | ||||
|             } | ||||
|             # Store in memory with embeds for performance | ||||
|             PAGINATION_DATA[self.custom_id_base] = { | ||||
|                 **serializable_data, | ||||
|                 "embeds": self.embeds | ||||
|             } | ||||
|             # Save serializable data to file | ||||
|             save_pagination_data() | ||||
|  | ||||
|     def get_view_for_page(self, page_num: int): | ||||
|         """Create a view with properly configured buttons for the given page""" | ||||
|         view = discord.ui.View(timeout=None) | ||||
|          | ||||
|         if len(self.pages) > 1: | ||||
|             # Previous button | ||||
|             prev_button = discord.ui.Button( | ||||
|                 label="Previous", | ||||
|                 style=discord.ButtonStyle.secondary, | ||||
|                 emoji="⬅️", | ||||
|                 disabled=(page_num == 0), | ||||
|                 custom_id=f"lyrics_prev:{self.custom_id_base}", | ||||
|                 row=0 | ||||
|             ) | ||||
|              | ||||
|             # Next button   | ||||
|             next_button = discord.ui.Button( | ||||
|                 label="Next", | ||||
|                 style=discord.ButtonStyle.primary, | ||||
|                 emoji="➡️",  | ||||
|                 disabled=(page_num == len(self.pages) - 1), | ||||
|                 custom_id=f"lyrics_next:{self.custom_id_base}", | ||||
|                 row=0 | ||||
|             ) | ||||
|              | ||||
|             view.add_item(prev_button) | ||||
|             view.add_item(next_button) | ||||
|              | ||||
|         return view | ||||
|  | ||||
|  | ||||
| class Sing(commands.Cog): | ||||
|     """Sing Cog for Havoc""" | ||||
| @@ -23,6 +151,84 @@ class Sing(commands.Cog): | ||||
|             r"\x0f|\x1f|\035|\002|\u2064|\x02|(\x03([0-9]{1,2}))|(\x03|\003)(?:\d{1,2}(?:,\d{1,2})?)?", | ||||
|             regex.UNICODE, | ||||
|         ) | ||||
|          | ||||
|         # Persistent interactions handled by on_interaction listener | ||||
|  | ||||
|     @commands.Cog.listener() | ||||
|     async def on_interaction(self, interaction: discord.Interaction): | ||||
|         """Handle persistent lyrics pagination interactions""" | ||||
|         if interaction.type != discord.InteractionType.component: | ||||
|             return | ||||
|              | ||||
|         custom_id = interaction.data.get("custom_id", "") | ||||
|         if not (custom_id.startswith("lyrics_prev:") or custom_id.startswith("lyrics_next:")): | ||||
|             return | ||||
|              | ||||
|         try: | ||||
|             # Extract the base custom ID and direction | ||||
|             if custom_id.startswith("lyrics_prev:"): | ||||
|                 direction = -1 | ||||
|                 base_id = custom_id.replace("lyrics_prev:", "") | ||||
|             else:  # lyrics_next: | ||||
|                 direction = 1 | ||||
|                 base_id = custom_id.replace("lyrics_next:", "") | ||||
|              | ||||
|             # Get pagination data from JSON persistence  | ||||
|             data = PAGINATION_DATA.get(base_id) | ||||
|             if not data: | ||||
|                 await interaction.response.send_message( | ||||
|                     "⚠️ **Navigation Expired**\n" | ||||
|                     "These buttons are from a previous session. " | ||||
|                     "Use `/s <song>` or `/sing <artist> : <song>` to get fresh lyrics!",  | ||||
|                     ephemeral=True, | ||||
|                 ) | ||||
|                 return | ||||
|                  | ||||
|             # Recreate embeds if they don't exist (loaded from JSON) | ||||
|             if "embeds" not in data or not data["embeds"]: | ||||
|                 paginator = LyricsPaginator(data["pages"], data["song"], data["artist"]) | ||||
|                 paginator.custom_id_base = base_id | ||||
|                 data["embeds"] = paginator.embeds | ||||
|                 # Update in-memory data | ||||
|                 PAGINATION_DATA[base_id] = data | ||||
|              | ||||
|             # Get current page from embed footer | ||||
|             if not interaction.message or not interaction.message.embeds: | ||||
|                 await interaction.response.defer() | ||||
|                 return | ||||
|             current_embed = interaction.message.embeds[0] | ||||
|             footer_text = current_embed.footer.text if current_embed.footer else "" | ||||
|              | ||||
|             # Extract current page number (format: "Page X of Y") | ||||
|             current_page = 0 | ||||
|             if "Page " in footer_text and " of " in footer_text: | ||||
|                 try: | ||||
|                     page_part = footer_text.split("Page ")[1].split(" of ")[0] | ||||
|                     current_page = int(page_part) - 1 | ||||
|                 except: | ||||
|                     pass | ||||
|              | ||||
|             # Calculate new page | ||||
|             new_page = current_page + direction | ||||
|             total_pages = len(data["pages"]) | ||||
|              | ||||
|             if new_page < 0 or new_page >= total_pages: | ||||
|                 await interaction.response.defer()  # Just acknowledge, don't change anything | ||||
|                 return | ||||
|              | ||||
|             # Create new paginator and get view for new page | ||||
|             paginator = LyricsPaginator(data["pages"], data["song"], data["artist"]) | ||||
|             paginator.custom_id_base = base_id  # Preserve same custom ID | ||||
|             view = paginator.get_view_for_page(new_page) | ||||
|              | ||||
|             # Update the message | ||||
|             await interaction.response.edit_message(embed=data["embeds"][new_page], view=view) | ||||
|              | ||||
|         except Exception as e: | ||||
|             try: | ||||
|                 await interaction.response.send_message(f"Error handling pagination: {str(e)}", ephemeral=True) | ||||
|             except: | ||||
|                 pass | ||||
|  | ||||
|     def is_spamchan():  # type: ignore | ||||
|         """Check if channel is spam chan""" | ||||
| @@ -60,18 +266,18 @@ class Sing(commands.Cog): | ||||
|                             "**Error**: No song specified, no activity found to read." | ||||
|                         ) | ||||
|  | ||||
|                 await ctx.respond( | ||||
|                     "*Searching...*" | ||||
|                 )  # Must respond to interactions within 3 seconds, per Discord | ||||
|                 # Initial response that we'll edit later | ||||
|                 message = await ctx.respond("*Searching for lyrics...*") | ||||
|  | ||||
|                 parsed = self.utility.parse_song_input(song, activity) | ||||
|  | ||||
|                 if isinstance(parsed, tuple): | ||||
|                     (search_artist, search_song, search_subsearch) = parsed | ||||
|                     # Update the message to show what we're searching for | ||||
|                     await message.edit(content=f"*Searching for '{search_song}' by {search_artist}...*") | ||||
|  | ||||
|                 # await ctx.respond(f"So, {search_song} by {search_artist}?  Subsearch: {search_subsearch} I will try...") # Commented, useful for debugging | ||||
|                 search_result: Optional[list] = await self.utility.lyric_search( | ||||
|                     search_artist, search_song, search_subsearch | ||||
|                     search_artist, search_song, search_subsearch, is_spam_channel=(ctx.channel.id in BOT_CHANIDS) | ||||
|                 ) | ||||
|  | ||||
|                 if not search_result: | ||||
| @@ -97,29 +303,26 @@ class Sing(commands.Cog): | ||||
|                 search_result_wrapped_short: list[str] = search_result[ | ||||
|                     2 | ||||
|                 ]  # Third is short wrapped lyrics | ||||
|                 if ctx.channel.id not in BOT_CHANIDS: | ||||
|                     short_lyrics = " ".join( | ||||
|                         search_result_wrapped_short | ||||
|                     )  # Replace with shortened lyrics for non spamchans | ||||
|                     short_lyrics = regex.sub( | ||||
|                         r"\p{Vert_Space}", " / ", short_lyrics.strip() | ||||
|                     ) | ||||
|                     return await ctx.respond( | ||||
|                         f"**{search_result_song}** by **{search_result_artist}**\n-# {short_lyrics}" | ||||
|                     ) | ||||
|                 # Now all channels get full pagination - removed the early return restriction | ||||
|  | ||||
|                 out_messages: list = [] | ||||
|                 footer: str = ""  # Placeholder | ||||
|                 for c, section in enumerate(search_result_wrapped): | ||||
|                     if c == len(search_result_wrapped): | ||||
|                         footer = f"`Found on: {search_result_src}`" | ||||
|                     section = regex.sub(r"\p{Vert_Space}", " / ", section.strip()) | ||||
|                     msg: str = f"**{search_result_song}** by **{search_result_artist}**\n-# {section}\n{footer}" | ||||
|                     if c > 1: | ||||
|                         msg = "\n".join(msg.split("\n")[1:]) | ||||
|                     out_messages.append(msg.strip()) | ||||
|                 for msg in out_messages: | ||||
|                     await ctx.send(msg) | ||||
|                 # Use the appropriate wrapped lyrics based on channel type | ||||
|                 is_spam_channel = ctx.channel.id in BOT_CHANIDS | ||||
|                 if is_spam_channel: | ||||
|                     # Spam channels get full-length lyrics | ||||
|                     pages = search_result_wrapped.copy() | ||||
|                 else: | ||||
|                     # Non-spam channels get shorter lyrics for better UX | ||||
|                     pages = search_result_wrapped.copy()  # For now, still use full but we'll modify limits later | ||||
|  | ||||
|                 # Add source info to last page | ||||
|                 if pages: | ||||
|                     pages[-1] += f"\n\n`Found on: {search_result_src}`" | ||||
|  | ||||
|                 # Create and send view with paginator | ||||
|                 paginator = LyricsPaginator(pages, search_result_song, search_result_artist) | ||||
|                 view = paginator.get_view_for_page(0) | ||||
|                 # Edit the existing message to show the lyrics | ||||
|                 await message.edit(content=None, embed=paginator.embeds[0], view=view) | ||||
|         except Exception as e: | ||||
|             traceback.print_exc() | ||||
|             await ctx.respond(f"ERR: {str(e)}") | ||||
| @@ -149,26 +352,29 @@ class Sing(commands.Cog): | ||||
|                 ) | ||||
|             member_id: int = member.id  # if not(member.id == PODY_ID) else 1234134345497837679 # Use Thomas for Pody! | ||||
|             activity: Optional[discord.Activity] = None | ||||
|             if IS_SPAMCHAN: | ||||
|                 await ctx.respond(f"***Reading activity of {member_display}...***") | ||||
|             # Initial response that we'll edit later | ||||
|             message = await ctx.respond(f"*Reading activity of {member_display}...*" if IS_SPAMCHAN else "*Processing...*") | ||||
|  | ||||
|             for _activity in ctx.interaction.guild.get_member(member_id).activities: | ||||
|                 if _activity.type == discord.ActivityType.listening: | ||||
|                     activity = _activity | ||||
|  | ||||
|             parsed: Union[tuple, bool] = self.utility.parse_song_input( | ||||
|                 song=None, activity=activity | ||||
|             ) | ||||
|             if not parsed: | ||||
|                 return await ctx.respond( | ||||
|                     f"Could not parse activity of {member_display}.", ephemeral=True | ||||
|                 ) | ||||
|                 if IS_SPAMCHAN: | ||||
|                     return await message.edit(content=f"Could not parse activity of {member_display}.") | ||||
|                 else: | ||||
|                     return await ctx.respond( | ||||
|                         f"Could not parse activity of {member_display}.", ephemeral=True | ||||
|                     ) | ||||
|  | ||||
|             if isinstance(parsed, tuple): | ||||
|                 (search_artist, search_song, search_subsearch) = parsed | ||||
|                 await ctx.respond( | ||||
|                     "*Searching...*" | ||||
|                 )  # Must respond to interactions within 3 seconds, per Discord | ||||
|                 await message.edit(content=f"*Searching for '{search_song}' by {search_artist}...*") | ||||
|                 search_result: Optional[list] = await self.utility.lyric_search( | ||||
|                     search_artist, search_song, search_subsearch | ||||
|                     search_artist, search_song, search_subsearch, is_spam_channel=(ctx.channel.id in BOT_CHANIDS) | ||||
|                 ) | ||||
|                 if not search_result: | ||||
|                     await ctx.respond("ERR: No search result") | ||||
| @@ -193,33 +399,24 @@ class Sing(commands.Cog): | ||||
|                     2 | ||||
|                 ]  # Third index is shortened lyrics | ||||
|  | ||||
|                 if not IS_SPAMCHAN: | ||||
|                     short_lyrics = " ".join( | ||||
|                         search_result_wrapped_short | ||||
|                     )  # Replace with shortened lyrics for non spamchans | ||||
|                     short_lyrics = regex.sub( | ||||
|                         r"\p{Vert_Space}", " / ", short_lyrics.strip() | ||||
|                     ) | ||||
|                     return await ctx.respond( | ||||
|                         f"**{search_result_song}** by **{search_result_artist}**\n-# {short_lyrics}" | ||||
|                     ) | ||||
|                 # All channels now get full paginated response | ||||
|  | ||||
|                 out_messages: list = [] | ||||
|                 footer: str = "" | ||||
|                 c: int = 0 | ||||
|                 for section in search_result_wrapped: | ||||
|                     c += 1 | ||||
|                     if c == len(search_result_wrapped): | ||||
|                         footer = f"`Found on: {search_result_src}`" | ||||
|                     # if ctx.guild.id == 1145182936002482196: | ||||
|                     #     section = section.upper() | ||||
|                     section = regex.sub(r"\p{Vert_Space}", " / ", section.strip()) | ||||
|                     msg: str = f"**{search_result_song}** by **{search_result_artist}**\n-# {section}\n{footer}" | ||||
|                     if c > 1: | ||||
|                         msg = "\n".join(msg.split("\n")[1:]) | ||||
|                     out_messages.append(msg.strip()) | ||||
|                 for msg in out_messages: | ||||
|                     await ctx.send(msg) | ||||
|                 # Create paginator for lyrics | ||||
|                 if not search_result_wrapped: | ||||
|                     return await ctx.respond("No lyrics found.") | ||||
|  | ||||
|                 # Use the already smartly-wrapped pages from sing_util | ||||
|                 pages = search_result_wrapped.copy()  # Already processed by _smart_lyrics_wrap | ||||
|  | ||||
|                 # Add source info to last page | ||||
|                 if pages: | ||||
|                     pages[-1] += f"\n\n`Found on: {search_result_src}`" | ||||
|  | ||||
|                 # Create and send view with paginator | ||||
|                 paginator = LyricsPaginator(pages, search_result_song, search_result_artist) | ||||
|                 view = paginator.get_view_for_page(0) | ||||
|                 # Edit the existing message to show the lyrics | ||||
|                 await message.edit(content=None, embed=paginator.embeds[0], view=view) | ||||
|         except Exception as e: | ||||
|             traceback.print_exc() | ||||
|             return await ctx.respond(f"ERR: {str(e)}") | ||||
| @@ -227,4 +424,5 @@ class Sing(commands.Cog): | ||||
|  | ||||
| def setup(bot) -> None: | ||||
|     """Run on Cog Load""" | ||||
|     load_pagination_data()  # Load existing pagination data from file | ||||
|     bot.add_cog(Sing(bot)) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user