From 9ea2c4b6efdba3b4a9c6ee24216766d6ad6ec2ef Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 02:24:17 +0100 Subject: [PATCH 01/17] Abandon Guilded Migrated to `py-cord`. Including: - hints, - exceptions, - improved embeds, - better project structure, - updated invitation link. --- .vscode/launch.json | 4 +- README.md | 25 +--- Spoyt/Discord/__init__.py | 0 Spoyt/Discord/__main__.py | 28 ---- Spoyt/Guilded/__init__.py | 0 Spoyt/Guilded/__main__.py | 19 --- Spoyt/__main__.py | 101 +++++++++++++- Spoyt/{spotify_api.py => api/spotify.py} | 36 +++-- Spoyt/api/youtube.py | 48 +++++++ Spoyt/embeds.py | 150 ++++++++++++++++++++ Spoyt/embeds/__init__.py | 0 Spoyt/embeds/color.py | 17 --- Spoyt/embeds/core.py | 27 ---- Spoyt/embeds/definitions.py | 167 ----------------------- Spoyt/env_check.py | 31 ----- Spoyt/exceptions.py | 30 ++++ Spoyt/settings.py | 10 +- Spoyt/types.py | 15 -- Spoyt/utils.py | 24 ++++ Spoyt/wrapper.py | 120 ---------------- Spoyt/youtube_api.py | 68 --------- requirements.txt | 3 +- 22 files changed, 395 insertions(+), 528 deletions(-) delete mode 100644 Spoyt/Discord/__init__.py delete mode 100644 Spoyt/Discord/__main__.py delete mode 100644 Spoyt/Guilded/__init__.py delete mode 100644 Spoyt/Guilded/__main__.py rename Spoyt/{spotify_api.py => api/spotify.py} (67%) create mode 100644 Spoyt/api/youtube.py create mode 100644 Spoyt/embeds.py delete mode 100644 Spoyt/embeds/__init__.py delete mode 100644 Spoyt/embeds/color.py delete mode 100644 Spoyt/embeds/core.py delete mode 100644 Spoyt/embeds/definitions.py delete mode 100644 Spoyt/env_check.py create mode 100644 Spoyt/exceptions.py delete mode 100644 Spoyt/types.py create mode 100644 Spoyt/utils.py delete mode 100644 Spoyt/wrapper.py delete mode 100644 Spoyt/youtube_api.py diff --git a/.vscode/launch.json b/.vscode/launch.json index ba3647c..0786102 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,10 +5,10 @@ "version": "0.2.0", "configurations": [ { - "name": "Python: Spoyt Discord", + "name": "Python: Spoyt", "type": "python", "request": "launch", - "module": "Spoyt.Discord", + "module": "Spoyt", "console": "integratedTerminal", "justMyCode": true } diff --git a/README.md b/README.md index a942fba..98d6f14 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,18 @@ # Spoyt -Spotify to YouTube; Discord and Guilded link converter. +Discord bot that allows you to "convert" Spotify links to YouTube videos. ## Usage -Just send a message with share link from Spotify. Bot will automatically find -the track in Spotify database, and search its name and artists in YouTube. -If possible, it will try to delete your message, but you can disable it -by permitting permissions. +1. Invite the bot with this link: . -Invite the bot by one of following links: -- Discord: https://discord.com/api/oauth2/authorize?client_id=948274806325903410&permissions=3072&scope=bot -- Guilded: https://www.guilded.gg/b/93177486-3a1d-4464-a202-1ddd6354844b +1. Use `/track` or `/playlist` command to search. Bot will try to find the track or playlist (respectively) using Spotify API, and search it in YouTube (also using API). -## Support - -You can join one of my servers (or both): +### Note -- Discord: [discord.gg/SRdmrPpf2z](https://discord.gg/SRdmrPpf2z) -- Guilded: [guilded.gg/Anonymous-Canteen](https://guilded.gg/Anonymous-Canteen) +YouTube searching currently applies only to `/track`. I'm currently inspecting YouTube API limiations. -## How to run +## Support -Make sure you have Python `>=3.8` installed. -``` -[py|python|python3] -(O|OO)m Spoyt.[Discord|Guilded] -``` +You can join my server: . diff --git a/Spoyt/Discord/__init__.py b/Spoyt/Discord/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/Spoyt/Discord/__main__.py b/Spoyt/Discord/__main__.py deleted file mode 100644 index e28e560..0000000 --- a/Spoyt/Discord/__main__.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from logging import INFO, basicConfig - -from discord import Activity, ActivityType, Client, Intents -from rich.logging import RichHandler - -from Spoyt.logging import log -from Spoyt.wrapper import main - -if __name__ == '__main__': - basicConfig( - level=INFO, - format='%(message)s', - datefmt='[%x]', - handlers=[RichHandler(rich_tracebacks=True)] - ) - log.info('Starting Discord bot') - intents = Intents.default() - intents.message_content = True - client = Client( - max_messages=None, - intents=intents, - activity=Activity( - name='Spotify & YouTube', - type=ActivityType.listening - ) - ) - main(client, __package__) diff --git a/Spoyt/Guilded/__init__.py b/Spoyt/Guilded/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/Spoyt/Guilded/__main__.py b/Spoyt/Guilded/__main__.py deleted file mode 100644 index 230e903..0000000 --- a/Spoyt/Guilded/__main__.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from logging import INFO, basicConfig - -from guilded import Client -from rich.logging import RichHandler - -from Spoyt.logging import log -from Spoyt.wrapper import main - -if __name__ == '__main__': - basicConfig( - level=INFO, - format='%(message)s', - datefmt='[%x]', - handlers=[RichHandler(rich_tracebacks=True)] - ) - log.info('Starting Guilded bot') - client = Client() - main(client, __package__) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index a9aea03..07cdc2e 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -1,9 +1,19 @@ # -*- coding: utf-8 -*- from logging import INFO, basicConfig +from discord import ApplicationContext, Bot, Option +from discord.ext.commands import Cooldown from rich.logging import RichHandler +from Spoyt.api.spotify import search_track, search_playlist, url_to_id +from Spoyt.api.youtube import search_video +from Spoyt.embeds import ErrorEmbed, IncorrectInputEmbed, SpotifyTrackEmbed, \ + SpotifyPlaylistEmbed, SpotifyUnreachableEmbed, YouTubeVideoEmbed, \ + UnderCunstructionEmbed +from Spoyt.exceptions import SpotifyUnreachableException, YouTubeException from Spoyt.logging import log +from Spoyt.settings import BOT_TOKEN +from Spoyt.utils import check_env if __name__ == '__main__': basicConfig( @@ -12,6 +22,91 @@ datefmt='[%x]', handlers=[RichHandler(rich_tracebacks=True)] ) - log.info('This is general Spoyt module.') - log.info('To run specific bot please run "Discord" or "Guilded" module.') - log.info('Remember to set "BOT_TOKEN" environment variables.') + if not check_env(): + log.critical('Aborting start') + exit() + + log.info('Starting Discord bot') + + bot = Bot() + + @bot.event + async def on_ready() -> None: + log.info(f'Logged in as "{bot.user}"') + + @bot.slash_command( + name='track', + description='Search for a track', + cooldown=Cooldown( + rate=1, + per=5.0 + ), + ) + async def track( + ctx: ApplicationContext, + url: Option( + input_type=str, + name='URL', + description='Starts with "https://open.spotify.com/track/..."', + required=True + ) + ) -> None: + if not url.startswith('https://open.spotify.com/track/'): + await ctx.respond(embed=IncorrectInputEmbed) + return + + track_id = url_to_id(url) + try: + track = search_track(track_id) + except SpotifyUnreachableException: + await ctx.respond(embed=SpotifyUnreachableEmbed) + return + + await ctx.respond(embed=SpotifyTrackEmbed(track)) + + youtube_query = '{} {}'.format(track.name, ' '.join(track.artists)) + try: + youtube_result = search_video(youtube_query) + except YouTubeException as e: + await ctx.channel.send(embed=ErrorEmbed( + description=f'```diff\n- {e}\n```' + )) + return + await ctx.channel.send(embed=YouTubeVideoEmbed(youtube_result)) + + log.info(f'Successfully converted "{track.name}" track') + + @bot.slash_command( + name='playlist', + description='Search for a playlist', + cooldown=Cooldown( + rate=1, + per=30.0 + ), + ) + async def playlist( + ctx: ApplicationContext, + url: Option( + input_type=str, + name='URL', + description='Starts with "https://open.spotify.com/playlist/..."', + required=True + ) + ) -> None: + if not url.startswith('https://open.spotify.com/playlist/'): + await ctx.respond(embed=IncorrectInputEmbed) + return + + playlist_id = url_to_id(url) + try: + playlist = search_playlist(playlist_id) + except SpotifyUnreachableException: + await ctx.respond(embed=SpotifyUnreachableEmbed) + return + + await ctx.respond(embed=SpotifyPlaylistEmbed(playlist)) + + await ctx.channel.send(embed=UnderCunstructionEmbed) + log.info('Playlist conversion issued.') + + bot.start(BOT_TOKEN) diff --git a/Spoyt/spotify_api.py b/Spoyt/api/spotify.py similarity index 67% rename from Spoyt/spotify_api.py rename to Spoyt/api/spotify.py index 2e6895e..27caf1d 100644 --- a/Spoyt/spotify_api.py +++ b/Spoyt/api/spotify.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from os import getenv - from spotipy import Spotify, SpotifyClientCredentials +from Spoyt.exceptions import SpotifyUnreachableException from Spoyt.logging import log +from Spoyt.settings import SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET class Track: @@ -41,7 +41,7 @@ def __init__(self, payload: dict) -> None: self.query_limit: int = payload.get('tracks', {}).get('limit') @property - def playlist_url(self) -> str: + def url(self) -> str: return f'https://open.spotify.com/playlist/{self.playlist_id}' @property @@ -53,22 +53,40 @@ def is_query_limited(self) -> bool: return len(self.tracks) == self.query_limit +def url_to_id(url: str) -> str: + """ + Removes trailing parameters like share source, then extractd ID. + + For example this: "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT?si=8a1b522f00744ee1", + becomes: "4cOdK2wGLETKBW3PvgPWqT". + """ + return url.split('?')[0].split('&')[0].split('/')[-1] + + def spotify_connect() -> Spotify: return Spotify( auth_manager=SpotifyClientCredentials( - client_id=getenv('SPOTIFY_CLIENT_ID'), - client_secret=getenv('SPOTIFY_CLIENT_SECRET') + client_id=SPOTIFY_CLIENT_ID, + client_secret=SPOTIFY_CLIENT_SECRET ) ) # Search functions should not return `class Track` or `class Playlist` # because of checks if connections was successful during runtime. -def search_track(track_id: str) -> dict: +def search_track(track_id: str) -> Track: log.info(f'Searching track by ID "{track_id}"') - return spotify_connect().track(track_id=track_id) + track: dict | None = spotify_connect().track(track_id=track_id) + if not track: + log.error('Spotify unreachable') + raise SpotifyUnreachableException + return Track(track) -def search_playlist(playlist_id: str) -> dict: +def search_playlist(playlist_id: str) -> Playlist: log.info(f'Searching playlist by ID "{playlist_id}"') - return spotify_connect().playlist(playlist_id=playlist_id) + playlist: dict | None = spotify_connect().playlist(playlist_id=playlist_id) + if not playlist: + log.error('Spotify unreachable') + raise SpotifyUnreachableException + return Playlist(playlist) diff --git a/Spoyt/api/youtube.py b/Spoyt/api/youtube.py new file mode 100644 index 0000000..30a64b8 --- /dev/null +++ b/Spoyt/api/youtube.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from json import loads as json_loads + +from requests import get as requests_get + +from Spoyt.exceptions import YouTubeException, YouTubeForbiddenException +from Spoyt.logging import log +from Spoyt.settings import YOUTUBE_API_KEY + + +class YouTubeVideo: + def __init__(self, payload: dict) -> None: + item: dict = payload.get('items', [{}])[0] + snippet: dict = item.get('snippet', {}) + self.video_id: str = item.get('id', {}).get('videoId') + self.title: str = snippet.get('title') + self.description: str = snippet.get('description') + self.published_date: str = snippet.get('publishTime', '')[:10] + + @property + def video_link(self) -> str: + return f'https://www.youtube.com/watch?v={self.video_id}' + + @property + def video_thumbnail(self) -> str: + return f'https://i.ytimg.com/vi/{self.video_id}/default.jpg' + + +def search_video(query: str) -> YouTubeVideo: + log.info(f'Searching YouTube: "{query}"') + yt_r = requests_get( + 'https://www.googleapis.com/youtube/v3/search' + '?key={}' + '&part=snippet' + '&maxResults=1' + '&q={}'.format(YOUTUBE_API_KEY, query) + ) + content = json_loads(yt_r.content) + if (error_code := yt_r.status_code) == 200: + video = YouTubeVideo(content) + log.info(f'Found YouTube video "{video.title}" ({video.video_link})') + elif error_code == 403: + log.critical(content['error']['message']) + raise YouTubeForbiddenException + else: + log.error(content['error']['message']) + raise YouTubeException + return video diff --git a/Spoyt/embeds.py b/Spoyt/embeds.py new file mode 100644 index 0000000..708316a --- /dev/null +++ b/Spoyt/embeds.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +from discord import Embed, Color + +from Spoyt.api.spotify import Playlist, Track +from Spoyt.api.youtube import YouTubeVideo +from Spoyt.settings import MAX_QUERY +from Spoyt.utils import markdown_url + +class BaseEmbed(Embed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.color = Color.blurple() + +# Searching + +class SearchingEmbed(BaseEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = '\u23f3 Searching platform' + + +class SearchingSpotify(SearchingEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = '\u23f3 Searching Spotify' + + +class SearchingYouTube(SearchingEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = '\u23f3 Searching YouTube' + +# Unreachable + +class UnreachableEmbed(BaseEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Oh no' + self.description = 'Platform is out of service.' + self.color = Color.red() + + +class SpotifyUnreachableEmbed(UnreachableEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.description = 'Spotify is out of service.' + + +class YouTubeUnreachableEmbed(UnreachableEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.description = 'YouTube is out of service.' + +# Not found + +class NotFoundEmbed(BaseEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Content not found' + self.color = Color.red() + + +class VideoNotFound(NotFoundEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Video not found' + +# Other errors + +class ErrorEmbed(BaseEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'There was an error' + self.color = Color.red() + +class IncorrectInputEmbed(ErrorEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.description = 'Your input is incorrect.' + +# Other embeds, with no errors + +class SpotifyTrackEmbed(BaseEmbed): + def __init__(self, track: Track, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = track.name + self.description = markdown_url(track.track_url) + self.color = Color.green() + self.set_thumbnail(url=track.cover_url) + self.add_field( + name='Artist{}'.format('' if track.is_single_artist else 's'), + value=', '.join(track.artists), + inline=track.is_single_artist + ) + self.add_field( + name='Released', + value=track.release_date + ) + + +class SpotifyPlaylistEmbed(BaseEmbed): + def __init__(self, playlist: Playlist, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + if (d := playlist.description): + description = f'{d}\n\n{playlist.url}' + else: + description = playlist.url + self.title = playlist.name + self.description = description + self.color = Color.green() + self.set_thumbnail(url=playlist.cover_url) + self.add_field( + name='Owner', + value=f'[{playlist.owner_name}]({playlist.owner_url})', + inline=False + ) + first_tracks = '\n'.join(map( + lambda a: f'- {markdown_url(a.track_url, a.name)}', + playlist.tracks[:MAX_QUERY] + )) + if (tr := playlist.total_tracks) > MAX_QUERY: + first_tracks += f'\nAnd {tr - MAX_QUERY} more.' + self.add_field( + name='Tracks', + value=first_tracks + ) + + +class YouTubeVideoEmbed(BaseEmbed): + def __init__(self, video: YouTubeVideo, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title=video.title + self.description=markdown_url(video.video_link) + self.set_thumbnail(url=video.video_thumbnail) + self.add_field( + name='Description', + value=video.description, + inline=False + ) + self.add_field( + name='Published', + value=video.published_date + ) + + +class UnderCunstructionEmbed(BaseEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Function under construction' + self.color = Color.gold() diff --git a/Spoyt/embeds/__init__.py b/Spoyt/embeds/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/Spoyt/embeds/color.py b/Spoyt/embeds/color.py deleted file mode 100644 index 323e491..0000000 --- a/Spoyt/embeds/color.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -from discord import Color as DiscordColor -from guilded import Color as GuildedColor - -from Spoyt.env_check import is_discord, is_guilded - -if is_discord(): - DEFAULT = DiscordColor.blurple() -elif is_guilded(): - DEFAULT = GuildedColor.gilded() -else: - DEFAULT = 0xCCCCCC - -GREEN = 0x2ECC71 -RED = 0xFF0000 -DARK_RED = 0x992D22 -GOLD = 0xFFCC00 diff --git a/Spoyt/embeds/core.py b/Spoyt/embeds/core.py deleted file mode 100644 index a18d471..0000000 --- a/Spoyt/embeds/core.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from Spoyt.embeds.definitions import BaseDiscordEmbed, BaseGuildedEmbed, EmbedDict -from Spoyt.env_check import current_platform, is_discord, is_guilded -from Spoyt.logging import log - - -def create_embed(data: EmbedDict) -> BaseDiscordEmbed or BaseGuildedEmbed: - embed = BaseDiscordEmbed if is_discord() else BaseGuildedEmbed if is_guilded else None - if type(embed) is None: - log.critical(f'Platform is "{current_platform()}" which is not good.') - return - - embed = embed( - title=data.title, - description=data.description, - color=data.color - ) - for field in data.fields: - embed.add_field( - name=field.name, - value=field.value, - inline=field.inline - ) - if url := data.thumbnail_url: - embed.set_thumbnail(url=url) - - return embed diff --git a/Spoyt/embeds/definitions.py b/Spoyt/embeds/definitions.py deleted file mode 100644 index 792771f..0000000 --- a/Spoyt/embeds/definitions.py +++ /dev/null @@ -1,167 +0,0 @@ -# -*- coding: utf-8 -*- -from discord import Embed as DiscordEmbed, Color as DiscordColor -from guilded import Embed as GuiledEmbed, Color as GuildedColor - -from Spoyt.embeds.color import DEFAULT, DARK_RED, GREEN, RED, GOLD -from Spoyt.env_check import is_discord, is_guilded -from Spoyt.spotify_api import Playlist, Track -from Spoyt.youtube_api import YouTubeResult - - -MAX_QUERY = 10 - - -class BaseDiscordEmbed(DiscordEmbed): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - if 'color' not in kwargs.keys(): - self.color = DiscordColor.blurple() - - -class BaseGuildedEmbed(GuiledEmbed): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - if 'color' not in kwargs.keys(): - self.color = GuildedColor.gilded() - - -class EmbedField: - def __init__( - self, - name: str, - value: str, - inline: bool = True - ) -> None: - self.name = name - self.value = value - self.inline = inline - - -class EmbedDict: - def __init__( - self, - title: str = None, - description: str = None, - color: int = DEFAULT, - thumbnail_url: str = None - ) -> None: - self.title = title - self.description = description - self.color = color - self._fields = [] - self.thumbnail_url = thumbnail_url - - def add_field(self, name: str, value: str, inline: bool = True) -> None: - self._fields.append(EmbedField(name, value, inline)) - - @property - def fields(self) -> list[EmbedField]: - return self._fields.copy() - - -def markdown_url(url: str) -> str: - return ( - '<{0}>' if is_discord() else - '[{0}]({0})' if is_guilded() else - '{0}' - ).format(url) - - -def track_to_embed(track: Track) -> EmbedDict: - em = EmbedDict( - title=track.name, - description=markdown_url(track.track_url), - color=GREEN, - thumbnail_url=track.cover_url - ) - em.add_field( - name='Artist{}'.format('' if track.is_single_artist else 's'), - value=', '.join(track.artists), - inline=track.is_single_artist - ) - em.add_field( - name='Released', - value=track.release_date - ) - return em - - -def playlist_to_embed(playlist: Playlist) -> EmbedDict: - if (d := playlist.description): - description = f'{d}\n\n{playlist.url}' - else: - description = playlist.playlist_url - em = EmbedDict( - title=playlist.name, - description=description, - color=GREEN, - thumbnail_url=playlist.cover_url - ) - em.add_field( - name='Owner', - value=f'[{playlist.owner_name}]({playlist.owner_url})', - inline=False - ) - first_tracks = '\n'.join(map( - lambda a: f'- [{a.name}]({a.track_url})', - playlist.tracks[:MAX_QUERY] - )) - if (tr := playlist.total_tracks) > MAX_QUERY: - first_tracks += f'\nAnd {tr - MAX_QUERY} more.' - em.add_field( - name='Tracks', - value=first_tracks - ) - return em - - -def video_to_embed(video: YouTubeResult) -> EmbedDict: - return EmbedDict( - title=video.title, - description=markdown_url(video.video_link), - fields=[ - EmbedField( - name='Description', - value=video.description, - inline=False - ), - EmbedField( - name='Published', - value=video.published_date - ) - ], - thumbnail_url=video.video_thumbnail - ) - - -LINK_FOUND = EmbedDict( - title='\u23f3 Spotify link found!', - description='Connecting to super secret database\u2026', - color=GREEN -) - -SPOTIFY_UNREACHABLE = EmbedDict( - title='Oh no', - description='Spotify is out of service', - color=RED -) - -SEARCHING_YOUTUBE = EmbedDict( - title='\u23f3 Searching YouTube' -) - -VIDEO_NOT_FOUND = EmbedDict( - title='Video not found', - color=DARK_RED -) - -FUNCTION_NOT_AVAILABLE = EmbedDict( - title='\u274c Function is currently unavailable', - color=DARK_RED -) - -FUNCTION_IN_DEVELOPMENT = EmbedDict( - title='\U0001f6e0 Function is under construction', - description='Please check debug console for output', - color=GOLD -) diff --git a/Spoyt/env_check.py b/Spoyt/env_check.py deleted file mode 100644 index 87fb474..0000000 --- a/Spoyt/env_check.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from os import environ, getenv - -from Spoyt.logging import log - - -def current_platform() -> str: - return getenv('PLATFORM', 'unknown') - - -def is_discord() -> bool: - return current_platform() == 'discord' - - -def is_guilded() -> bool: - return current_platform() == 'guilded' - - -def check_platform(source: str, platform: str) -> bool: - if source.lower().endswith(platform.lower()): - environ['PLATFORM'] = platform - log.info(f'Automatically set "PLATFORM" to "{platform}"') - return True - return False - - -def auto_set_platform(source: str) -> bool: - for platform in ['guilded', 'discord']: - if check_platform(source, platform): - return True - return False diff --git a/Spoyt/exceptions.py b/Spoyt/exceptions.py new file mode 100644 index 0000000..111f347 --- /dev/null +++ b/Spoyt/exceptions.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +class SpoytException(BaseException): + def __init__(self, traceback='') -> None: + message = 'Spoyt global exception' + BaseException.__init__(self, f'{__class__.__name__}: {traceback or message}') + + +class YouTubeException(SpoytException): + def __init__(self, traceback='') -> None: + message = 'There was an error during querying YouTube.' + SpoytException.__init__(self, f'{__class__.__name__}: {traceback or message}') + + +class YouTubeForbiddenException(YouTubeException): + def __init__(self, traceback='') -> None: + message = 'Bot is not set properly. Ask the bot owner for further information.' + YouTubeException.__init__(self, f'{__class__.__name__}: {traceback or message}') + + +class SpotifyException(SpoytException): + def __init__(self, traceback='') -> None: + message = 'There was an error during querying Spotify.' + SpoytException.__init__(self, f'{__class__.__name__}: {traceback or message}') + + + +class SpotifyUnreachableException(SpotifyException): + def __init__(self, traceback='') -> None: + message = 'Spotify is unreachable.' + SpotifyException.__init__(self, f'{__class__.__name__}: {traceback or message}') \ No newline at end of file diff --git a/Spoyt/settings.py b/Spoyt/settings.py index fd9b7aa..cb3be80 100644 --- a/Spoyt/settings.py +++ b/Spoyt/settings.py @@ -1,6 +1,12 @@ # -*- coding: utf-8 -*- from os import getenv +BOT_TOKEN: str = getenv('BOT_TOKEN') -def bot_token(): - return getenv('BOT_TOKEN') +# Maximum, visible tracks in playlist +MAX_QUERY: int = int(getenv('MAX_QUERY', 10)) + +SPOTIFY_CLIENT_ID: str = getenv('SPOTIFY_CLIENT_ID') +SPOTIFY_CLIENT_SECRET: str = getenv('SPOTIFY_CLIENT_SECRET') + +YOUTUBE_API_KEY: str = getenv('YOUTUBE_API_KEY') diff --git a/Spoyt/types.py b/Spoyt/types.py deleted file mode 100644 index 1ce86a0..0000000 --- a/Spoyt/types.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from typing import TypeAlias - -from discord import \ - Client as DiscordClient, \ - Message as DiscordMessage, \ - Member as DiscordMember -from guilded import \ - Client as GuildedClient, \ - ChatMessage as GuildedMessage, \ - Member as GuildedMember - -CLIENT_TYPE: TypeAlias = DiscordClient or GuildedClient -MESSAGE_TYPE: TypeAlias = DiscordMessage or GuildedMessage -MEMBER_TYPE: TypeAlias = DiscordMember or GuildedMember diff --git a/Spoyt/utils.py b/Spoyt/utils.py new file mode 100644 index 0000000..8fcc02c --- /dev/null +++ b/Spoyt/utils.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from Spoyt.logging import log +from Spoyt.settings import BOT_TOKEN, SPOTIFY_CLIENT_ID, \ + SPOTIFY_CLIENT_SECRET, YOUTUBE_API_KEY + + +def markdown_url(url: str, text: str = None) -> str: + """Wraps URL to be clickable with with optional mask.""" + return f'[{text}]({url})' if text else f'<{url}>' + + +def check_env() -> bool: + """Checks if all required environment varables are set.""" + env_is_valid = True + for key in [ + BOT_TOKEN, + SPOTIFY_CLIENT_ID, + SPOTIFY_CLIENT_SECRET, + YOUTUBE_API_KEY + ]: + if not key: + env_is_valid = False + log.critical(f'"{key}" environment varaible is not set') + return env_is_valid diff --git a/Spoyt/wrapper.py b/Spoyt/wrapper.py deleted file mode 100644 index 66e3166..0000000 --- a/Spoyt/wrapper.py +++ /dev/null @@ -1,120 +0,0 @@ -# -*- coding: utf-8 -*- -from json import dump as json_dump - -from discord import \ - Forbidden as DiscordForbidden, \ - NotFound as DiscordNotFound -from guilded import \ - Forbidden as GuildedForbidden, \ - NotFound as GuildedNotFound - -from Spoyt.embeds.core import create_embed -from Spoyt.embeds.definitions import FUNCTION_IN_DEVELOPMENT, \ - FUNCTION_NOT_AVAILABLE, LINK_FOUND, SEARCHING_YOUTUBE, \ - SPOTIFY_UNREACHABLE, VIDEO_NOT_FOUND, playlist_to_embed, track_to_embed, video_to_embed, \ - EmbedDict -from Spoyt.env_check import auto_set_platform, current_platform -from Spoyt.logging import log -from Spoyt.settings import bot_token -from Spoyt.spotify_api import Playlist, Track, search_track, search_playlist -from Spoyt.types import CLIENT_TYPE, MESSAGE_TYPE -from Spoyt.youtube_api import find_video_by_id - - -def main(client: CLIENT_TYPE, source: str = None): - if current_platform() == 'unknown': - if not auto_set_platform(source): - log.critical('Please set "PLATFORM"') - return - - log.info(f'Running on "{current_platform()}" platform') - - if bot_token() is None: - log.critical('Please set "BOT_TOKEN"') - return - - @client.event - async def on_ready(): - log.info(f'Logged in as {client.user}') - - @client.event - async def on_message(message: MESSAGE_TYPE): - content = message.content - # Masked links - if content.startswith('['): - content = content[1::].split(']')[0] - if not content.startswith('https://open.spotify.com/'): - return - # Track - if content.startswith('https://open.spotify.com/track/'): - new_em = LINK_FOUND - new_em.add_field( - name='Type', - value='track' - ) - spotify_msg: MESSAGE_TYPE = await message.channel.send(embed=create_embed(LINK_FOUND)) - track_id = message.content.split('?')[0].split('&')[0].split('/')[-1] - spotify_query = search_track(track_id) - - if not spotify_query: - await spotify_msg.edit(embed=create_embed(SPOTIFY_UNREACHABLE)) - return - - track = Track(spotify_query) - track_embed = create_embed(track_to_embed(track)) - await spotify_msg.edit(embed=track_embed) - - youtube_msg: MESSAGE_TYPE = await message.channel.send(embed=create_embed(SEARCHING_YOUTUBE)) - youtube_query = '{} {}'.format(track.name, ' '.join(track.artists)) - youtube_result = find_video_by_id(query=youtube_query) - - if not youtube_result.found: - await youtube_msg.edit(embed=create_embed(EmbedDict( - **VIDEO_NOT_FOUND, - description=youtube_result.description, - ))) - return - - await youtube_msg.edit(embed=create_embed( - video_to_embed(youtube_result) - ).set_author( - name=f'{message.author.display_name} (probably) shared:', - icon_url=message.author.display_avatar.url - )) - - try: - await message.delete() - except DiscordForbidden or DiscordNotFound or GuildedForbidden or GuildedNotFound: - pass - else: - track_embed.set_author( - name=f'{message.author.display_name} shared:', - icon_url=message.author.display_avatar.url - ) - await spotify_msg.edit(embed=track_embed) - - log.info(f'Successfully converted "{track.name}" track') - # Playlist - if content.startswith('https://open.spotify.com/playlist/'): - if current_platform() == 'guilded': - spotify_msg: MESSAGE_TYPE = await message.channel.send(embed=create_embed(FUNCTION_NOT_AVAILABLE)) - return - - new_em = LINK_FOUND - new_em.add_field( - name='Type', - value='playlist' - ) - spotify_msg: MESSAGE_TYPE = await message.channel.send(embed=create_embed(new_em)) - playlist_id = message.content.split('?')[0].split('&')[0].split('/')[-1] - spotify_query = search_playlist(playlist_id) - - if not spotify_query: - await spotify_msg.edit(embed=create_embed(SPOTIFY_UNREACHABLE)) - return - - playlist = Playlist(spotify_query) - playlist_embed = create_embed(playlist_to_embed(playlist)) - await spotify_msg.edit(embed=playlist_embed) - - client.run(bot_token()) diff --git a/Spoyt/youtube_api.py b/Spoyt/youtube_api.py deleted file mode 100644 index d1bff5b..0000000 --- a/Spoyt/youtube_api.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -from json import loads as json_loads -from os import getenv - -import requests - -from Spoyt.logging import log - - -class YouTubeResult: - def __init__( - self, - found: bool, - video_id: str = None, - title: str = None, - description: str = None, - published_date: str = None - ) -> None: - self.found = found - self.video_id = video_id - self.title = title - self.description = description - self.published_date = published_date - - @property - def video_link(self) -> str: - return f'https://www.youtube.com/watch?v={self.video_id}' - - @property - def video_thumbnail(self) -> str: - return f'https://i.ytimg.com/vi/{self.video_id}/default.jpg' - - -def find_video_by_id(query: str) -> YouTubeResult: - log.info(f'Searching YouTube: "{query}"') - yt_r = requests.get( - 'https://www.googleapis.com/youtube/v3/search' - '?key={}' - '&part=snippet' - '&maxResults=1' - '&q={}'.format( - getenv('YOUTUBE_API_KEY'), - query - ) - ) - content = json_loads(yt_r.content) - if (error_code := yt_r.status_code) == 200: - data = YouTubeResult( - found=True, - video_id=content['items'][0]['id']['videoId'], - title=content['items'][0]['snippet']['title'], - description=content['items'][0]['snippet']['description'], - published_date=content['items'][0]['snippet']['publishTime'][:10] - ) - log.info(f'Found YouTube video "{data.title}" ({data.video_link})') - elif error_code == 403: - data = YouTubeResult( - found=False, - description='Bot is not set properly. Ask the bot owner for further information.' - ) - log.error(content['error']['message']) - else: - data = YouTubeResult( - found=False, - description=content['error']['message'] - ) - log.error(content['error']['message']) - return data diff --git a/requirements.txt b/requirements.txt index 2842450..4471795 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -discord.py -guilded.py +py-cord rich requests spotipy From c5c0c2c20622c18e2007c7e78eccbd03f3e8a016 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:00:35 +0100 Subject: [PATCH 02/17] Fix logging module importing --- Spoyt/__main__.py | 2 +- Spoyt/api/spotify.py | 2 +- Spoyt/api/youtube.py | 2 +- Spoyt/{logging.py => logger.py} | 0 Spoyt/utils.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename Spoyt/{logging.py => logger.py} (100%) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index 07cdc2e..c73f861 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -11,7 +11,7 @@ SpotifyPlaylistEmbed, SpotifyUnreachableEmbed, YouTubeVideoEmbed, \ UnderCunstructionEmbed from Spoyt.exceptions import SpotifyUnreachableException, YouTubeException -from Spoyt.logging import log +from Spoyt.logger import log from Spoyt.settings import BOT_TOKEN from Spoyt.utils import check_env diff --git a/Spoyt/api/spotify.py b/Spoyt/api/spotify.py index 27caf1d..e777c58 100644 --- a/Spoyt/api/spotify.py +++ b/Spoyt/api/spotify.py @@ -2,7 +2,7 @@ from spotipy import Spotify, SpotifyClientCredentials from Spoyt.exceptions import SpotifyUnreachableException -from Spoyt.logging import log +from Spoyt.logger import log from Spoyt.settings import SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET diff --git a/Spoyt/api/youtube.py b/Spoyt/api/youtube.py index 30a64b8..cbcff2a 100644 --- a/Spoyt/api/youtube.py +++ b/Spoyt/api/youtube.py @@ -4,7 +4,7 @@ from requests import get as requests_get from Spoyt.exceptions import YouTubeException, YouTubeForbiddenException -from Spoyt.logging import log +from Spoyt.logger import log from Spoyt.settings import YOUTUBE_API_KEY diff --git a/Spoyt/logging.py b/Spoyt/logger.py similarity index 100% rename from Spoyt/logging.py rename to Spoyt/logger.py diff --git a/Spoyt/utils.py b/Spoyt/utils.py index 8fcc02c..7d9fa27 100644 --- a/Spoyt/utils.py +++ b/Spoyt/utils.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from Spoyt.logging import log +from Spoyt.logger import log from Spoyt.settings import BOT_TOKEN, SPOTIFY_CLIENT_ID, \ SPOTIFY_CLIENT_SECRET, YOUTUBE_API_KEY From 7fe8d6f007455eff8382d588a5de7323f5209988 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:01:21 +0100 Subject: [PATCH 03/17] Fix param name --- Spoyt/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index c73f861..77a8e3d 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -46,7 +46,7 @@ async def track( ctx: ApplicationContext, url: Option( input_type=str, - name='URL', + name='url', description='Starts with "https://open.spotify.com/track/..."', required=True ) @@ -88,7 +88,7 @@ async def playlist( ctx: ApplicationContext, url: Option( input_type=str, - name='URL', + name='url', description='Starts with "https://open.spotify.com/playlist/..."', required=True ) From 770fd318a79d67eba73f5ffd4136f5f4d283ae9e Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:01:55 +0100 Subject: [PATCH 04/17] Fix embed sending --- Spoyt/__main__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index 77a8e3d..4d2673c 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -52,14 +52,14 @@ async def track( ) ) -> None: if not url.startswith('https://open.spotify.com/track/'): - await ctx.respond(embed=IncorrectInputEmbed) + await ctx.respond(embed=IncorrectInputEmbed()) return track_id = url_to_id(url) try: track = search_track(track_id) except SpotifyUnreachableException: - await ctx.respond(embed=SpotifyUnreachableEmbed) + await ctx.respond(embed=SpotifyUnreachableEmbed()) return await ctx.respond(embed=SpotifyTrackEmbed(track)) @@ -94,19 +94,21 @@ async def playlist( ) ) -> None: if not url.startswith('https://open.spotify.com/playlist/'): - await ctx.respond(embed=IncorrectInputEmbed) + await ctx.respond(embed=IncorrectInputEmbed()) return playlist_id = url_to_id(url) try: playlist = search_playlist(playlist_id) except SpotifyUnreachableException: - await ctx.respond(embed=SpotifyUnreachableEmbed) + await ctx.respond(embed=SpotifyUnreachableEmbed()) return await ctx.respond(embed=SpotifyPlaylistEmbed(playlist)) - await ctx.channel.send(embed=UnderCunstructionEmbed) + await ctx.channel.send(embed=UnderCunstructionEmbed( + description='YouTube searching is currently available only for `/track`.' + )) log.info('Playlist conversion issued.') bot.start(BOT_TOKEN) From 840ea58dbfa804619adcff3ba978afc70527c410 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:02:10 +0100 Subject: [PATCH 05/17] Fix PEP8 --- Spoyt/__main__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index 4d2673c..02f2ac3 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -49,8 +49,7 @@ async def track( name='url', description='Starts with "https://open.spotify.com/track/..."', required=True - ) - ) -> None: + )) -> None: if not url.startswith('https://open.spotify.com/track/'): await ctx.respond(embed=IncorrectInputEmbed()) return From 146a18d10c61d1bf961e0a592a38707c7e0700fc Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:02:23 +0100 Subject: [PATCH 06/17] Fix bot starting --- Spoyt/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index 02f2ac3..f54d3bb 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -110,4 +110,4 @@ async def playlist( )) log.info('Playlist conversion issued.') - bot.start(BOT_TOKEN) + bot.run(BOT_TOKEN) From 1b399e94dd24012824e89c0fc4c6c0b6ec4a3428 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:03:59 +0100 Subject: [PATCH 07/17] Add Spotify not found error handling --- Spoyt/__main__.py | 12 +++++++++--- Spoyt/api/spotify.py | 14 ++++++++++---- Spoyt/embeds.py | 12 ++++++++++++ Spoyt/exceptions.py | 8 +++++++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index f54d3bb..cca6d42 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -7,10 +7,10 @@ from Spoyt.api.spotify import search_track, search_playlist, url_to_id from Spoyt.api.youtube import search_video -from Spoyt.embeds import ErrorEmbed, IncorrectInputEmbed, SpotifyTrackEmbed, \ - SpotifyPlaylistEmbed, SpotifyUnreachableEmbed, YouTubeVideoEmbed, \ +from Spoyt.embeds import ErrorEmbed, IncorrectInputEmbed, SpotifyPlaylistkNotFoundEmbed, SpotifyTrackEmbed, \ + SpotifyPlaylistEmbed, SpotifyTrackNotFoundEmbed, SpotifyUnreachableEmbed, YouTubeVideoEmbed, \ UnderCunstructionEmbed -from Spoyt.exceptions import SpotifyUnreachableException, YouTubeException +from Spoyt.exceptions import SpotifyNotFoundException, SpotifyUnreachableException, YouTubeException from Spoyt.logger import log from Spoyt.settings import BOT_TOKEN from Spoyt.utils import check_env @@ -57,6 +57,9 @@ async def track( track_id = url_to_id(url) try: track = search_track(track_id) + except SpotifyNotFoundException: + await ctx.respond(embed=SpotifyTrackNotFoundEmbed()) + return except SpotifyUnreachableException: await ctx.respond(embed=SpotifyUnreachableEmbed()) return @@ -99,6 +102,9 @@ async def playlist( playlist_id = url_to_id(url) try: playlist = search_playlist(playlist_id) + except SpotifyNotFoundException: + await ctx.respond(embed=SpotifyPlaylistkNotFoundEmbed()) + return except SpotifyUnreachableException: await ctx.respond(embed=SpotifyUnreachableEmbed()) return diff --git a/Spoyt/api/spotify.py b/Spoyt/api/spotify.py index e777c58..2351eda 100644 --- a/Spoyt/api/spotify.py +++ b/Spoyt/api/spotify.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from spotipy import Spotify, SpotifyClientCredentials -from Spoyt.exceptions import SpotifyUnreachableException +from spotipy import Spotify, SpotifyException, SpotifyClientCredentials +from Spoyt.exceptions import SpotifyNotFoundException, SpotifyUnreachableException from Spoyt.logger import log from Spoyt.settings import SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET @@ -76,7 +76,10 @@ def spotify_connect() -> Spotify: def search_track(track_id: str) -> Track: log.info(f'Searching track by ID "{track_id}"') - track: dict | None = spotify_connect().track(track_id=track_id) + try: + track: dict | None = spotify_connect().track(track_id=track_id) + except SpotifyException: + raise SpotifyNotFoundException if not track: log.error('Spotify unreachable') raise SpotifyUnreachableException @@ -85,7 +88,10 @@ def search_track(track_id: str) -> Track: def search_playlist(playlist_id: str) -> Playlist: log.info(f'Searching playlist by ID "{playlist_id}"') - playlist: dict | None = spotify_connect().playlist(playlist_id=playlist_id) + try: + playlist: dict | None = spotify_connect().playlist(playlist_id=playlist_id) + except SpotifyException: + raise SpotifyNotFoundException if not playlist: log.error('Spotify unreachable') raise SpotifyUnreachableException diff --git a/Spoyt/embeds.py b/Spoyt/embeds.py index 708316a..665058e 100644 --- a/Spoyt/embeds.py +++ b/Spoyt/embeds.py @@ -65,6 +65,18 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.title = 'Video not found' + +class SpotifyTrackNotFoundEmbed(NotFoundEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Spotify track not found' + + +class SpotifyPlaylistkNotFoundEmbed(NotFoundEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Spotify playlist not found' + # Other errors class ErrorEmbed(BaseEmbed): diff --git a/Spoyt/exceptions.py b/Spoyt/exceptions.py index 111f347..f28a28a 100644 --- a/Spoyt/exceptions.py +++ b/Spoyt/exceptions.py @@ -27,4 +27,10 @@ def __init__(self, traceback='') -> None: class SpotifyUnreachableException(SpotifyException): def __init__(self, traceback='') -> None: message = 'Spotify is unreachable.' - SpotifyException.__init__(self, f'{__class__.__name__}: {traceback or message}') \ No newline at end of file + SpotifyException.__init__(self, f'{__class__.__name__}: {traceback or message}') + + +class SpotifyNotFoundException(SpotifyException): + def __init__(self, traceback='') -> None: + message = 'Spotify track not found.' + SpotifyException.__init__(self, f'{__class__.__name__}: {traceback or message}') From 660323322d84bf616c18253f80363da05604ae5a Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:04:12 +0100 Subject: [PATCH 08/17] Fix missing YouTube embed color --- Spoyt/embeds.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Spoyt/embeds.py b/Spoyt/embeds.py index 665058e..ff6cfa4 100644 --- a/Spoyt/embeds.py +++ b/Spoyt/embeds.py @@ -143,6 +143,7 @@ def __init__(self, video: YouTubeVideo, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.title=video.title self.description=markdown_url(video.video_link) + self.color = Color.dark_red() self.set_thumbnail(url=video.video_thumbnail) self.add_field( name='Description', From fb21c7c23d8e14853afe6550fd146d2d3530148f Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:45:50 +0100 Subject: [PATCH 09/17] Fix cooldown --- Spoyt/__main__.py | 32 +++++++++++++++++++------------- Spoyt/embeds.py | 6 ++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Spoyt/__main__.py b/Spoyt/__main__.py index cca6d42..40674b9 100644 --- a/Spoyt/__main__.py +++ b/Spoyt/__main__.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- from logging import INFO, basicConfig -from discord import ApplicationContext, Bot, Option -from discord.ext.commands import Cooldown +from discord import ApplicationContext, Bot, DiscordException, Option +from discord.ext.commands import BucketType, cooldown, CommandOnCooldown from rich.logging import RichHandler from Spoyt.api.spotify import search_track, search_playlist, url_to_id from Spoyt.api.youtube import search_video -from Spoyt.embeds import ErrorEmbed, IncorrectInputEmbed, SpotifyPlaylistkNotFoundEmbed, SpotifyTrackEmbed, \ +from Spoyt.embeds import CommandOnCooldownEmbed, ErrorEmbed, IncorrectInputEmbed, SpotifyPlaylistkNotFoundEmbed, SpotifyTrackEmbed, \ SpotifyPlaylistEmbed, SpotifyTrackNotFoundEmbed, SpotifyUnreachableEmbed, YouTubeVideoEmbed, \ UnderCunstructionEmbed from Spoyt.exceptions import SpotifyNotFoundException, SpotifyUnreachableException, YouTubeException @@ -34,14 +34,23 @@ async def on_ready() -> None: log.info(f'Logged in as "{bot.user}"') + @bot.event + async def on_application_command_error( + ctx: ApplicationContext, + exception: DiscordException + ) -> None: + if isinstance(exception, CommandOnCooldown): + await ctx.respond(embed=CommandOnCooldownEmbed( + description=f'Retry in {int(exception.retry_after)} second(s).' + )) + else: + raise exception + @bot.slash_command( name='track', - description='Search for a track', - cooldown=Cooldown( - rate=1, - per=5.0 - ), + description='Search for a track' ) + @cooldown(1, 5.0, BucketType.guild) async def track( ctx: ApplicationContext, url: Option( @@ -80,12 +89,9 @@ async def track( @bot.slash_command( name='playlist', - description='Search for a playlist', - cooldown=Cooldown( - rate=1, - per=30.0 - ), + description='Search for a playlist' ) + @cooldown(1, 30.0, BucketType.guild) async def playlist( ctx: ApplicationContext, url: Option( diff --git a/Spoyt/embeds.py b/Spoyt/embeds.py index ff6cfa4..44bbde9 100644 --- a/Spoyt/embeds.py +++ b/Spoyt/embeds.py @@ -85,6 +85,12 @@ def __init__(self, *args, **kwargs) -> None: self.title = 'There was an error' self.color = Color.red() + +class CommandOnCooldownEmbed(ErrorEmbed): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.title = 'Command on cooldown' + class IncorrectInputEmbed(ErrorEmbed): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) From 3693d7c79e03e797dca06cfd5e5173bc4ac230c3 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:46:17 +0100 Subject: [PATCH 10/17] Fix YouTube embed color --- Spoyt/embeds.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Spoyt/embeds.py b/Spoyt/embeds.py index 44bbde9..c49f9ec 100644 --- a/Spoyt/embeds.py +++ b/Spoyt/embeds.py @@ -6,6 +6,10 @@ from Spoyt.settings import MAX_QUERY from Spoyt.utils import markdown_url + +YOUTUBE_COLOR = Color.from_rgb(255, 0, 0) + + class BaseEmbed(Embed): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -149,7 +153,7 @@ def __init__(self, video: YouTubeVideo, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.title=video.title self.description=markdown_url(video.video_link) - self.color = Color.dark_red() + self.color = YOUTUBE_COLOR self.set_thumbnail(url=video.video_thumbnail) self.add_field( name='Description', From fadb1387237d12a3a2a78b2f74e8d7a15804a67b Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:47:25 +0100 Subject: [PATCH 11/17] Improve playlist author displaying Owner is now `User` class. --- Spoyt/api/spotify.py | 28 ++++++++++++++++++++++------ Spoyt/embeds.py | 8 ++++---- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Spoyt/api/spotify.py b/Spoyt/api/spotify.py index 2351eda..cb48b4e 100644 --- a/Spoyt/api/spotify.py +++ b/Spoyt/api/spotify.py @@ -25,13 +25,19 @@ def is_single_artist(self) -> bool: def track_url(self) -> str: return f'https://open.spotify.com/track/{self.track_id}' + +class User: + def __init__(self, payload: dict) -> None: + self.name: str = payload.get('display_name') + self.id: str = payload.get('id') + self.user_url: str = payload.get('external_urls', {}).get('spotify') + self.avatar_url: str = payload.get('images', [{}])[-1].get('url') + class Playlist: def __init__(self, payload: dict) -> None: self.name: str = payload.get('name') self.description: str = payload.get('description') self.playlist_id: str = payload.get('id') - self.owner_name: str = payload.get('owner', {}).get('display_name') - self.owner_id: str = payload.get('owner', {}).get('id') self.cover_url: str = payload.get('images', [{}])[0].get('url') self.tracks: list[Track] = list(map( lambda a: Track(a.get('track', {})), @@ -40,14 +46,12 @@ def __init__(self, payload: dict) -> None: self.total_tracks: int = payload.get('tracks', {}).get('total') self.query_limit: int = payload.get('tracks', {}).get('limit') + self.owner: User = search_user(payload.get('owner', {}).get('id')) + @property def url(self) -> str: return f'https://open.spotify.com/playlist/{self.playlist_id}' - @property - def owner_url(self) -> str: - return f'https://open.spotify.com/user/{self.owner_id}' - @property def is_query_limited(self) -> bool: return len(self.tracks) == self.query_limit @@ -96,3 +100,15 @@ def search_playlist(playlist_id: str) -> Playlist: log.error('Spotify unreachable') raise SpotifyUnreachableException return Playlist(playlist) + + +def search_user(user_id: str) -> User: + log.info(f'Searching user by ID "{user_id}"') + try: + user: dict | None = spotify_connect().user(user=user_id) + except SpotifyException: + raise SpotifyNotFoundException + if not user: + log.error('Spotify unreachable') + raise SpotifyUnreachableException + return User(user) diff --git a/Spoyt/embeds.py b/Spoyt/embeds.py index c49f9ec..7079274 100644 --- a/Spoyt/embeds.py +++ b/Spoyt/embeds.py @@ -131,10 +131,10 @@ def __init__(self, playlist: Playlist, *args, **kwargs) -> None: self.description = description self.color = Color.green() self.set_thumbnail(url=playlist.cover_url) - self.add_field( - name='Owner', - value=f'[{playlist.owner_name}]({playlist.owner_url})', - inline=False + self.set_author( + name=playlist.owner.name, + url=playlist.owner.user_url, + icon_url=playlist.owner.avatar_url ) first_tracks = '\n'.join(map( lambda a: f'- {markdown_url(a.track_url, a.name)}', From 279411b25ce9113a61919bb6590932539614e0d0 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 11:48:52 +0100 Subject: [PATCH 12/17] Ignore JSON files These are used only for testing. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 44d308e..edbb554 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,7 @@ cython_debug/ # 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/ + +# JSON files are used only for testing. If this behaviour will change in the future specific +# files will be ignored. +*.json From ade5f296769095071b7b55434a0896b149dc020a Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 12:10:49 +0100 Subject: [PATCH 13/17] Fix environment value leak --- Spoyt/utils.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Spoyt/utils.py b/Spoyt/utils.py index 7d9fa27..84a40ff 100644 --- a/Spoyt/utils.py +++ b/Spoyt/utils.py @@ -12,13 +12,14 @@ def markdown_url(url: str, text: str = None) -> str: def check_env() -> bool: """Checks if all required environment varables are set.""" env_is_valid = True - for key in [ - BOT_TOKEN, - SPOTIFY_CLIENT_ID, - SPOTIFY_CLIENT_SECRET, - YOUTUBE_API_KEY - ]: - if not key: + vars = { + 'BOT_TOKEN': BOT_TOKEN, + 'SPOTIFY_CLIENT_ID': SPOTIFY_CLIENT_ID, + 'SPOTIFY_CLIENT_SECRET': SPOTIFY_CLIENT_SECRET, + 'YOUTUBE_API_KEY': YOUTUBE_API_KEY + } + for key in vars.keys(): + if not vars.get(key): env_is_valid = False log.critical(f'"{key}" environment varaible is not set') return env_is_valid From 5b444652a2b32a7dc4b759b33770a0363f76ae43 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 12:23:00 +0100 Subject: [PATCH 14/17] Revert "Fix environment value leak" This reverts commit ade5f296769095071b7b55434a0896b149dc020a. --- Spoyt/utils.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Spoyt/utils.py b/Spoyt/utils.py index 84a40ff..7d9fa27 100644 --- a/Spoyt/utils.py +++ b/Spoyt/utils.py @@ -12,14 +12,13 @@ def markdown_url(url: str, text: str = None) -> str: def check_env() -> bool: """Checks if all required environment varables are set.""" env_is_valid = True - vars = { - 'BOT_TOKEN': BOT_TOKEN, - 'SPOTIFY_CLIENT_ID': SPOTIFY_CLIENT_ID, - 'SPOTIFY_CLIENT_SECRET': SPOTIFY_CLIENT_SECRET, - 'YOUTUBE_API_KEY': YOUTUBE_API_KEY - } - for key in vars.keys(): - if not vars.get(key): + for key in [ + BOT_TOKEN, + SPOTIFY_CLIENT_ID, + SPOTIFY_CLIENT_SECRET, + YOUTUBE_API_KEY + ]: + if not key: env_is_valid = False log.critical(f'"{key}" environment varaible is not set') return env_is_valid From 368da04f29c55a504f5385140e99df8b9c7c1a9a Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 12:26:38 +0100 Subject: [PATCH 15/17] Re-fix environment value leak --- Spoyt/utils.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Spoyt/utils.py b/Spoyt/utils.py index 7d9fa27..84a40ff 100644 --- a/Spoyt/utils.py +++ b/Spoyt/utils.py @@ -12,13 +12,14 @@ def markdown_url(url: str, text: str = None) -> str: def check_env() -> bool: """Checks if all required environment varables are set.""" env_is_valid = True - for key in [ - BOT_TOKEN, - SPOTIFY_CLIENT_ID, - SPOTIFY_CLIENT_SECRET, - YOUTUBE_API_KEY - ]: - if not key: + vars = { + 'BOT_TOKEN': BOT_TOKEN, + 'SPOTIFY_CLIENT_ID': SPOTIFY_CLIENT_ID, + 'SPOTIFY_CLIENT_SECRET': SPOTIFY_CLIENT_SECRET, + 'YOUTUBE_API_KEY': YOUTUBE_API_KEY + } + for key in vars.keys(): + if not vars.get(key): env_is_valid = False log.critical(f'"{key}" environment varaible is not set') return env_is_valid From 9f919d7654a5cacb57cbd9e2dcadd72307de17d2 Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Sun, 12 Nov 2023 12:39:20 +0100 Subject: [PATCH 16/17] Another fix for environment values leak --- Spoyt/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Spoyt/utils.py b/Spoyt/utils.py index 84a40ff..b19a378 100644 --- a/Spoyt/utils.py +++ b/Spoyt/utils.py @@ -18,8 +18,8 @@ def check_env() -> bool: 'SPOTIFY_CLIENT_SECRET': SPOTIFY_CLIENT_SECRET, 'YOUTUBE_API_KEY': YOUTUBE_API_KEY } - for key in vars.keys(): - if not vars.get(key): + for key, value in vars.items(): + if value is None: env_is_valid = False log.critical(f'"{key}" environment varaible is not set') return env_is_valid From feee7f699f95bf0d2052c654d6f877ae407b5c9c Mon Sep 17 00:00:00 2001 From: Jakub Suchenek Date: Mon, 13 Nov 2023 19:20:17 +0100 Subject: [PATCH 17/17] Do not import environment varaibles --- Spoyt/utils.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Spoyt/utils.py b/Spoyt/utils.py index b19a378..50b95f0 100644 --- a/Spoyt/utils.py +++ b/Spoyt/utils.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- +from os import environ + from Spoyt.logger import log -from Spoyt.settings import BOT_TOKEN, SPOTIFY_CLIENT_ID, \ - SPOTIFY_CLIENT_SECRET, YOUTUBE_API_KEY def markdown_url(url: str, text: str = None) -> str: @@ -12,14 +12,13 @@ def markdown_url(url: str, text: str = None) -> str: def check_env() -> bool: """Checks if all required environment varables are set.""" env_is_valid = True - vars = { - 'BOT_TOKEN': BOT_TOKEN, - 'SPOTIFY_CLIENT_ID': SPOTIFY_CLIENT_ID, - 'SPOTIFY_CLIENT_SECRET': SPOTIFY_CLIENT_SECRET, - 'YOUTUBE_API_KEY': YOUTUBE_API_KEY - } - for key, value in vars.items(): - if value is None: + for key in [ + 'BOT_TOKEN', + 'SPOTIFY_CLIENT_ID', + 'SPOTIFY_CLIENT_SECRET', + 'YOUTUBE_API_KEY' + ]: + if key not in environ: env_is_valid = False log.critical(f'"{key}" environment varaible is not set') return env_is_valid