Major changes to music player and activity tracker. Adding dedicated database for tracking and DJing.

This commit is contained in:
Jared Kick 2025-07-19 18:21:59 -04:00
parent 9cbcfc5b6b
commit 3a17d001e1
4 changed files with 763 additions and 264 deletions

View File

@ -4,25 +4,42 @@ import asyncio
import discord
from discord.ext import commands
from dotenv import load_dotenv
import logging
import os
import sys
import database
# Create custom logging handler
console_handler = logging.StreamHandler(sys.stdout)
console_formatter = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s")
console_handler.setFormatter(console_formatter)
# Make sure all loggers use this handler
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(console_handler)
# Get bot logger
logger = logging.getLogger("boywife-bot")
# Load credentials
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')
# client = discord.Client()
client = commands.Bot(command_prefix = '!', intents=discord.Intents.all())
client = commands.Bot(
command_prefix = '!', intents=discord.Intents.all(), log_hander=False)
# You need to import os for this method
@client.event
async def on_ready():
print(f'{client.user} is now running')
logger.info(f'{client.user} is now running')
# Load cogs
for filename in os.listdir('./cogs'):
if filename.endswith('.py'):
await client.load_extension(f'cogs.{filename[:-3]}')
print(f'Loaded {filename} cog')
logger.info(f'Loaded {filename} cog')
# player = music_player.setup(client)
client.run(TOKEN)
client.run(TOKEN, log_handler=None)

View File

@ -1,9 +1,13 @@
import datetime
import discord
from discord.ext import commands
import logging
import os
import pathlib
import sqlite3
import typing
import database
class Activities(commands.Cog):
@ -12,24 +16,8 @@ class Activities(commands.Cog):
def __init__(self, bot):
self.bot = bot
# Initialize the databse
self.db_path = pathlib.Path(os.getenv('DB_PATH', 'activities.db'))
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS user_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
before_activity_type TEXT,
before_activity_name TEXT,
after_activity_type TEXT,
after_activity_name TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
self.logger = logging.getLogger("activities")
self.db = database.Database("boywife_bot.db")
async def __local_check(self, ctx):
"""A local check which applies to all commands in this cog."""
@ -48,114 +36,39 @@ class Activities(commands.Cog):
print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr)
traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
def log_activity(self,
user_id: int,
before_activity_type: str = None,
before_activity_name: str = None,
after_activity_type: str = None,
after_activity_name: str = None):
"""Log an activity into the activities database for stats."""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
try:
cursor.execute("""
INSERT INTO user_activity (user_id, before_activity_type,
before_activity_name, after_activity_type, after_activity_name)
VALUES (?, ?, ?, ?, ?)
""", (user_id, before_activity_type, before_activity_name,
after_activity_type, after_activity_name))
conn.commit()
except sqlite3.Error as e:
print(f"Database error: {e}")
finally:
conn.close()
@commands.Cog.listener()
async def on_presence_update(self, before: discord.Member, after: discord.Member):
# Ignore changes to any bots
if after.bot:
return
if isinstance(before.activity, discord.Game):
before_type = "game"
before_name = before.activity.name
elif isinstance(before.activity, discord.Spotify):
before_type = "spotify"
before_name = before.activity.artist
elif isinstance(before.activity, discord.Activity):
before_type = "activity"
before_name = before.activity.name
else:
before_type = "other"
before_name = "other"
if isinstance(after.activity, discord.Game):
after_type = "game"
after_name = after.activity.name
elif isinstance(after.activity, discord.Spotify):
after_type = "spotify"
after_name = after.activity.artist
elif isinstance(after.activity, discord.Activity):
after_type = "activity"
after_name = after.activity.name
else:
after_type = "other"
after_name = "other"
self.log_activity(
user_id=before.id,
before_activity_type=before_type,
before_activity_name=before_name,
after_activity_type=after_type,
after_activity_name=after_name
)
async def on_presence_update(
self,
before: discord.Member,
after: discord.Member):
# Log the activity or status change
self.logger.info("Received activity change; logging")
self.db.insert_activity_change(before, after)
@commands.command(name='nerd', aliases=['nerdscale'],
description="Find how nerdy a user is.")
async def nerd_(self, ctx, member: discord.Member = None):
async def nerd_(self, ctx, member: typing.Union[discord.Member, int, None]):
"""Checks the play history of all users in the Discord server, and
based on how many hours they have in League of Legends compared to
other users, declare how nerdy they are."""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# Start by getting all unique user IDs
if member:
user_ids = [member.id]
# If member is not defined, find the user with the most time
if not member:
members = [member.id for member in ctx.guild.members]
else:
user_ids = []
cursor.execute("SELECT DISTINCT user_id FROM user_activity")
user_ids = [int(r[0]) for r in cursor.fetchall()]
if isinstance(member, discord.Member):
members = [member.id]
else:
members = [member]
# For each user, compute timedelta for time playing League
# Get League stats for every member in guild
league_stats = {}
for user_id in user_ids:
# Get all entries that mention league
cursor.execute("""
SELECT
before_activity_name,
after_activity_name,
timestamp
FROM
user_activity
WHERE
user_id = (?) AND
timestamp > datetime('now', '-1 month')
""", (user_id,))
league_activities = cursor.fetchall()
# Sort by timestamp
league_activities = sorted(league_activities, key=lambda x: x[2])
# Compare each status change to find each time playing League
total_time = datetime.timedelta()
for first, second in zip(league_activities, league_activities[1:]):
if "league of legends" in first[1].lower().strip() and \
"league of legends" in second[0].lower().strip():
total_time += \
datetime.datetime.fromisoformat(second[2]) - \
datetime.datetime.fromisoformat(first[2])
# Save total time in League
league_stats[user_id] = total_time
for m in members:
league_stats[m] = datetime.timedelta()
stats = self.db.get_activity_stats(m)
sus = True if stats == {} else False
for key, value in stats.items():
if 'leagueoflegends' in key.lower().strip().replace(' ', ''):
league_stats[m] += value
# Sort all users by time in League
league_stats = dict(sorted(
@ -163,11 +76,6 @@ class Activities(commands.Cog):
reverse=True
))
# Print all stats for testing
print("League Stats:")
for user_id, time in league_stats.items():
print(user_id, ":", str(time))
# Get top user
user_id, time = next(iter(league_stats.items()))
time_val = None
@ -184,9 +92,9 @@ class Activities(commands.Cog):
if member:
if time_val != 0:
descriptor = ""
if time_units == "hours":
if time_units in ["hour", "hours"]:
descriptor = "a massive fucking nerd"
elif time_units == "hour":
elif time_units in ["minutes", "minute"]:
descriptor = "a huge nerd"
else:
descriptor = "a nerd"
@ -194,8 +102,12 @@ class Activities(commands.Cog):
f"{time_units} in the past month, making them "\
f"{descriptor}."
else:
response = f"<@{user_id}> doesn't have any time in League. "\
f"They're not a nerd."
if sus:
response = f"<@{user_id}> doesn't have any activities at "\
f"all. They're definitely hiding something."
else:
response = f"<@{user_id}> doesn't have any time in "\
f"League. They're not a nerd."
else:
response = (
f"<@{user_id}> has played League for {time_val} {time_units} "\

View File

@ -15,10 +15,78 @@ from async_timeout import timeout
from functools import partial
import yt_dlp
from yt_dlp import YoutubeDL
import logging
import database
logger = logging.getLogger("music_player")
# Get API key for last.fm
LASTFM_API_KEY = os.getenv('LASTFM_API_KEY')
# TEMORARY LIST OF SONGS
songs = [
"I Love It - Icona Pop",
"Vanished - Crystal Castles",
"We Like To Party - Vengaboys",
"Gimme! Gimme! Gimme! - ABBA",
"Dancing Queen - ABBA",
"I Wanna Dance With Somebody - Whitney Houston",
"Dance in the Dark - Lady Gaga",
"Telephone - Lady Gaga",
"Just Dance - Lady Gaga",
"Rewind - Charli xcx",
"Nasty - Tinashe",
"Rush - Troy Sivan",
"360 - Charli xcx",
"Talk talk - Charli xcx",
"Von dutch - Charli xcx",
"365 - Charli xcx",
"HOT TO GO! - Chappell Roan",
"Super Graphic Ultra Modern Girl - Chappell Roan",
"Womanizer - Britney Spears",
"Red Wind Supernova - Chappell Roan",
"Toxic - Britney Spears",
"We Found Love - Rihanna",
"212 - Azealia Banks",
"Bad Romance - Lady Gaga",
"Girl, so confusing - Charli xcx",
"Alter Ego - Doechii",
"Break Free - Ariana Grande",
"Raingurl - Yaeji",
"Thot Shit - Megan Thee Stallion",
"BREAK MY SOUL - Beyonce",
"She Wolf - Shakira",
"Your Love Is My Drug - Ke$ha",
"365 featuring shygirl - Charli xcx",
"Applause - Lady Gaga",
"Lay All Your Love On Me - ABBA",
"Apple - Charli xcx",
"Pump It Up - Endor",
"Everytime We Touch - Cascada",
"Fantasy - Mariah Carey",
"Water - Tyla",
"Be The One - Eli Brown",
"3 - Britney Spears",
"Guess featuring billie ellish - Charli xcx",
"Bunny Is A Rider - Doss Remix - Caroline Polachek",
"Do You Miss Me? - PinkPantheress",
"Perfect (Exceeder) - Mason",
"Better Off Alone (Laidback Luke Remix) - Alice DJ",
"Beauty And A Beat - Justin Bieber",
"Girl, so confusing - Charli xcx",
"Got Me Started - Troy Sivan",
"Gimme More - Britney Spears",
"Around the World - Daft Punk",
"Harder, Better, Faster, Stronger - Daft Punk",
"Sweet Dreams - Eurythmics",
"Dancing Elephants - DJ Minx Remix - Rochelle Jordan",
"MADELINE - INJI",
"Baddy On The Floor - Jamix xx",
"SWEET HONEY BUCKIIN' - Beyonce",
"Boots & Boys - Ke$ha"
]
# Suppress noise about console usage from errors
yt_dlp.utils.bug_reports_message = lambda: ''
@ -62,12 +130,16 @@ class YTDLSource(discord.PCMVolumeTransformer):
super().__init__(source)
self.requester = requester
# YouTube Metadata
self.title = data.get('title')
self.web_url = data.get('webpage_url')
self.thumbnail_url = data.get('thumbnail')
self.duration = data.get('duration')
self.artist = ''
self.song_title = ''
# Song metadata
self.search_term = ""
self.artist = ""
self.song_title = ""
# YTDL info dicts (data) have other useful information you might want
# https://github.com/rg3/youtube-dl/blob/master/README.md
@ -82,23 +154,33 @@ class YTDLSource(discord.PCMVolumeTransformer):
async def create_source(cls, ctx, search: str, *, loop, download=False, silent=False, artist='Unknown', song_title='Unknown'):
loop = loop or asyncio.get_event_loop()
# Find out which song the user wants
# If we got a YouTube link, get the video title for the song search
if validators.url(search):
with YoutubeDL() as ydl:
info = ydl.extract_info(search, download=False)
search_term = info.get('title', '')
else:
search_term = search
# Get song metadata
logger.info(f"Searching LastFM for: '{search_term}'")
url = f"http://ws.audioscrobbler.com/2.0/?method=track.search&"\
f"track={search_term}&api_key={LASTFM_API_KEY}&format=json"
response = requests.get(url)
lastfm_data = response.json()
# Let's get the first result, if any
if lastfm_data['results']['trackmatches']['track']:
track = lastfm_data['results']['trackmatches']['track'][0]
artist = track['artist']
song_title = track['name']
# Adjust search term if we didn't get a URL
if not validators.url(search):
# Fetch song metadata
url = f'http://ws.audioscrobbler.com/2.0/?method=track.search&track={search}&api_key={LASTFM_API_KEY}&format=json'
response = requests.get(url)
lastfm_data = response.json()
search = f"{song_title} {artist} official audio"
# Let's get the first result, if any
if lastfm_data['results']['trackmatches']['track']:
track = lastfm_data['results']['trackmatches']['track'][0]
search = track['artist'] + ' ' + track['name'] + ' album version'
artist = track['artist']
song_title = track['name']
else:
return
# Get source
# Get YouTube video source
logger.info(f"Getting YouTube video: {search}")
to_run = partial(ytdl.extract_info, url=search, download=download)
data = await loop.run_in_executor(None, to_run)
@ -114,14 +196,12 @@ class YTDLSource(discord.PCMVolumeTransformer):
return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']}
ffmpeg_source = cls(discord.FFmpegPCMAudio(source), data=data, requester=ctx.author)
# TODO: ADD THESE TO THE CONSTRUCTOR
ffmpeg_source.search_term = search_term
ffmpeg_source.artist = artist
ffmpeg_source.song_title = song_title
ffmpeg_source.filename = source
if not silent:
embed = discord.Embed(title="Queued", description=f"[{ffmpeg_source.song_title} by {ffmpeg_source.artist}]({data['webpage_url']})", color=discord.Color.green())
await ctx.send(embed=embed)
return ffmpeg_source
@classmethod
@ -144,7 +224,11 @@ class MusicPlayer:
When the bot disconnects from the Voice it's instance will be destroyed.
"""
__slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume')
__slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume', 'dj_mode')
# Each player is assiciated with a guild, so create a lock for when we do
# volatile things in the server like delete previous messages
guild_lock_ = asyncio.Lock()
def __init__(self, ctx):
self.bot = ctx.bot
@ -158,6 +242,7 @@ class MusicPlayer:
self.np = None # Now playing message
self.volume = .5
self.current = None
self.dj_mode = False
ctx.bot.loop.create_task(self.player_loop())
@ -168,12 +253,33 @@ class MusicPlayer:
while not self.bot.is_closed():
self.next.clear()
try:
# Wait for the next song. If we timeout cancel the player and disconnect...
async with timeout(300): # 5 minutes...
source = await self.queue.get()
except asyncio.TimeoutError:
return self.destroy(self._guild)
# Always get a song if there's one in the queue
if self.queue.qsize() > 0 or self.dj_mode is False:
logger.info("Getting song from play queue")
try:
# Wait for the next song. If we timeout cancel the player
# and disconnect...
async with timeout(300): # 5 minutes...
source = await self.queue.get()
except asyncio.TimeoutError:
return self.destroy(self._guild)
# Otherwise we're in DJ mode and a user hasn't requested one, so
# pick a song at random and create a source for it
else:
logger.info(
"Queue is empty and DJ mode is on. Picking song at random")
source = await YTDLSource.create_source(
None, random.choice(songs),
loop=self.bot.loop,
download=True
)
# For the time being, we're going to use 'None' to signal to the
# player that is should go back around and check for a song again,
# mainly because DJ mode was switched on and it should pick a song
# at random this time
if source is None:
continue
if not isinstance(source, YTDLSource):
# Source was probably a stream (not downloaded)
@ -188,14 +294,16 @@ class MusicPlayer:
source.volume = self.volume
self.current = source
logger.info(f"Playing '{source.song_title}' by '{source.artist}'")
self._guild.voice_client.play(source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set))
logger.info("Updating presense and 'now playing' message")
await self.bot.change_presence(activity=discord.Activity(type=discord.ActivityType.custom, name="custom", state=f"🎵 {source.song_title} by {source.artist}"))
embed = discord.Embed(title="Now playing", description=f"[{source.song_title} by {source.artist}]({source.web_url})", color=discord.Color.green())
self.np = await self._channel.send(embed=embed)
await self.update_now_playing_message(repost=True)
logger.info("Waiting for song to finish")
await self.next.wait()
await self.bot.change_presence(status=None)
if os.path.exists(source.filename):
os.remove(source.filename)
@ -206,6 +314,52 @@ class MusicPlayer:
pass
self.current = None
# Update bot statuses to match no song playing
await self.bot.change_presence(status=None)
await self.update_now_playing_message(repost=False)
async def update_now_playing_message(self, repost=False, emoji='▶️'):
await self.bot.wait_until_ready()
# Create and post new 'Now Playing' message
embed = discord.Embed(
title=f"{emoji} Now Playing", color=discord.Color.green())
# Get and add the thumbnail
if self.current:
embed.set_thumbnail(url=self.current.thumbnail_url)
embed.add_field(
name="",
value=f"[{self.current.song_title}]({self.current.web_url}) - "
f"{self.current.artist}",
inline=False
)
# Add all upcoming songs
# Possibly dangerous, but only obvious solution
queue = [s for s in self.queue._queue if s is not None]
if self.queue.qsize() > 0:
value_str = ""
for i, song in enumerate(queue):
value_str += f"{i+1}. [{song.song_title}]({song.web_url}) - "\
f"{song.artist}\n"
embed.add_field(name="Queue", value=value_str, inline=False)
# There's a chance we'll delete a message another coroutine is using, so
# lock the messages
async with self. guild_lock_:
async for message in self._channel.history(limit=50):
if len(message.embeds) == 1 and message.embeds[0].title and "Now Playing" in message.embeds[0].title:
if repost:
await message.delete()
else:
await message.edit(embed=embed)
break
if repost:
self.np = await self._channel.send(embed=embed)
def destroy(self, guild):
"""Disconnect and cleanup the player."""
return self.bot.loop.create_task(self._cog.cleanup(guild))
@ -219,23 +373,27 @@ class Music(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.players = {}
self.last_tag_play_time = datetime.datetime.now() - datetime.timedelta(minutes=30)
self.last_tag_play_time = datetime.datetime.now()
def update_cache():
with yt_dlp.YoutubeDL({'quiet': True}) as ydl:
self.boywife_tracks = ydl.extract_info('https://soundcloud.com/djboywife', download=False)['entries']
# Get a reference to the database
# TODO: MAKE THIS INJECTED
self.db = database.Database("boywife_bot.db")
with open('soundcloud-cache', 'w') as f:
f.write(str(self.boywife_tracks))
# pickle.dump(self.boywife_tracks, f)
# def update_cache():
# with yt_dlp.YoutubeDL({'quiet': True}) as ydl:
# self.boywife_tracks = ydl.extract_info('https://soundcloud.com/djboywife', download=False)['entries']
if os.path.exists('soundcloud-cache'):
with open('soundcloud-cache', 'r') as f:
exec(f'self.boywife_tracks = {f.read()}')
# self.boywife_tracks = pickle.load(f)
threading.Thread(target=update_cache).start()
else:
update_cache()
# with open('soundcloud-cache', 'w') as f:
# f.write(str(self.boywife_tracks))
# # pickle.dump(self.boywife_tracks, f)
# if os.path.exists('soundcloud-cache'):
# with open('soundcloud-cache', 'r') as f:
# exec(f'self.boywife_tracks = {f.read()}')
# # self.boywife_tracks = pickle.load(f)
# threading.Thread(target=update_cache).start()
# else:
# update_cache()
async def cleanup(self, guild):
@ -311,9 +469,7 @@ class Music(commands.Cog):
await channel.connect()
except asyncio.TimeoutError:
raise VoiceConnectionError(f'Connecting to channel: <{channel}> timed out.')
if (random.randint(0, 1) == 0):
await ctx.message.add_reaction('👍')
# await ctx.send(f'**Joined `{channel}`**')
# await ctx.message.add_reaction('👍')
@commands.command(name='play', aliases=['sing','p'], description="streams music")
async def play_(self, ctx, *, search: str = None):
@ -325,29 +481,59 @@ class Music(commands.Cog):
search: str [Required]
The song to search and retrieve using YTDL. This could be a simple search, an ID or URL.
"""
# Ensure we're connected to the proper voice channel
vc = ctx.voice_client
if not vc:
await ctx.invoke(self.connect_)
player = self.get_player(ctx)
# Send message to say we're working on it
embed = discord.Embed(
title=f"🔎 Searching for:",
description=f"{search}",
color=discord.Color.green()
)
message = await ctx.channel.send(embed=embed)
# Play tag every 30 minutes
if datetime.datetime.now() - self.last_tag_play_time > datetime.timedelta(minutes=30):
self.last_tag_play_time = datetime.datetime.now()
source = await YTDLSource.create_source(ctx, 'https://soundcloud.com/djboywife/youre-listening-to-dj-boywife', loop=self.bot.loop,
download=True, silent=True, artist='DJ Boywife', song_title="Tag")
await player.queue.put(source)
# If no song is given, pick a soundcloud track at random
if not search:
track = random.choice(self.boywife_tracks)
source = await YTDLSource.create_source(ctx, track['formats'][0]['url'], loop=self.bot.loop,
download=True, silent=True, artist=track['uploader'], song_title=track['title'])
else:
# Create source
try:
source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop, download=True)
except:
pass
# Update previous message to show found song and video
embed = discord.Embed(
title=f"Queued",
description=f"[{source.song_title}]({source.web_url}) - {source.artist}",
color=discord.Color.green()
)
embed.set_thumbnail(url=source.thumbnail_url)
await message.edit(embed=embed)
# Add song to the corresponding player object
player = self.get_player(ctx)
await player.queue.put(source)
await player.update_now_playing_message()
@commands.command(name="djmode", aliases=['dj'], description="Turns DJ mode on or off.")
async def djmode_(self, ctx, *, mode: str = "on"):
"""Turns DJ mode on or off. When on, the bot will play songs
automatically."""
# Get desired mode
mode = mode.lower().strip()
if mode in ("true", "t", "yes", "y", "on"):
mode = True
elif mode in ("false", "f", "no", "n", "off"):
mode = False
else:
return
# Switch to desired mode
player = self.get_player(ctx)
player.dj_mode = mode
# Break player out of waiting on queue so it can pick a song at random
if player.dj_mode:
await player.queue.put(None)
@commands.command(name='pause', description="pauses music")
async def pause_(self, ctx):
@ -361,7 +547,10 @@ class Music(commands.Cog):
return
vc.pause()
await ctx.send("Paused ⏸️")
# Update the 'Now Playing' message to reflect its paused
player = self.get_player(ctx)
await player.update_now_playing_message(emoji='⏸️')
@commands.command(name='resume', description="resumes music")
async def resume_(self, ctx):
@ -375,7 +564,10 @@ class Music(commands.Cog):
return
vc.resume()
await ctx.send("Resuming ⏯️")
# Update the 'Now Playing' message to reflect its resumed
player = self.get_player(ctx)
await player.update_now_playing_message()
@commands.command(name='skip', description="skips to next song in queue")
async def skip_(self, ctx):
@ -393,7 +585,7 @@ class Music(commands.Cog):
vc.stop()
@commands.command(name='remove', aliases=['rm', 'rem'], description="removes specified song from queue")
@commands.command(name='remove', aliases=['rm'], description="removes specified song from queue")
async def remove_(self, ctx, pos : int=None):
"""Removes specified song from queue"""
@ -430,69 +622,6 @@ class Music(commands.Cog):
player.queue._queue.clear()
await ctx.send('**Cleared**')
@commands.command(name='queue', aliases=['q', 'playlist', 'que'], description="shows the queue")
async def queue_info(self, ctx):
"""Retrieve a basic queue of upcoming songs."""
vc = ctx.voice_client
if not vc or not vc.is_connected():
embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green())
return await ctx.send(embed=embed)
player = self.get_player(ctx)
if player.queue.empty():
embed = discord.Embed(title="", description="The queue is empty", color=discord.Color.green())
return await ctx.send(embed=embed)
seconds = vc.source.duration % (24 * 3600)
hour = seconds // 3600
seconds %= 3600
minutes = seconds // 60
seconds %= 60
if hour > 0:
duration = "%dh %02dm %02ds" % (hour, minutes, seconds)
else:
duration = "%02dm %02ds" % (minutes, seconds)
# Grabs the songs in the queue...
upcoming = list(itertools.islice(player.queue._queue, 0, int(len(player.queue._queue))))
# fmt = '\n'.join(f"`{(upcoming.index(_)) + 1}.` [{_['title']}]({_['webpage_url']}) | ` {duration} Requested by: {_['requester']}`\n" for _ in upcoming)
fmt = '\n'.join(f"`{(upcoming.index(_)) + 1}.` {_['title']} | ` {duration} Requested by: {_['requester']}`\n" for _ in upcoming)
fmt = f"\n__Now Playing__:\n[{vc.source.title}]({vc.source.web_url})\n\n__Up Next:__\n" + fmt
embed = discord.Embed(title=f'Queue for {ctx.guild.name}', description=fmt, color=discord.Color.green())
# embed.set_footer(text=f"{ctx.author.display_name}", icon_url=ctx.author.avatar_url)
await ctx.send(embed=embed)
@commands.command(name='np', aliases=['song', 'current', 'currentsong', 'playing'], description="shows the current playing song")
async def now_playing_(self, ctx):
"""Display information about the currently playing song."""
vc = ctx.voice_client
if not vc or not vc.is_connected():
embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green())
return await ctx.send(embed=embed)
player = self.get_player(ctx)
if not player.current:
embed = discord.Embed(title="", description="I am currently not playing anything", color=discord.Color.green())
return await ctx.send(embed=embed)
seconds = vc.source.duration % (24 * 3600)
hour = seconds // 3600
seconds %= 3600
minutes = seconds // 60
seconds %= 60
if hour > 0:
duration = "%dh %02dm %02ds" % (hour, minutes, seconds)
else:
duration = "%02dm %02ds" % (minutes, seconds)
embed = discord.Embed(title="", description=f"[{vc.source.title}]({vc.source.web_url}) [{vc.source.requester.mention}] | `{duration}`", color=discord.Color.green())
#embed.set_author(icon_url=self.bot.user.avatar_url, name=f"Now Playing 🎶")
embed.set_author(name=f"Now Playing 🎶")
await ctx.send(embed=embed)
@commands.command(name='volume', aliases=['vol', 'v'], description="changes Kermit's volume")
async def change_volume(self, ctx, *, vol: float=None):
"""Change the player volume.
@ -536,9 +665,7 @@ class Music(commands.Cog):
embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green())
return await ctx.send(embed=embed)
if (random.randint(0, 1) == 0):
await ctx.message.add_reaction('👋')
#await ctx.send('**Successfully disconnected**')
await ctx.message.add_reaction('👋')
await self.cleanup(ctx.guild)

443
database.py Normal file
View File

@ -0,0 +1,443 @@
from datetime import datetime, timedelta
import discord
import sqlite3
import typing
from tqdm import tqdm
from cogs import music_player
class Database:
def __init__(self, path: str = "boywife_bot.db"):
self.path = path
self._ensure_db()
# # TEMP: THIS IS FOR MIGRATING THE PREVIOUS DATABASE SCHEMA
# with sqlite3.connect("ingest.db") as conn:
# cursor = conn.cursor()
# cursor.execute("""
# SELECT
# user_id,
# before_activity_type,
# before_activity_name,
# after_activity_type,
# after_activity_name,
# timestamp
# FROM
# user_activity
# """)
# activities = cursor.fetchall()
# for activity in tqdm(activities):
# # Convert activity type
# if activity[1] == "activity":
# new_before_type = "playing"
# elif activity[1] == "spotify":
# new_before_type = "listening"
# elif activity[1] == "game":
# new_before_type = "playing"
# else:
# new_before_type = None
# if activity[3] == "activity":
# new_after_type = "playing"
# elif activity[3] == "spotify":
# new_after_type = "listening"
# elif activity[3] == "game":
# new_after_type = "playing"
# else:
# new_after_type = None
# new_before_name = None if activity[2] == "other" else activity[2]
# new_after_name = None if activity[4] == "other" else activity[4]
# user_id = self._insert_user(activity[0])
# with sqlite3.connect(self.path) as conn:
# cursor = conn.cursor()
# cursor.execute("""
# INSERT INTO activity_change (
# user_id,
# before_activity_type,
# before_activity_name,
# before_activity_status,
# after_activity_type,
# after_activity_name,
# after_activity_status,
# timestamp
# ) VALUES (
# ?, ?, ?, ?, ?, ?, ?, ?
# )
# """, (
# user_id,
# new_before_type,
# new_before_name,
# "unknown",
# new_after_type,
# new_after_name,
# "unknown",
# datetime.datetime.fromisoformat(activity[5])
# ))
def _ensure_db(self):
with sqlite3.connect(self.path) as conn:
# Table for keeping track of servers
conn.execute("""
CREATE TABLE IF NOT EXISTS server (
id INTEGER PRIMARY KEY,
discord_id INTEGER NOT NULL UNIQUE
)
""")
# Table for keeping track of channels
conn.execute("""
CREATE TABLE IF NOT EXISTS channel (
id INTEGER PRIMARY KEY,
discord_id INTEGER NOT NULL UNIQUE
)
""")
# Table for keeping track of users
conn.execute("""
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY,
discord_id INTEGER NOT NULL UNIQUE
)
""")
# Create the activity table
conn.execute("""
CREATE TABLE IF NOT EXISTS activity_change (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
before_activity_type TEXT,
before_activity_name TEXT,
before_activity_status TEXT NOT NULL,
after_activity_type TEXT,
after_activity_name TEXT,
after_activity_status TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
# Create the song request table
conn.execute("""
CREATE TABLE IF NOT EXISTS song_request (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
channel_id INTEGER NOT NULL,
search_term TEXT NOT NULL,
song_title TEXT,
song_artist TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
# Table for songs that actually get played
conn.execute("""
CREATE TABLE IF NOT EXISTS song_play (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
channel_id INTEGER NOT NULL,
search_term TEXT NOT NULL,
song_title TEXT,
song_artist TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
conn.commit()
def _insert_server(self, discord_id: int = None) -> int:
"""Inserts Discord server ID into the 'server' table.
This method takes an ID for a server used in Discord, and inserts it
into the database. It ignores the case where the server ID is already
present. It then returns the row ID regardless.
Args:
discord_id (int): The ID used to identify the server in Discord.
Returns:
int: The ID of the server in the server table.
Examples:
>>> db = Database("path.db")
>>> db._insert_server(850610922256442889)
12
"""
with sqlite3.connect(self.path) as conn:
cursor = conn.cursor()
# Insert it; ignoring already exists error
cursor.execute("""
INSERT INTO server (discord_id)
VALUES (?)
ON CONFLICT(discord_id) DO NOTHING
RETURNING id;
""", (discord_id,))
row = cursor.fetchone()
if row:
row_id = row[0]
else:
# Get row ID if it already exists and wasn't inserted
cursor.execute("""
SELECT id FROM server WHERE discord_id = ?
""", (discord_id,))
row_id = cursor.fetchone()[0]
return row_id
def _insert_channel(self, discord_id: int = None) -> int:
"""Inserts Discord channel ID into the 'channel' table.
This method takes an ID for a channel used in Discord, and inserts it
into the database. It ignores the case where the channel ID is already
present. It then returns the row ID regardless.
Args:
discord_id (int): The ID used to identify the channel in Discord.
Returns:
int: The ID of the channel in the channel table.
Examples:
>>> db = Database("path.db")
>>> db._insert_channel(8506109222564428891)
12
"""
with sqlite3.connect(self.path) as conn:
cursor = conn.cursor()
# Insert it; ignoring already exists error
cursor.execute("""
INSERT INTO channel (discord_id)
VALUES (?)
ON CONFLICT(discord_id) DO NOTHING
RETURNING id;
""", (discord_id,))
row = cursor.fetchone()
if row:
row_id = row[0]
else:
# Get row ID if it already exists and wasn't inserted
cursor.execute("""
SELECT id FROM channel WHERE discord_id = ?
""", (discord_id,))
row_id = cursor.fetchone()[0]
return row_id
def _insert_user(self, discord_id: int = None) -> int:
"""Inserts Discord user ID into the 'user' table.
This method takes an ID for a user used in Discord, and inserts it
into the database. It ignores the case where the user ID is already
present. It then returns the row ID regardless.
Args:
discord_id (int): The ID used to identify the user in Discord.
Returns:
int: The ID of the user in the user table.
Examples:
>>> db = Database("path.db")
>>> db._insert_user(850610922256442889)
12
"""
with sqlite3.connect(self.path) as conn:
cursor = conn.cursor()
# Insert it; ignoring already exists error
cursor.execute("""
INSERT INTO user (discord_id)
VALUES (?)
ON CONFLICT(discord_id) DO NOTHING
RETURNING id;
""", (discord_id,))
row = cursor.fetchone()
if row:
row_id = row[0]
else:
# Get row ID if it already exists and wasn't inserted
cursor.execute("""
SELECT id FROM user WHERE discord_id = ?
""", (discord_id,))
row_id = cursor.fetchone()[0]
return row_id
def insert_activity_change(
self,
before: discord.Member,
after: discord.Member):
"""Inserts an activity change into the database.
This method takes two discord.Memeber objects, and records the change
in activity into the 'activity_change' table.
Args:
before (discord.Member): The previous user status.
after (discord.Member): The current user status.
Raises:
ValueError: If the before and after activity do not refer to the
same user.
Examples:
>>> @commands.Cog.listener()
>>> async def on_presence_update(
... self,
... before: discord.Member,
... after: discord.Member):
... db = Database("path.db")
... db.insert_activity_change(before, after)
>>>
"""
# Ensure the users are the same
if before.id != after.id:
raise ValueError("User IDs do not match.")
user_id = self._insert_user(before.id)
# Get activities if they exist
before_type = before.activity.type.name if before.activity else None
before_name = before.activity.name if before.activity else None
after_type = after.activity.type.name if after.activity else None
after_name = after.activity.name if after.activity else None
# Insert the activity change
with sqlite3.connect(self.path) as conn:
conn.execute("""
INSERT INTO activity_change (
user_id,
before_activity_type,
before_activity_name,
before_activity_status,
after_activity_type,
after_activity_name,
after_activity_status
) VALUES (
?, ?, ?, ?, ?, ?, ?
)
""", (
user_id,
before_type,
before_name,
before.status.name,
after_type,
after_name,
after.status.name
))
def insert_song_request(
self,
message: discord.Message,
source: music_player.YTDLSource):
"""Inserts a song request into the database.
This method takes a message and its derived music source and inserts
the relevant information into the 'song_request' table.
Args:
message (discord.Message): The Discord message requesting the song.
source (music_player.YTDLSource): The audio source.
"""
# Insert the information
with sqlite3.connect(self.path) as conn:
conn.execute("""
INSERT INTO song_request (
user_id,
channel_id,
search_term,
song_title,
song_artist
) VALUES (
?, ?, ?, ?, ?
)
""", (
self._insert_user(message.author.id),
self._insert_channel(message.channel.id),
source.search_term,
source.song_title,
source.artist
))
def insert_song_play(
self,
channel_id: int,
source: music_player.YTDLSource):
"""Inserts a song play into the database.
This method takes a channel and the song being played and inserts the
relevant information into the 'song_play' table.
Args:
channel (int): The Discord channel the song is being played in.
source (music_player.YTDLSource): The audio source.
"""
user_id = self._insert_user(source.requester.id)
channel_id = self._insert_user(channel.id)
# Insert the information
with sqlite3.connect(self.path) as conn:
conn.execute("""
INSERT INTO song_play (
user_id,
channel_id,
search_term,
song_title,
song_artist
) VALUES (
?, ?, ?, ?, ?
)
""", (
user_id,
channel_id,
source.search_term,
source.song_title,
source.artist
))
def get_activity_stats(
self,
member: typing.Union[discord.Member, int],
start: datetime = datetime.now() - timedelta(days=30)
)-> dict[str, timedelta]:
"""Gets stats on the activities of the given member.
This method searches the database for activity changes by the given
user and computes the amount of time spent in each activity.
Args:
member (discord.Member): The Discord member to get stats for.
start (datetime): The earliest activity change to get.
Returns:
dict[str, timedelta]: A dictionary of activity names and
seconds in each.
"""
# Get member Discord ID and convert to DB ID
member_id = member.id if isinstance(member, discord.Member) else member
member_id = self._insert_user(member_id)
# Pull all activities for this user
with sqlite3.connect(self.path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
before_activity_name,
after_activity_name,
timestamp
FROM
activity_change
WHERE
user_id = (?) AND
timestamp > (?)
""", (member_id, start))
activities = cursor.fetchall()
# Collect activities
activity_stats = {}
for first, second in zip(activities, activities[1:]):
if first[1] == second[0]:
activity_name = first[1]
activity_time = \
datetime.fromisoformat(second[2]) - \
datetime.fromisoformat(first[2])
if activity_name in activity_stats:
activity_stats[activity_name] += activity_time
else:
activity_stats[activity_name] = activity_time
if None in activity_stats:
del activity_stats[None]
return activity_stats