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