Merge changes for Apple Music support #1

Merged
PacketParker merged 3 commits from dev into main 2024-07-19 00:27:24 -05:00
9 changed files with 247 additions and 29 deletions

View File

@ -27,7 +27,8 @@
Guava is a Discord music bot with support for multiple different music and video streaming platforms. Guava is a part of >200 Discord servers and currently supports these services: Guava is a Discord music bot with support for multiple different music and video streaming platforms. Guava is a part of >200 Discord servers and currently supports these services:
- Spotify (Links) - Apple Music
- Spotify
- SoundCloud - SoundCloud
- Bandcamp - Bandcamp
- Deezer - Deezer
@ -71,11 +72,24 @@ FEEDBACK_CHANNEL_ID | `CHANNEL ID`: Discord channel for feedback messages to be
BUG_CHANNEL_ID | `CHANNEL ID`: Discord channel for bug messages to be sent to | **OPTIONAL** BUG_CHANNEL_ID | `CHANNEL ID`: Discord channel for bug messages to be sent to | **OPTIONAL**
SPOTIFY_CLIENT_ID | Client ID from Spotify Developer account | **REQUIRED** SPOTIFY_CLIENT_ID | Client ID from Spotify Developer account | **REQUIRED**
SPOTIFY_CLIENT_SECRET | Client Secret from Spotify Developer account | **REQUIRED** SPOTIFY_CLIENT_SECRET | Client Secret from Spotify Developer account | **REQUIRED**
APPLE_MUSIC_KEY | See the `note` below to get a media api key without a developer account | **REQUIRED**
OPENAI_API_KEY | API Key from OpenAI for autoplay recommendations | **REQUIRED** OPENAI_API_KEY | API Key from OpenAI for autoplay recommendations | **REQUIRED**
HOST | Host address for your Lavalink node | **REQUIRED** HOST | Host address for your Lavalink node | **REQUIRED**
PORT | Port for your Lavalink node | **REQUIRED** PORT | Port for your Lavalink node | **REQUIRED**
PASSWORD | Password to authenticate into the Lavalink node | **REQUIRED** PASSWORD | Password to authenticate into the Lavalink node | **REQUIRED**
<br>
<details>
<summary><strong>NOTE: Media API Key</strong></summary>
1. Go to https://music.apple.com
2. Open the debuger tab in dev tools
3. Regex this `"(?<token>(ey[\w-]+)\.([\w-]+)\.([\w-]+))"`
4. Copy the entire token from the JS file
</details>
<br>
# Lavalink Information # Lavalink Information
As previously state, a Lavalink node running at least `v4` with the LavaSrc plugin is required. Due to the plugin requirement, it is unlikely that you will be able to use a free/public Lavalink node. As previously state, a Lavalink node running at least `v4` with the LavaSrc plugin is required. Due to the plugin requirement, it is unlikely that you will be able to use a free/public Lavalink node.

View File

@ -37,7 +37,7 @@ class MyBot(commands.Bot):
async def on_ready(self): async def on_ready(self):
config.LOG.info(f"{bot.user} has connected to Discord.") config.LOG.info(f"{bot.user} has connected to Discord.")
config.LOG.info("Startup complete. Sync slash commands by DMing the bot ***sync") config.LOG.info(f"Startup complete. Sync slash commands by DMing the bot {bot.command_prefix}tree sync (guild id)")
bot = MyBot() bot = MyBot()

View File

@ -18,6 +18,11 @@ class News(commands.Cog):
color=BOT_COLOR, color=BOT_COLOR,
) )
embed.add_field(
name="**Apple Music Support!**",
value="> You can now play music through Apple Music links. Just paste the link and the bot will do the rest!",
)
embed.add_field( embed.add_field(
name="**Autoplay Update**", name="**Autoplay Update**",
value="> Autoplay is now much more stable after a revamp of the previous system. If you experienced short outages recently, this was due to the update. Thank you for your patience!", value="> Autoplay is now much more stable after a revamp of the previous system. If you experienced short outages recently, this was due to the update. Thank you for your patience!",

View File

@ -2,18 +2,22 @@ import discord
import datetime import datetime
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from cogs.music import Music
from lavalink import LoadType from lavalink import LoadType
import re import re
from cogs.music import LavalinkVoiceClient
import requests import requests
from config import BOT_COLOR from cogs.music import Music, LavalinkVoiceClient
from custom_source import CustomSource from config import BOT_COLOR, APPLE_MUSIC_KEY
from custom_sources import SpotifySource, AppleSource
url_rx = re.compile(r"https?://(?:www\.)?.+") url_rx = re.compile(r"https?://(?:www\.)?.+")
apple_headers = {
"Authorization": f"Bearer {APPLE_MUSIC_KEY}",
"Origin": "https://apple.com",
}
class Play(commands.Cog): class Play(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
@ -26,7 +30,7 @@ class Play(commands.Cog):
"Play a song from your favorite music provider" "Play a song from your favorite music provider"
player = self.bot.lavalink.player_manager.get(interaction.guild.id) player = self.bot.lavalink.player_manager.get(interaction.guild.id)
# Notify users that YouTube and Apple Music links are not allowed # Notify users that YouTube links are not allowed
if "youtube.com" in query or "youtu.be" in query: if "youtube.com" in query or "youtu.be" in query:
embed = discord.Embed( embed = discord.Embed(
@ -36,18 +40,107 @@ class Play(commands.Cog):
) )
return await interaction.response.send_message(embed=embed, ephemeral=True) return await interaction.response.send_message(embed=embed, ephemeral=True)
if "music.apple.com" in query: ###
embed = discord.Embed( ### APPLE MUSIC links, perform API requests and load all tracks from the playlist/album/track
title="Apple Music Not Supported", ###
description="Unfortunately, Apple Music does not allow bots to stream from their platform. Try sending a link for a different platform, or simply type the name of the song and I will automatically find it on a supported platform.",
color=BOT_COLOR,
)
return await interaction.response.send_message(embed=embed, ephemeral=True)
# If a Spotify link is found, act accordingly if "music.apple.com" in query:
# We use a custom source for this (I tried the LavaSrc plugin, but Spotify embed = discord.Embed(color=BOT_COLOR)
# links would just result in random shit being played whenever ytsearch was removed)
if "open.spotify.com" in query: if "/playlist/" in query and "?i=" not in query:
playlist_id = query.split("/playlist/")[1].split("/")[1]
# Get all of the tracks in the playlist (limit at 250)
playlist_url = f"https://api.music.apple.com/v1/catalog/us/playlists/{playlist_id}/tracks?limit=100"
response = requests.get(playlist_url, headers=apple_headers)
if response.status_code == 200:
playlist = response.json()
# Get the general playlist info (name, artwork)
playlist_info_url = f"https://api.music.apple.com/v1/catalog/us/playlists/{playlist_id}"
playlist_info = requests.get(playlist_info_url, headers=apple_headers)
playlist_info = playlist_info.json()
artwork_url = playlist_info["data"][0]["attributes"]["artwork"]["url"].replace(
"{w}x{h}", "300x300"
)
embed.title = "Playlist Queued"
embed.description = f"**{playlist_info['data'][0]['attributes']['name']}**\n` {len(playlist['data'])} ` tracks\n\nQueued by: {interaction.user.mention}"
embed.set_thumbnail(url=artwork_url)
embed.set_footer(
text=datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
+ " UTC"
)
# Add small alert if the playlist is the max size
if len(playlist["data"]) == 100:
embed.description += "\n\n*This playlist is longer than the 100 song maximum. Only the first 100 songs will be queued.*"
await interaction.response.send_message(embed=embed)
tracks = await AppleSource.load_playlist(
self, interaction.user, playlist
)
for track in tracks["tracks"]:
player.add(requester=interaction.user, track=track)
# If there is an album, not a specific song within the album
if "/album/" in query and "?i=" not in query:
album_id = query.split("/album/")[1].split("/")[1]
album_url = f"https://api.music.apple.com/v1/catalog/us/albums/{album_id}"
response = requests.get(album_url, headers=apple_headers)
if response.status_code == 200:
album = response.json()
embed.title = "Album Queued"
embed.description = f"**{album['data'][0]['attributes']['name']}** by **{album['data'][0]['attributes']['artistName']}**\n` {len(album['data'][0]['relationships']['tracks']['data'])} ` tracks\n\nQueued by: {interaction.user.mention}"
embed.set_thumbnail(
url=album["data"][0]["attributes"]["artwork"]["url"].replace(
"{w}x{h}", "300x300"
)
)
embed.set_footer(
text=datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
+ " UTC"
)
await interaction.response.send_message(embed=embed)
tracks = await AppleSource.load_album(self, interaction.user, album)
for track in tracks["tracks"]:
player.add(requester=interaction.user, track=track)
# If there is a specific song
if "/album/" in query and "?i=" in query:
song_id = query.split("/album/")[1].split("?i=")[1]
song_url = f"https://api.music.apple.com/v1/catalog/us/songs/{song_id}"
response = requests.get(song_url, headers=apple_headers)
if response.status_code == 200:
song = response.json()
embed.title = "Song Queued"
embed.description = f"**{song['data'][0]['attributes']['name']}** by **{song['data'][0]['attributes']['artistName']}**\n\nQueued by: {interaction.user.mention}"
embed.set_thumbnail(
url=song["data"][0]["attributes"]["artwork"]["url"].replace(
"{w}x{h}", "300x300"
)
)
embed.set_footer(
text=datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
+ " UTC"
)
await interaction.response.send_message(embed=embed)
results = await AppleSource.load_item(self, interaction.user, song)
player.add(requester=interaction.user, track=results.tracks[0])
###
### SPOTIFY links, perform API requests and load all tracks from the playlist/album/track
###
elif "open.spotify.com" in query:
embed = discord.Embed(color=BOT_COLOR) embed = discord.Embed(color=BOT_COLOR)
if "open.spotify.com/playlist" in query: if "open.spotify.com/playlist" in query:
@ -68,7 +161,7 @@ class Play(commands.Cog):
) )
await interaction.response.send_message(embed=embed) await interaction.response.send_message(embed=embed)
tracks = await CustomSource.load_playlist( tracks = await SpotifySource.load_playlist(
self, interaction.user, playlist self, interaction.user, playlist
) )
for track in tracks["tracks"]: for track in tracks["tracks"]:
@ -92,7 +185,7 @@ class Play(commands.Cog):
) )
await interaction.response.send_message(embed=embed) await interaction.response.send_message(embed=embed)
tracks = await CustomSource.load_album( tracks = await SpotifySource.load_album(
self, interaction.user, album self, interaction.user, album
github-advanced-security[bot] commented 2024-07-19 00:28:54 -05:00 (Migrated from github.com)
Review

Incomplete URL substring sanitization

The string open.spotify.com may be at an arbitrary position in the sanitized URL.

Show more details

## Incomplete URL substring sanitization The string [open.spotify.com](1) may be at an arbitrary position in the sanitized URL. [Show more details](https://github.com/PacketParker/Guava/security/code-scanning/5)
) )
for track in tracks["tracks"]: for track in tracks["tracks"]:
@ -116,7 +209,7 @@ class Play(commands.Cog):
) )
await interaction.response.send_message(embed=embed) await interaction.response.send_message(embed=embed)
results = await CustomSource.load_item( results = await SpotifySource.load_item(
self, interaction.user, track self, interaction.user, track
) )
player.add(requester=interaction.user, track=results.tracks[0]) player.add(requester=interaction.user, track=results.tracks[0])
@ -128,7 +221,9 @@ class Play(commands.Cog):
embed=embed, ephemeral=True embed=embed, ephemeral=True
) )
# For anything else, do default searches and attempt to find track and play ###
### For anything else, use default Lavalink providers to search the query
###
else: else:
if not url_rx.match(query): if not url_rx.match(query):

View File

@ -6,7 +6,7 @@ from cogs.music import Music
import asyncio import asyncio
from config import BOT_COLOR from config import BOT_COLOR
from custom_source import LoadError from custom_sources import LoadError
class Skip(commands.Cog): class Skip(commands.Cog):

View File

@ -31,6 +31,7 @@ FEEDBACK_CHANNEL_ID = None
BUG_CHANNEL_ID = None BUG_CHANNEL_ID = None
SPOTIFY_CLIENT_ID = None SPOTIFY_CLIENT_ID = None
SPOTIFY_CLIENT_SECRET = None SPOTIFY_CLIENT_SECRET = None
APPLE_MUSIC_KEY = None
OPENAI_API_KEY = None OPENAI_API_KEY = None
LAVALINK_HOST = None LAVALINK_HOST = None
LAVALINK_PORT = None LAVALINK_PORT = None
@ -70,6 +71,10 @@ def load_config():
"SPOTIFY_CLIENT_SECRET": "", "SPOTIFY_CLIENT_SECRET": "",
} }
config["APPLE_MUSIC"] = {
"APPLE_MUSIC_KEY": "",
}
config["OPENAI"] = { config["OPENAI"] = {
"OPENAI_API_KEY": "", "OPENAI_API_KEY": "",
} }
@ -96,7 +101,7 @@ Validate all of the options in the config.ini file.
def validate_config(file_contents): def validate_config(file_contents):
global TOKEN, BOT_COLOR, BOT_INVITE_LINK, FEEDBACK_CHANNEL_ID, BUG_CHANNEL_ID, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, OPENAI_API_KEY, LAVALINK_HOST, LAVALINK_PORT, LAVALINK_PASSWORD global TOKEN, BOT_COLOR, BOT_INVITE_LINK, FEEDBACK_CHANNEL_ID, BUG_CHANNEL_ID, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, APPLE_MUSIC_KEY, OPENAI_API_KEY, LAVALINK_HOST, LAVALINK_PORT, LAVALINK_PASSWORD
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read_string(file_contents) config.read_string(file_contents)
@ -106,7 +111,7 @@ def validate_config(file_contents):
errors = 0 errors = 0
# Make sure all sections are present # Make sure all sections are present
if ["BOT_INFO", "SPOTIFY", "OPENAI", "LAVALINK"] != config.sections(): if ["BOT_INFO", "SPOTIFY", "APPLE_MUSIC", "OPENAI", "LAVALINK"] != config.sections():
sys.exit( sys.exit(
LOG.critical( LOG.critical(
"Missing sections in config.ini file. Delete the file and re-run the bot to generate a blank config.ini file." "Missing sections in config.ini file. Delete the file and re-run the bot to generate a blank config.ini file."
@ -127,6 +132,13 @@ def validate_config(file_contents):
) )
) )
if ["apple_music_key"] != config.options("APPLE_MUSIC"):
sys.exit(
LOG.critical(
"Missing options in APPLE_MUSIC section of config.ini file. Delete the file and re-run the bot to generate a blank config.ini file."
)
)
if ["openai_api_key"] != config.options("OPENAI"): if ["openai_api_key"] != config.options("OPENAI"):
sys.exit( sys.exit(
LOG.critical( LOG.critical(
@ -175,6 +187,7 @@ def validate_config(file_contents):
TOKEN = config["BOT_INFO"]["TOKEN"] TOKEN = config["BOT_INFO"]["TOKEN"]
SPOTIFY_CLIENT_ID = config["SPOTIFY"]["SPOTIFY_CLIENT_ID"] SPOTIFY_CLIENT_ID = config["SPOTIFY"]["SPOTIFY_CLIENT_ID"]
SPOTIFY_CLIENT_SECRET = config["SPOTIFY"]["SPOTIFY_CLIENT_SECRET"] SPOTIFY_CLIENT_SECRET = config["SPOTIFY"]["SPOTIFY_CLIENT_SECRET"]
APPLE_MUSIC_KEY = config["APPLE_MUSIC"]["APPLE_MUSIC_KEY"]
OPENAI_API_KEY = config["OPENAI"]["OPENAI_API_KEY"] OPENAI_API_KEY = config["OPENAI"]["OPENAI_API_KEY"]
LAVALINK_HOST = config["LAVALINK"]["HOST"] LAVALINK_HOST = config["LAVALINK"]["HOST"]
LAVALINK_PORT = config["LAVALINK"]["PORT"] LAVALINK_PORT = config["LAVALINK"]["PORT"]
@ -194,7 +207,7 @@ Validate all of the environment variables.
def validate_env_vars(): def validate_env_vars():
global TOKEN, BOT_COLOR, BOT_INVITE_LINK, FEEDBACK_CHANNEL_ID, BUG_CHANNEL_ID, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, OPENAI_API_KEY, LAVALINK_HOST, LAVALINK_PORT, LAVALINK_PASSWORD global TOKEN, BOT_COLOR, BOT_INVITE_LINK, FEEDBACK_CHANNEL_ID, BUG_CHANNEL_ID, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, APPLE_MUSIC_KEY, OPENAI_API_KEY, LAVALINK_HOST, LAVALINK_PORT, LAVALINK_PASSWORD
hex_pattern_one = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" hex_pattern_one = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
hex_pattern_two = "^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" hex_pattern_two = "^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
@ -202,7 +215,7 @@ def validate_env_vars():
errors = 0 errors = 0
# Make sure all required variables are present in the environment # Make sure all required variables are present in the environment
required_vars = ["TOKEN", "BOT_COLOR", "BOT_INVITE_LINK", "SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET", "OPENAI_API_KEY", "LAVALINK_HOST", "LAVALINK_PORT", "LAVALINK_PASSWORD"] required_vars = ["TOKEN", "BOT_COLOR", "BOT_INVITE_LINK", "SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET", "APPLE_MUSIC_KEY", "OPENAI_API_KEY", "LAVALINK_HOST", "LAVALINK_PORT", "LAVALINK_PASSWORD"]
for var in required_vars: for var in required_vars:
if var not in os.environ: if var not in os.environ:
@ -254,6 +267,7 @@ def validate_env_vars():
TOKEN = os.environ["TOKEN"] TOKEN = os.environ["TOKEN"]
SPOTIFY_CLIENT_ID = os.environ["SPOTIFY_CLIENT_ID"] SPOTIFY_CLIENT_ID = os.environ["SPOTIFY_CLIENT_ID"]
SPOTIFY_CLIENT_SECRET = os.environ["SPOTIFY_CLIENT_SECRET"] SPOTIFY_CLIENT_SECRET = os.environ["SPOTIFY_CLIENT_SECRET"]
APPLE_MUSIC_KEY = os.environ["APPLE_MUSIC_KEY"]
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
LAVALINK_HOST = os.environ["LAVALINK_HOST"] LAVALINK_HOST = os.environ["LAVALINK_HOST"]
LAVALINK_PORT = os.environ["LAVALINK_PORT"] LAVALINK_PORT = os.environ["LAVALINK_PORT"]

View File

@ -5,6 +5,11 @@ class LoadError(Exception): # We'll raise this if we have trouble loading our t
pass pass
"""
Retrieve the playback URL for a custom track
"""
class CustomAudioTrack(DeferredAudioTrack): class CustomAudioTrack(DeferredAudioTrack):
# A DeferredAudioTrack allows us to load metadata now, and a playback URL later. # A DeferredAudioTrack allows us to load metadata now, and a playback URL later.
# This makes the DeferredAudioTrack highly efficient, particularly in cases # This makes the DeferredAudioTrack highly efficient, particularly in cases
@ -36,7 +41,12 @@ class CustomAudioTrack(DeferredAudioTrack):
return base64 return base64
class CustomSource(Source): """
Custom Source for Spotify links
"""
class SpotifySource(Source):
def __init__(self): def __init__(self):
super().__init__( super().__init__(
name="custom" name="custom"
@ -112,3 +122,82 @@ class CustomSource(Source):
) )
return LoadResult(LoadType.PLAYLIST, tracks, playlist_info=PlaylistInfo.none()) return LoadResult(LoadType.PLAYLIST, tracks, playlist_info=PlaylistInfo.none())
"""
Custom Source for Apple Music links
"""
class AppleSource(Source):
def __init__(self):
super().__init__(name="custom")
async def load_item(self, user, metadata):
track = CustomAudioTrack(
{ # Create an instance of our CustomAudioTrack.
"identifier": metadata["data"][0]["id"],
"isSeekable": True,
"author": metadata["data"][0]["attributes"]["artistName"],
"length": metadata["data"][0]["attributes"]["durationInMillis"],
"isStream": False,
"title": metadata["data"][0]["attributes"]["name"],
"uri": metadata["data"][0]["attributes"]["url"],
"duration": metadata["data"][0]["attributes"]["durationInMillis"],
"artworkUrl": metadata["data"][0]["attributes"]["artwork"]["url"].replace(
"{w}x{h}", "300x300"
),
},
requester=user,
)
return LoadResult(LoadType.TRACK, [track], playlist_info=PlaylistInfo.none())
async def load_album(self, user, metadata):
tracks = []
for track in metadata["data"][0]["relationships"]["tracks"][
"data"
]: # Loop through each track in the album.
tracks.append(
CustomAudioTrack(
{ # Create an instance of our CustomAudioTrack.
"identifier": track["id"],
"isSeekable": True,
"author": track["attributes"]["artistName"],
"length": track["attributes"]["durationInMillis"],
"isStream": False,
"title": track["attributes"]["name"],
"uri": track["attributes"]["url"],
"duration": track["attributes"]["durationInMillis"],
"artworkUrl": track["attributes"]["artwork"]["url"].replace(
"{w}x{h}", "300x300"
),
},
requster=user,
)
)
return LoadResult(LoadType.PLAYLIST, tracks, playlist_info=PlaylistInfo.none())
async def load_playlist(self, user, metadata):
tracks = []
for track in metadata["data"]: # Loop through each track in the playlist.
tracks.append(
CustomAudioTrack(
{ # Create an instance of our CustomAudioTrack.
"identifier": track["id"],
"isSeekable": True,
"author": track["attributes"]["artistName"],
"length": track["attributes"]["durationInMillis"],
"isStream": False,
"title": track["attributes"]["name"],
"uri": track["attributes"]["url"],
"duration": track["attributes"]["durationInMillis"],
"artworkUrl": track["attributes"]["artwork"]["url"].replace(
"{w}x{h}", "300x300"
),
},
requster=user,
)
)
return LoadResult(LoadType.PLAYLIST, tracks, playlist_info=PlaylistInfo.none())

View File

@ -5,7 +5,7 @@ import datetime
from cogs.music import CheckPlayerError from cogs.music import CheckPlayerError
from config import BOT_COLOR from config import BOT_COLOR
from custom_source import LoadError from custom_sources import LoadError
class Tree(app_commands.CommandTree): class Tree(app_commands.CommandTree):

View File

@ -10,6 +10,7 @@ services:
- BUG_CHANNEL_ID= - BUG_CHANNEL_ID=
- SPOTIFY_CLIENT_ID= - SPOTIFY_CLIENT_ID=
- SPOTIFY_CLIENT_SECRET= - SPOTIFY_CLIENT_SECRET=
- APPLE_MUSIC_KEY=
- OPENAI_API_KEY= - OPENAI_API_KEY=
- LAVALINK_HOST= - LAVALINK_HOST=
- LAVALINK_PORT= - LAVALINK_PORT=