Compare commits

..

No commits in common. "a7f00f9e43196d90f9150aeb45f4f2323952c619" and "8ef6ccde085b7c90425745790d4170a13cb0fbcb" have entirely different histories.

9 changed files with 40 additions and 403 deletions

163
.gitignore vendored
View File

@ -1,163 +0,0 @@
# 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,10 +15,6 @@ Source Code:
https://github.com/jtkick/base-discord-bot https://github.com/jtkick/base-discord-bot
""" """
# BOT PERMISSIONS
# 1729383718059856
PROJECT_VERSION = "0.1.0" PROJECT_VERSION = "0.1.0"
# Standard imports # Standard imports
@ -29,7 +25,6 @@ import sys
# Third-part imports # Third-part imports
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands
from dotenv import load_dotenv from dotenv import load_dotenv
from openai import OpenAI from openai import OpenAI
@ -77,8 +72,6 @@ def main():
if filename.endswith('.py'): if filename.endswith('.py'):
await client.load_extension(f'cogs.{filename[:-3]}') await client.load_extension(f'cogs.{filename[:-3]}')
logger.info("Loaded %s cog", filename) 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) client.run(TOKEN, log_handler=None)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -3,7 +3,6 @@ import atexit
import datetime import datetime
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands
import enum import enum
import random import random
import asyncio import asyncio
@ -21,13 +20,16 @@ import yt_dlp
from yt_dlp import YoutubeDL from yt_dlp import YoutubeDL
import logging import logging
import database
logger = logging.getLogger("music_player") logger = logging.getLogger("music_player")
# Get API key for last.fm # Get API key for last.fm
LASTFM_API_KEY = os.getenv("LASTFM_API_KEY") LASTFM_API_KEY = os.getenv("LASTFM_API_KEY")
# TEMORARY LIST OF SONGS
songs = [
[REDACTED]
]
# Suppress noise about console usage from errors # Suppress noise about console usage from errors
# yt_dlp.utils.bug_reports_message = lambda: "" # yt_dlp.utils.bug_reports_message = lambda: ""
@ -84,7 +86,15 @@ class YTDLSource(discord.PCMVolumeTransformer):
return self.__getattribute__(item) return self.__getattribute__(item)
@classmethod @classmethod
async def create_source(cls, ctx, search: str, *, download=False): async def create_source(
cls,
ctx,
search: str,
*,
download=False,
artist="",
song_title="",
):
loop = ctx.bot.loop if ctx else asyncio.get_event_loop() 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 # If we got a YouTube link, get the video title for the song search
@ -236,24 +246,6 @@ class MusicPlayer:
logger.info("Updating 'Now Playing' message") logger.info("Updating 'Now Playing' message")
await self.bot.wait_until_ready() await self.bot.wait_until_ready()
async with self._guild_lock: 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 # Create new 'Now Playing' message
if self._state is self.State.IDLE: if self._state is self.State.IDLE:
embed = discord.Embed( embed = discord.Embed(
@ -261,52 +253,28 @@ class MusicPlayer:
) )
elif self._state is self.State.PLAYING: elif self._state is self.State.PLAYING:
embed = discord.Embed( embed = discord.Embed(
title=f"'{self.current.song_title}' by {self.current.artist}", title=f"▶️ Now Playing", color=discord.Color.blue()
url=self.current.web_url,
color=discord.Color.green()
) )
elif self._state is self.State.PAUSED: elif self._state is self.State.PAUSED:
embed = discord.Embed( embed = discord.Embed(
title=f"'{self.current.song_title}' by {self.current.artist}", title=f"⏸️ Paused", color=discord.Color.light_gray()
url=self.current.web_url,
color=discord.Color.green()
) )
else: else:
embed = discord.Embed( embed = discord.Embed(
title="UNKNOWN STATE", color=discord.Color.red() 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 # Get and add the thumbnail
if self._state in [self.State.PLAYING, self.State.PAUSED]: if self._state in [self.State.PLAYING, self.State.PAUSED]:
embed.set_thumbnail(url=self.current.thumbnail_url) embed.set_thumbnail(url=self.current.thumbnail_url)
# embed.add_field( embed.add_field(
# name="", name="",
# value=( value=(
# f"[{self.current.song_title}]({self.current.web_url}) - " f"[{self.current.song_title}]({self.current.web_url}) - "
# f"{self.current.artist}" f"{self.current.artist}"
# ), ),
# inline=False, inline=False,
# ) )
# Add all upcoming songs # Add all upcoming songs
# Possibly dangerous, but only obvious solution # Possibly dangerous, but only obvious solution
@ -315,19 +283,16 @@ class MusicPlayer:
value_str = "" value_str = ""
for i, song in enumerate(queue): for i, song in enumerate(queue):
value_str += ( value_str += (
f"{i+1}. ['{song.song_title}' by {song.artist}]({song.web_url})\n" f"{i+1}. [{song.song_title}]({song.web_url}) -"
f" {song.artist}\n"
) )
embed.add_field(name="Queue", value=value_str, inline=False) 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 # Build controls
controls = discord.ui.View(timeout=None) controls = discord.ui.View(timeout=None)
# Construct 'back' button # Construct 'back' button
prev_button = discord.ui.Button( prev_button = discord.ui.Button(
label="", label="",
style=discord.ButtonStyle.secondary, style=discord.ButtonStyle.secondary,
custom_id="prev" custom_id="prev"
) )
@ -338,7 +303,7 @@ class MusicPlayer:
# Construct 'play/pause' button # Construct 'play/pause' button
play_button = discord.ui.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, style=discord.ButtonStyle.secondary,
custom_id="playpause" custom_id="playpause"
) )
@ -351,7 +316,7 @@ class MusicPlayer:
# Construct 'next' button # Construct 'next' button
next_button = discord.ui.Button( next_button = discord.ui.Button(
label="", label="",
style=discord.ButtonStyle.secondary, style=discord.ButtonStyle.secondary,
custom_id="next" custom_id="next"
) )
@ -428,24 +393,14 @@ class MusicPlayer:
"Queue is empty and DJ mode is on. Picking song at random" "Queue is empty and DJ mode is on. Picking song at random"
) )
try: 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( source = await YTDLSource.create_source(
None, None,
search, random.choice(songs),
download=True, download=True,
) )
if not source: if not source:
raise RuntimeError("Could not get YouTube source.") raise RuntimeError("Could not get YouTube source.")
except Exception as e: except Exception as e:
# Something's wrong, turn off DJ mode to prevent infinite
# loop
self.dj_mode = False
print(e) print(e)
await self._channel.send("Failed to get YouTube source.") await self._channel.send("Failed to get YouTube source.")
@ -539,11 +494,6 @@ class Music(commands.Cog):
self.bot = bot self.bot = bot
self.players = {} 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): async def cleanup(self, guild):
try: try:
await guild.voice_client.disconnect() await guild.voice_client.disconnect()
@ -707,10 +657,6 @@ class Music(commands.Cog):
await message.edit(embed=embed) await message.edit(embed=embed)
raise e 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( @commands.command(
name="djmode", aliases=["dj"], description="Turns DJ mode on or off." name="djmode", aliases=["dj"], description="Turns DJ mode on or off."
) )

View File

@ -1,15 +1,10 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import discord import discord
import logging
import openai
import random
import sqlite3 import sqlite3
import typing import typing
from cogs import music_player from cogs import music_player
logger = logging.getLogger("database")
class Database: class Database:
def __init__(self, path: str): def __init__(self, path: str):
self.path = path self.path = path
@ -316,7 +311,7 @@ class Database:
int: The row ID of the entered song. Used to update 'played' value. 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 user_id = self._insert_user(source.requester.id) if source.requester else None
channel_id = self._insert_channel(channel_id) channel_id = self._insert_user(channel_id)
# Insert the information # Insert the information
with sqlite3.connect(self.path) as conn: with sqlite3.connect(self.path) as conn:
cur = conn.cursor() cur = conn.cursor()
@ -414,138 +409,3 @@ class Database:
if None in activity_stats: if None in activity_stats:
del activity_stats[None] del activity_stats[None]
return activity_stats 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,7 +1,8 @@
async_timeout==5.0.1 discord
discord.py==2.5.2 discord[voice]
openai==1.97.1 ffmpeg
python-dotenv==1.1.1 python-dotenv
Requests==2.32.4 yt-dlp
validators==0.34.0 async_timeout
yt_dlp @ git+https://github.com/yt-dlp/yt-dlp.git@2025.07.21 validators
openai