.
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