Compare commits
No commits in common. "a7f00f9e43196d90f9150aeb45f4f2323952c619" and "8ef6ccde085b7c90425745790d4170a13cb0fbcb" have entirely different histories.
a7f00f9e43
...
8ef6ccde08
163
.gitignore
vendored
@ -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/
|
||||
@ -15,10 +15,6 @@ Source Code:
|
||||
https://github.com/jtkick/base-discord-bot
|
||||
"""
|
||||
|
||||
|
||||
# BOT PERMISSIONS
|
||||
# 1729383718059856
|
||||
|
||||
PROJECT_VERSION = "0.1.0"
|
||||
|
||||
# Standard imports
|
||||
@ -29,7 +25,6 @@ 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
|
||||
|
||||
@ -77,8 +72,6 @@ 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)
|
||||
|
||||
|
||||
BIN
assets/dj.png
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
BIN
assets/pause.png
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
assets/play.png
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.4 KiB |
BIN
assets/skip.png
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.6 KiB |
@ -3,7 +3,6 @@ import atexit
|
||||
import datetime
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
import enum
|
||||
import random
|
||||
import asyncio
|
||||
@ -21,13 +20,16 @@ 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: ""
|
||||
|
||||
@ -84,7 +86,15 @@ class YTDLSource(discord.PCMVolumeTransformer):
|
||||
return self.__getattribute__(item)
|
||||
|
||||
@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()
|
||||
|
||||
# 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")
|
||||
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(
|
||||
@ -261,52 +253,28 @@ class MusicPlayer:
|
||||
)
|
||||
elif self._state is self.State.PLAYING:
|
||||
embed = discord.Embed(
|
||||
title=f"'{self.current.song_title}' by {self.current.artist}",
|
||||
url=self.current.web_url,
|
||||
color=discord.Color.green()
|
||||
title=f"▶️ Now Playing", color=discord.Color.blue()
|
||||
)
|
||||
elif self._state is self.State.PAUSED:
|
||||
embed = discord.Embed(
|
||||
title=f"'{self.current.song_title}' by {self.current.artist}",
|
||||
url=self.current.web_url,
|
||||
color=discord.Color.green()
|
||||
title=f"⏸️ Paused", color=discord.Color.light_gray()
|
||||
)
|
||||
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
|
||||
@ -315,19 +283,16 @@ class MusicPlayer:
|
||||
value_str = ""
|
||||
for i, song in enumerate(queue):
|
||||
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)
|
||||
|
||||
# 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"
|
||||
)
|
||||
@ -338,7 +303,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"
|
||||
)
|
||||
@ -351,7 +316,7 @@ class MusicPlayer:
|
||||
|
||||
# Construct 'next' button
|
||||
next_button = discord.ui.Button(
|
||||
label="⏭",
|
||||
label="⏭️",
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id="next"
|
||||
)
|
||||
@ -428,24 +393,14 @@ 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,
|
||||
search,
|
||||
random.choice(songs),
|
||||
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.")
|
||||
|
||||
@ -539,11 +494,6 @@ 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()
|
||||
@ -707,10 +657,6 @@ 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."
|
||||
)
|
||||
|
||||
144
database.py
@ -1,15 +1,10 @@
|
||||
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
|
||||
@ -316,7 +311,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_channel(channel_id)
|
||||
channel_id = self._insert_user(channel_id)
|
||||
# Insert the information
|
||||
with sqlite3.connect(self.path) as conn:
|
||||
cur = conn.cursor()
|
||||
@ -413,139 +408,4 @@ class Database:
|
||||
activity_stats[activity_name] = activity_time
|
||||
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)
|
||||
return activity_stats
|
||||
@ -1,7 +1,8 @@
|
||||
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
|
||||
discord
|
||||
discord[voice]
|
||||
ffmpeg
|
||||
python-dotenv
|
||||
yt-dlp
|
||||
async_timeout
|
||||
validators
|
||||
openai
|
||||
|
||||