Compare commits

...

2 Commits

Author SHA1 Message Date
a7f00f9e43 Added DJ mode automatic song selection. 2025-07-29 23:22:26 -04:00
08938c08ae Shrunk icons for use in embeds. 2025-07-27 21:15:39 -04:00
9 changed files with 403 additions and 40 deletions

163
.gitignore vendored Normal file
View File

@ -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/

View File

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -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."
)

View File

@ -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()
@ -409,3 +414,138 @@ class Database:
if None in activity_stats:
del activity_stats[None]
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)

View File

@ -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