diff --git a/README.md b/README.md index d0ab795..a1b8da6 100644 --- a/README.md +++ b/README.md @@ -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: -- Spotify (Links) +- Apple Music +- Spotify - SoundCloud - Bandcamp - 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** SPOTIFY_CLIENT_ID | Client ID 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** HOST | Host address for your Lavalink node | **REQUIRED** PORT | Port for your Lavalink node | **REQUIRED** PASSWORD | Password to authenticate into the Lavalink node | **REQUIRED** +
+
+NOTE: Media API Key + +1. Go to https://music.apple.com +2. Open the debuger tab in dev tools +3. Regex this `"(?(ey[\w-]+)\.([\w-]+)\.([\w-]+))"` +4. Copy the entire token from the JS file + +
+
+ # 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. diff --git a/code/bot.py b/code/bot.py index dd0be09..1247e1a 100644 --- a/code/bot.py +++ b/code/bot.py @@ -37,7 +37,7 @@ class MyBot(commands.Bot): async def on_ready(self): 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() diff --git a/code/cogs/news.py b/code/cogs/news.py index d7c25d2..5c0c2e7 100644 --- a/code/cogs/news.py +++ b/code/cogs/news.py @@ -18,6 +18,11 @@ class News(commands.Cog): 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( 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!", diff --git a/code/cogs/play.py b/code/cogs/play.py index 67c3e04..84be2f3 100644 --- a/code/cogs/play.py +++ b/code/cogs/play.py @@ -2,18 +2,22 @@ import discord import datetime from discord import app_commands from discord.ext import commands -from cogs.music import Music from lavalink import LoadType import re -from cogs.music import LavalinkVoiceClient import requests -from config import BOT_COLOR -from custom_source import CustomSource +from cogs.music import Music, LavalinkVoiceClient +from config import BOT_COLOR, APPLE_MUSIC_KEY +from custom_sources import SpotifySource, AppleSource url_rx = re.compile(r"https?://(?:www\.)?.+") +apple_headers = { + "Authorization": f"Bearer {APPLE_MUSIC_KEY}", + "Origin": "https://apple.com", +} + class Play(commands.Cog): def __init__(self, bot): @@ -26,7 +30,7 @@ class Play(commands.Cog): "Play a song from your favorite music provider" 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: embed = discord.Embed( @@ -36,18 +40,107 @@ class Play(commands.Cog): ) return await interaction.response.send_message(embed=embed, ephemeral=True) - if "music.apple.com" in query: - embed = discord.Embed( - 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) + ### + ### APPLE MUSIC links, perform API requests and load all tracks from the playlist/album/track + ### - # If a Spotify link is found, act accordingly - # We use a custom source for this (I tried the LavaSrc plugin, but Spotify - # links would just result in random shit being played whenever ytsearch was removed) - if "open.spotify.com" in query: + if "music.apple.com" in query: + embed = discord.Embed(color=BOT_COLOR) + + 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) if "open.spotify.com/playlist" in query: @@ -68,7 +161,7 @@ class Play(commands.Cog): ) await interaction.response.send_message(embed=embed) - tracks = await CustomSource.load_playlist( + tracks = await SpotifySource.load_playlist( self, interaction.user, playlist ) for track in tracks["tracks"]: @@ -92,7 +185,7 @@ class Play(commands.Cog): ) await interaction.response.send_message(embed=embed) - tracks = await CustomSource.load_album( + tracks = await SpotifySource.load_album( self, interaction.user, album ) for track in tracks["tracks"]: @@ -116,7 +209,7 @@ class Play(commands.Cog): ) await interaction.response.send_message(embed=embed) - results = await CustomSource.load_item( + results = await SpotifySource.load_item( self, interaction.user, track ) player.add(requester=interaction.user, track=results.tracks[0]) @@ -128,7 +221,9 @@ class Play(commands.Cog): 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: if not url_rx.match(query): diff --git a/code/cogs/skip.py b/code/cogs/skip.py index 966a8c2..b3645fb 100644 --- a/code/cogs/skip.py +++ b/code/cogs/skip.py @@ -6,7 +6,7 @@ from cogs.music import Music import asyncio from config import BOT_COLOR -from custom_source import LoadError +from custom_sources import LoadError class Skip(commands.Cog): diff --git a/code/config.py b/code/config.py index 8f47e2f..d411653 100644 --- a/code/config.py +++ b/code/config.py @@ -31,6 +31,7 @@ FEEDBACK_CHANNEL_ID = None BUG_CHANNEL_ID = None SPOTIFY_CLIENT_ID = None SPOTIFY_CLIENT_SECRET = None +APPLE_MUSIC_KEY = None OPENAI_API_KEY = None LAVALINK_HOST = None LAVALINK_PORT = None @@ -70,6 +71,10 @@ def load_config(): "SPOTIFY_CLIENT_SECRET": "", } + config["APPLE_MUSIC"] = { + "APPLE_MUSIC_KEY": "", + } + config["OPENAI"] = { "OPENAI_API_KEY": "", } @@ -96,7 +101,7 @@ Validate all of the options in the config.ini file. 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.read_string(file_contents) @@ -106,7 +111,7 @@ def validate_config(file_contents): errors = 0 # 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( LOG.critical( "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"): sys.exit( LOG.critical( @@ -175,6 +187,7 @@ def validate_config(file_contents): TOKEN = config["BOT_INFO"]["TOKEN"] SPOTIFY_CLIENT_ID = config["SPOTIFY"]["SPOTIFY_CLIENT_ID"] 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"] LAVALINK_HOST = config["LAVALINK"]["HOST"] LAVALINK_PORT = config["LAVALINK"]["PORT"] @@ -194,7 +207,7 @@ Validate all of the environment variables. 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_two = "^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" @@ -202,7 +215,7 @@ def validate_env_vars(): errors = 0 # 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: if var not in os.environ: @@ -254,6 +267,7 @@ def validate_env_vars(): TOKEN = os.environ["TOKEN"] SPOTIFY_CLIENT_ID = os.environ["SPOTIFY_CLIENT_ID"] SPOTIFY_CLIENT_SECRET = os.environ["SPOTIFY_CLIENT_SECRET"] + APPLE_MUSIC_KEY = os.environ["APPLE_MUSIC_KEY"] OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] LAVALINK_HOST = os.environ["LAVALINK_HOST"] LAVALINK_PORT = os.environ["LAVALINK_PORT"] diff --git a/code/custom_source.py b/code/custom_sources.py similarity index 57% rename from code/custom_source.py rename to code/custom_sources.py index 8ed2091..96ae3a9 100644 --- a/code/custom_source.py +++ b/code/custom_sources.py @@ -5,6 +5,11 @@ class LoadError(Exception): # We'll raise this if we have trouble loading our t pass +""" +Retrieve the playback URL for a custom track +""" + + class CustomAudioTrack(DeferredAudioTrack): # A DeferredAudioTrack allows us to load metadata now, and a playback URL later. # This makes the DeferredAudioTrack highly efficient, particularly in cases @@ -36,7 +41,12 @@ class CustomAudioTrack(DeferredAudioTrack): return base64 -class CustomSource(Source): +""" +Custom Source for Spotify links +""" + + +class SpotifySource(Source): def __init__(self): super().__init__( name="custom" @@ -112,3 +122,82 @@ class CustomSource(Source): ) 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()) diff --git a/code/tree.py b/code/tree.py index a16f126..eff83b8 100644 --- a/code/tree.py +++ b/code/tree.py @@ -5,7 +5,7 @@ import datetime from cogs.music import CheckPlayerError from config import BOT_COLOR -from custom_source import LoadError +from custom_sources import LoadError class Tree(app_commands.CommandTree): diff --git a/docker-compose.yaml b/docker-compose.yaml index 69953c1..0446ada 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,7 @@ services: - BUG_CHANNEL_ID= - SPOTIFY_CLIENT_ID= - SPOTIFY_CLIENT_SECRET= + - APPLE_MUSIC_KEY= - OPENAI_API_KEY= - LAVALINK_HOST= - LAVALINK_PORT=