From a7f00f9e43196d90f9150aeb45f4f2323952c619 Mon Sep 17 00:00:00 2001 From: Jared Kick Date: Tue, 29 Jul 2025 23:22:26 -0400 Subject: [PATCH] Added DJ mode automatic song selection. --- .gitignore | 163 +++++++++++++++++++++++++++++++++++++++++++ __main__.py | 7 ++ cogs/music_player.py | 114 ++++++++++++++++++++++-------- database.py | 144 +++++++++++++++++++++++++++++++++++++- requirements.txt | 15 ++-- 5 files changed, 403 insertions(+), 40 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfabb7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +# Bot databases stored as .db +*.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/__main__.py b/__main__.py index edf8905..b6fa76f 100755 --- a/__main__.py +++ b/__main__.py @@ -15,6 +15,10 @@ Source Code: https://github.com/jtkick/base-discord-bot """ + +# BOT PERMISSIONS +# 1729383718059856 + PROJECT_VERSION = "0.1.0" # Standard imports @@ -25,6 +29,7 @@ import sys # Third-part imports import discord from discord.ext import commands +from discord import app_commands from dotenv import load_dotenv from openai import OpenAI @@ -72,6 +77,8 @@ def main(): if filename.endswith('.py'): await client.load_extension(f'cogs.{filename[:-3]}') logger.info("Loaded %s cog", filename) + for server in client.guilds: + await client.tree.sync(guild=discord.Object(id=server.id)) client.run(TOKEN, log_handler=None) diff --git a/cogs/music_player.py b/cogs/music_player.py index b351f3e..b26a336 100644 --- a/cogs/music_player.py +++ b/cogs/music_player.py @@ -3,6 +3,7 @@ import atexit import datetime import discord from discord.ext import commands +from discord import app_commands import enum import random import asyncio @@ -20,16 +21,13 @@ 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 = [ - [REDACTED] -] - # Suppress noise about console usage from errors # yt_dlp.utils.bug_reports_message = lambda: "" @@ -86,15 +84,7 @@ class YTDLSource(discord.PCMVolumeTransformer): return self.__getattribute__(item) @classmethod - async def create_source( - cls, - ctx, - search: str, - *, - download=False, - artist="", - song_title="", - ): + async def create_source(cls, ctx, search: str, *, download=False): loop = ctx.bot.loop if ctx else asyncio.get_event_loop() # If we got a YouTube link, get the video title for the song search @@ -246,6 +236,24 @@ class MusicPlayer: logger.info("Updating 'Now Playing' message") await self.bot.wait_until_ready() async with self._guild_lock: + # # Create new 'Now Playing' message + # if self._state is self.State.IDLE: + # embed = discord.Embed( + # title=f"◻️ Idle", color=discord.Color.light_gray() + # ) + # elif self._state is self.State.PLAYING: + # embed = discord.Embed( + # title=f"▶️ Now Playing", color=discord.Color.blue() + # ) + # elif self._state is self.State.PAUSED: + # embed = discord.Embed( + # title=f"⏸️ Paused", color=discord.Color.light_gray() + # ) + # else: + # embed = discord.Embed( + # title="UNKNOWN STATE", color=discord.Color.red() + # ) + # Create new 'Now Playing' message if self._state is self.State.IDLE: embed = discord.Embed( @@ -253,28 +261,52 @@ class MusicPlayer: ) elif self._state is self.State.PLAYING: embed = discord.Embed( - title=f"▶️ Now Playing", color=discord.Color.blue() + title=f"'{self.current.song_title}' by {self.current.artist}", + url=self.current.web_url, + color=discord.Color.green() ) elif self._state is self.State.PAUSED: embed = discord.Embed( - title=f"⏸️ Paused", color=discord.Color.light_gray() + title=f"'{self.current.song_title}' by {self.current.artist}", + url=self.current.web_url, + color=discord.Color.green() ) else: embed = discord.Embed( title="UNKNOWN STATE", color=discord.Color.red() ) + if self._state is self.State.IDLE: + pass + elif self._state is self.State.PLAYING: + embed.set_author( + name="Now Playing", + icon_url="https://raw.githubusercontent.com/jtkick/base-discord-bot/refs/heads/develop/assets/play.png" + ) + elif self._state is self.State.PAUSED: + embed.set_author( + name="Paused", + icon_url="https://raw.githubusercontent.com/jtkick/base-discord-bot/refs/heads/develop/assets/pause.png" + ) + else: + embed = discord.Embed( + title="UNKNOWN STATE", color=discord.Color.red() + ) + + + + # Get and add the thumbnail if self._state in [self.State.PLAYING, self.State.PAUSED]: 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, - ) + # 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 @@ -283,16 +315,19 @@ class MusicPlayer: value_str = "" for i, song in enumerate(queue): value_str += ( - f"{i+1}. [{song.song_title}]({song.web_url}) -" - f" {song.artist}\n" + f"{i+1}. ['{song.song_title}' by {song.artist}]({song.web_url})\n" ) embed.add_field(name="Queue", value=value_str, inline=False) + # Add 'DJ Mode' footer if on + if self.dj_mode: + embed.set_footer(text="DJ Mode", icon_url="https://raw.githubusercontent.com/jtkick/base-discord-bot/refs/heads/develop/assets/dj.png") + # Build controls controls = discord.ui.View(timeout=None) # Construct 'back' button prev_button = discord.ui.Button( - label="⏮️", + label="⏮", style=discord.ButtonStyle.secondary, custom_id="prev" ) @@ -303,7 +338,7 @@ class MusicPlayer: # Construct 'play/pause' button play_button = discord.ui.Button( - label="▶️" if self._state is self.State.PAUSED else "⏸️", + label="⏵" if self._state is self.State.PAUSED else "⏸", style=discord.ButtonStyle.secondary, custom_id="playpause" ) @@ -316,7 +351,7 @@ class MusicPlayer: # Construct 'next' button next_button = discord.ui.Button( - label="⏭️", + label="⏭", style=discord.ButtonStyle.secondary, custom_id="next" ) @@ -393,14 +428,24 @@ class MusicPlayer: "Queue is empty and DJ mode is on. Picking song at random" ) try: + + # TEST + user_ids = [m.id for m in self._channel.members] + channel_ids = [c.id for c in self._channel.guild.channels] + song = self.bot.db.get_next_song(users=user_ids, channels=channel_ids) + search = f"{song["artist"]} {song["title"]}" + source = await YTDLSource.create_source( None, - random.choice(songs), + search, download=True, ) if not source: raise RuntimeError("Could not get YouTube source.") except Exception as e: + # Something's wrong, turn off DJ mode to prevent infinite + # loop + self.dj_mode = False print(e) await self._channel.send("Failed to get YouTube source.") @@ -494,6 +539,11 @@ class Music(commands.Cog): self.bot = bot self.players = {} + # @commands.Cog.listener() + # async def on_ready(self): + # await self.bot.tree.sync() + # logger.info("Synced command tree") + async def cleanup(self, guild): try: await guild.voice_client.disconnect() @@ -657,6 +707,10 @@ class Music(commands.Cog): await message.edit(embed=embed) raise e + @app_commands.command(name="hello", description="says hello") + async def hello(self, interaction: discord.Interaction): + await interaction.response.send_message("hello"); + @commands.command( name="djmode", aliases=["dj"], description="Turns DJ mode on or off." ) diff --git a/database.py b/database.py index 4f1d794..df28985 100644 --- a/database.py +++ b/database.py @@ -1,10 +1,15 @@ from datetime import datetime, timedelta import discord +import logging +import openai +import random import sqlite3 import typing from cogs import music_player +logger = logging.getLogger("database") + class Database: def __init__(self, path: str): self.path = path @@ -311,7 +316,7 @@ class Database: int: The row ID of the entered song. Used to update 'played' value. """ user_id = self._insert_user(source.requester.id) if source.requester else None - channel_id = self._insert_user(channel_id) + channel_id = self._insert_channel(channel_id) # Insert the information with sqlite3.connect(self.path) as conn: cur = conn.cursor() @@ -408,4 +413,139 @@ class Database: activity_stats[activity_name] = activity_time if None in activity_stats: del activity_stats[None] - return activity_stats \ No newline at end of file + return activity_stats + + def get_next_song(self, users: list[int], channels: list[int], limit: int = 100, cutoff: datetime = datetime.now() - timedelta(hours=1)): + + print("users:", users) + print("channels:", channels) + + # Convert user IDs to row IDs + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT + id + FROM + user + WHERE + discord_id IN (%s);""" % ",".join("?" for _ in users), + tuple(users)) + user_ids = [row[0] for row in cursor.fetchall()] + + cursor.execute(""" + SELECT + id + FROM + channel + WHERE + discord_id IN (%s);""" % ",".join("?" for _ in channels), + tuple(channels)) + channel_ids = [row[0] for row in cursor.fetchall()] + + # Pull song plays from the given channels + logger.info("Getting past song plays") + cursor.execute(""" + SELECT + song_title, + song_artist, + COUNT(*) AS count + FROM + song_play + WHERE + user_id IN (%s) AND + channel_id IN (%s) AND + finished = 1 AND + timestamp < ? + GROUP BY + song_title, + song_artist + ORDER BY + count DESC + LIMIT ?; + """ % ( + ",".join(str(id) for id in user_ids), + ",".join(str(id) for id in channel_ids) + ), (cutoff, limit)) + old_song_plays = cursor.fetchall() + + # Compile results into cleaner list of dicts + candidates = [{"title": t, "artist": a, "plays": p} for t, a, p in old_song_plays] + print("candidates:", candidates) + + # Get recent song plays + logger.info("Getting recent song plays") + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + # Get recent songs to avoid + cursor.execute(""" + SELECT + song_title, + song_artist + FROM + song_play + WHERE + channel_id IN (%s) AND + timestamp >= ? + GROUP BY + song_title, + song_artist; + """ % (",".join(str(id) for id in channel_ids)), (cutoff, )) + recent_song_plays = cursor.fetchall() + print("recent:", recent_song_plays) + + # Remove all songs that were recently played + def keep(song_play: dict[str, str, int]): + return not (song_play["title"], song_play["artist"]) in recent_song_plays + candidates = list(filter(keep, candidates)) + print("filtered candidates:", candidates) + + if len(candidates) > 0: + candidate = random.choice(candidates) + return {"title": candidate["title"], "artist": candidate["artist"]} + # If we have no songs left to play, get a recommendation from ChatGPT + else: + + # Get last five or so completed song plays + with sqlite3.connect(self.path) as conn: + cursor = conn.cursor() + # Get recent songs to avoid + cursor.execute(""" + SELECT + song_title, + song_artist + FROM + song_play + WHERE + channel_id IN (%s) AND + finished = 1 + GROUP BY + song_title, + song_artist + ORDER BY + timestamp DESC + LIMIT 5; + """ % (",".join(str(id) for id in channel_ids))) + last_five = cursor.fetchall() + + print("last five song plays:", last_five) + + setup_prompt = "I'm going to give you a list of songs and artists "\ + "formatted as a Python list of dicts where the "\ + "song title is the 'title' key and the artist is "\ + "the 'artist' key. I want you to return a song "\ + "title and artist that you would recommend based "\ + "on the given songs. You should give me only a bare text "\ + "string formatted as a Python dict where the "\ + "'title' key is the song title, and the 'artist' "\ + "key is the song's artist. Don't add anything other "\ + "than this dict." + user_prompt = [] + completion = openai.OpenAI().chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": setup_prompt}, + {"role": "user", "content": str(last_five)} + ] + ) + return eval(completion.choices[0].message.content) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 30a6043..afefe8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,7 @@ -discord -discord[voice] -ffmpeg -python-dotenv -yt-dlp -async_timeout -validators -openai +async_timeout==5.0.1 +discord.py==2.5.2 +openai==1.97.1 +python-dotenv==1.1.1 +Requests==2.32.4 +validators==0.34.0 +yt_dlp @ git+https://github.com/yt-dlp/yt-dlp.git@2025.07.21