Skip to content

Commit

Permalink
Abandon Guilded
Browse files Browse the repository at this point in the history
Migrated to `py-cord`.
Including:
- hints,
- exceptions,
- improved embeds,
- better project structure,
- updated invitation link.
  • Loading branch information
AnonymousX86 committed Nov 12, 2023
1 parent 22d9151 commit 9ea2c4b
Show file tree
Hide file tree
Showing 22 changed files with 395 additions and 528 deletions.
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
25 changes: 7 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -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: <https://discord.com/api/oauth2/authorize?client_id=948274806325903410&permissions=2147485696&scope=bot%20applications.commands>.

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: <https://discord.gg/SRdmrPpf2z>.

Empty file removed Spoyt/Discord/__init__.py
Empty file.
28 changes: 0 additions & 28 deletions Spoyt/Discord/__main__.py

This file was deleted.

Empty file removed Spoyt/Guilded/__init__.py
Empty file.
19 changes: 0 additions & 19 deletions Spoyt/Guilded/__main__.py

This file was deleted.

101 changes: 98 additions & 3 deletions Spoyt/__main__.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
36 changes: 27 additions & 9 deletions Spoyt/spotify_api.py → Spoyt/api/spotify.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
48 changes: 48 additions & 0 deletions Spoyt/api/youtube.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 9ea2c4b

Please sign in to comment.