From 239ccc161564bc143da93c3dd91178fedcab8f35 Mon Sep 17 00:00:00 2001 From: Mootfrost Date: Tue, 15 Apr 2025 22:01:12 +0300 Subject: [PATCH 1/3] Add authorize page --- app/__init__.py | 8 +- app/callback_listener.py | 44 ++++++++++- app/static/authorize_spotify.html | 121 ++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 app/static/authorize_spotify.html diff --git a/app/__init__.py b/app/__init__.py index 98ef18b..995e905 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -47,13 +47,9 @@ async def get_user(user_id): def get_spotify_link(user_id) -> str: params = { - 'client_id': config.spotify.client_id, - 'response_type': 'code', - 'redirect_uri': config.spotify.redirect, - 'scope': 'user-read-recently-played user-read-currently-playing', 'state': user_id } - return f"https://accounts.spotify.com/authorize?{urllib.parse.urlencode(params)}" + return f"https://music.mootfrost.dev/spotify/authorize?{urllib.parse.urlencode(params)}" def get_ymusic_link(user_id) -> str: @@ -69,7 +65,7 @@ def get_ymusic_link(user_id) -> str: async def start(e: events.NewMessage.Event): payload = { 'tg_id': e.chat_id, - 'exp': int(time.time()) + 300 + 'exp': int(time.time()) + 900 } enc_user_id = jwt.encode(payload, config.jwt_secret, algorithm='HS256') buttons = [ diff --git a/app/callback_listener.py b/app/callback_listener.py index 2050128..24888da 100644 --- a/app/callback_listener.py +++ b/app/callback_listener.py @@ -1,3 +1,4 @@ +import urllib.parse from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession @@ -6,7 +7,7 @@ from fastapi.staticfiles import StaticFiles import uvicorn from telethon import TelegramClient import aiohttp -import time +from pydantic import BaseModel import jwt from app.dependencies import get_session @@ -69,7 +70,44 @@ You can change default service using /default command. """ -@app.get('/spotify_callback') +def get_spotify_link(client_id, state) -> str: + params = { + 'client_id': client_id, + 'response_type': 'code', + 'redirect_uri': config.spotify.redirect, + 'scope': 'user-read-recently-played user-read-currently-playing', + 'state': state + } + return f"https://accounts.spotify.com/authorize?{urllib.parse.urlencode(params)}" + + +class SpotifyAuthorizeRequest(BaseModel): + client_id: str + client_secret: str + state: str + + +@app.post('/spotify/authorize') +async def spotify_authorize(data: SpotifyAuthorizeRequest, session: AsyncSession = Depends(get_session)): + user_id = get_decoded_id(data.state) + creds = { + 'client_id': data.client_id, + 'client_secret': data.client_secret + } + user = await session.get(User, user_id) + if user: + user.spotify_auth = creds + else: + user = User(id=user_id, + spotify_auth=creds, + default='spotify' + ) + session.add(user) + await session.commit() + return {'redirect_url': get_spotify_link(data.client_id, client.state)} + + +@app.get('/spotify/callback') async def spotify_callback(code: str, state: str, session: AsyncSession = Depends(get_session)): user_id = get_decoded_id(state) token, refresh_token, expires_in = await code_to_token(code, 'https://accounts.spotify.com/api/token', config.spotify) @@ -93,7 +131,7 @@ async def spotify_callback(code: str, state: str, session: AsyncSession = Depend -@app.get('/ym_callback') +@app.get('/ym/callback') async def ym_callback(state: str, code: str, cid: str, session: AsyncSession = Depends(get_session)): user_id = get_decoded_id(state) token, refresh_token, expires_in = await code_to_token(code, 'https://oauth.yandex.com/token', config.ymusic, config.proxy) diff --git a/app/static/authorize_spotify.html b/app/static/authorize_spotify.html new file mode 100644 index 0000000..bbf5bdd --- /dev/null +++ b/app/static/authorize_spotify.html @@ -0,0 +1,121 @@ + + + + + + Spotify API Authorization + + + + +

Spotify API Setup

+
    +
  1. Visit Spotify Developer Dashboard
  2. +
  3. Log in and create an app
  4. +
  5. Fill any name and description. Set redirect url to: {{}}
  6. +
  7. Copy your Client ID and Client Secret
  8. +
+ + + + + + + + + + + + From e98bc96744d10a0da9740ae81064431317332313 Mon Sep 17 00:00:00 2001 From: Mootfrost Date: Thu, 17 Apr 2025 22:04:00 +0300 Subject: [PATCH 2/3] Request users api creds to link account --- alembic/env.py | 2 +- .../59bdc35f510c_change_creds_storage.py | 47 +++++-- .../934c5c1d1f4a_id_from_int_to_bigint.py | 31 +++-- .../9b7a211cc587_id_from_int_to_bigint.py | 31 +++-- .../9f96b664be50_add_default_field.py | 21 ++- .../e4a1561c2b63_add_yandex_music_fileds.py | 49 +++---- .../fc8875e47bc0_add_yandex_music_fileds.py | 13 +- app/MusicProvider/Context.py | 2 +- app/MusicProvider/SpotifyStrategy.py | 57 ++++---- app/MusicProvider/Strategy.py | 2 +- app/MusicProvider/YMusicStrategy.py | 28 ++-- app/MusicProvider/auth.py | 22 ++-- app/__init__.py | 113 ++++++++-------- app/__main__.py | 3 +- app/callback_listener.py | 122 +++++++++--------- app/config.py | 20 +-- app/db.py | 2 +- app/dependencies.py | 4 +- app/models/__init__.py | 2 +- app/models/base.py | 2 +- app/models/track.py | 8 +- app/models/user.py | 4 +- app/static/authorize_spotify.html | 6 +- app/static/privacy.html | 104 +++++++++++++++ app/sus.py | 14 +- app/youtube_api.py | 34 ++--- pyproject.toml | 1 + uv.lock | 14 ++ 28 files changed, 467 insertions(+), 291 deletions(-) create mode 100644 app/static/privacy.html diff --git a/alembic/env.py b/alembic/env.py index 582194e..18dbe30 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -17,7 +17,7 @@ config = context.config # This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) -config.set_main_option('sqlalchemy.url', app_config.db_string) +config.set_main_option("sqlalchemy.url", app_config.db_string) # add your model's MetaData object here # for 'autogenerate' support diff --git a/alembic/versions/59bdc35f510c_change_creds_storage.py b/alembic/versions/59bdc35f510c_change_creds_storage.py index 56ec81b..96e45eb 100644 --- a/alembic/versions/59bdc35f510c_change_creds_storage.py +++ b/alembic/versions/59bdc35f510c_change_creds_storage.py @@ -5,6 +5,7 @@ Revises: fc8875e47bc0 Create Date: 2025-04-09 23:28:32.649596 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '59bdc35f510c' -down_revision: Union[str, None] = 'fc8875e47bc0' +revision: str = "59bdc35f510c" +down_revision: Union[str, None] = "fc8875e47bc0" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,22 +22,40 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('spotify_auth', sa.JSON(), nullable=False)) - op.add_column('users', sa.Column('ymusic_auth', sa.JSON(), nullable=False)) - op.drop_column('users', 'ymusic_token') - op.drop_column('users', 'spotify_refresh_token') - op.drop_column('users', 'spotify_refresh_at') - op.drop_column('users', 'spotify_access_token') + op.add_column("users", sa.Column("spotify_auth", sa.JSON(), nullable=False)) + op.add_column("users", sa.Column("ymusic_auth", sa.JSON(), nullable=False)) + op.drop_column("users", "ymusic_token") + op.drop_column("users", "spotify_refresh_token") + op.drop_column("users", "spotify_refresh_at") + op.drop_column("users", "spotify_access_token") # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('spotify_access_token', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.add_column('users', sa.Column('spotify_refresh_at', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('users', sa.Column('spotify_refresh_token', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.add_column('users', sa.Column('ymusic_token', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.drop_column('users', 'ymusic_auth') - op.drop_column('users', 'spotify_auth') + op.add_column( + "users", + sa.Column( + "spotify_access_token", sa.VARCHAR(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "users", + sa.Column( + "spotify_refresh_at", sa.INTEGER(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "users", + sa.Column( + "spotify_refresh_token", sa.VARCHAR(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "users", + sa.Column("ymusic_token", sa.VARCHAR(), autoincrement=False, nullable=True), + ) + op.drop_column("users", "ymusic_auth") + op.drop_column("users", "spotify_auth") # ### end Alembic commands ### diff --git a/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py b/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py index 890a96c..16fd21c 100644 --- a/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py +++ b/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py @@ -5,6 +5,7 @@ Revises: 9f96b664be50 Create Date: 2025-04-15 18:14:14.644341 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '934c5c1d1f4a' -down_revision: Union[str, None] = '9f96b664be50' +revision: str = "934c5c1d1f4a" +down_revision: Union[str, None] = "9f96b664be50" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,20 +22,26 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('tracks', 'id', - existing_type=sa.INTEGER(), - type_=sa.BigInteger(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "tracks", + "id", + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('tracks', 'id', - existing_type=sa.BigInteger(), - type_=sa.INTEGER(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "tracks", + "id", + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### diff --git a/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py b/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py index 35335c3..4d9934f 100644 --- a/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py +++ b/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py @@ -5,6 +5,7 @@ Revises: 934c5c1d1f4a Create Date: 2025-04-15 18:17:57.302668 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '9b7a211cc587' -down_revision: Union[str, None] = '934c5c1d1f4a' +revision: str = "9b7a211cc587" +down_revision: Union[str, None] = "934c5c1d1f4a" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,20 +22,26 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('users', 'id', - existing_type=sa.INTEGER(), - type_=sa.BigInteger(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "users", + "id", + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('users', 'id', - existing_type=sa.BigInteger(), - type_=sa.INTEGER(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "users", + "id", + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### diff --git a/alembic/versions/9f96b664be50_add_default_field.py b/alembic/versions/9f96b664be50_add_default_field.py index 1262fd8..e7e7974 100644 --- a/alembic/versions/9f96b664be50_add_default_field.py +++ b/alembic/versions/9f96b664be50_add_default_field.py @@ -5,6 +5,7 @@ Revises: 59bdc35f510c Create Date: 2025-04-10 21:33:30.353105 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision: str = '9f96b664be50' -down_revision: Union[str, None] = '59bdc35f510c' +revision: str = "9f96b664be50" +down_revision: Union[str, None] = "59bdc35f510c" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,14 +22,22 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('tracks', 'telegram_reference') - op.add_column('users', sa.Column('default', sa.String(), nullable=False)) + op.drop_column("tracks", "telegram_reference") + op.add_column("users", sa.Column("default", sa.String(), nullable=False)) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('users', 'default') - op.add_column('tracks', sa.Column('telegram_reference', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_column("users", "default") + op.add_column( + "tracks", + sa.Column( + "telegram_reference", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + ) # ### end Alembic commands ### diff --git a/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py b/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py index 2b60c0d..71a2318 100644 --- a/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py +++ b/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py @@ -1,10 +1,11 @@ """add yandex music fileds Revision ID: e4a1561c2b63 -Revises: +Revises: Create Date: 2025-04-05 21:22:09.221527 """ + from typing import Sequence, Union from alembic import op @@ -12,7 +13,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'e4a1561c2b63' +revision: str = "e4a1561c2b63" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,26 +22,28 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('tracks', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('telegram_id', sa.BigInteger(), nullable=True), - sa.Column('telegram_access_hash', sa.BigInteger(), nullable=True), - sa.Column('telegram_file_reference', sa.LargeBinary(), nullable=True), - sa.Column('spotify_id', sa.String(), nullable=True), - sa.Column('ymusic_id', sa.String(), nullable=True), - sa.Column('name', sa.String(), nullable=False), - sa.Column('artist', sa.String(), nullable=False), - sa.Column('cover_url', sa.String(), nullable=False), - sa.Column('used_times', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "tracks", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("telegram_id", sa.BigInteger(), nullable=True), + sa.Column("telegram_access_hash", sa.BigInteger(), nullable=True), + sa.Column("telegram_file_reference", sa.LargeBinary(), nullable=True), + sa.Column("spotify_id", sa.String(), nullable=True), + sa.Column("ymusic_id", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("artist", sa.String(), nullable=False), + sa.Column("cover_url", sa.String(), nullable=False), + sa.Column("used_times", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('spotify_access_token', sa.String(), nullable=True), - sa.Column('spotify_refresh_token', sa.String(), nullable=True), - sa.Column('spotify_refresh_at', sa.Integer(), nullable=True), - sa.Column('ymusic_token', sa.String(), nullable=True), - sa.PrimaryKeyConstraint('id') + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("spotify_access_token", sa.String(), nullable=True), + sa.Column("spotify_refresh_token", sa.String(), nullable=True), + sa.Column("spotify_refresh_at", sa.Integer(), nullable=True), + sa.Column("ymusic_token", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### @@ -48,6 +51,6 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('users') - op.drop_table('tracks') + op.drop_table("users") + op.drop_table("tracks") # ### end Alembic commands ### diff --git a/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py b/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py index 89426b2..629eeed 100644 --- a/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py +++ b/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py @@ -5,6 +5,7 @@ Revises: e4a1561c2b63 Create Date: 2025-04-08 23:34:01.812378 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'fc8875e47bc0' -down_revision: Union[str, None] = 'e4a1561c2b63' +revision: str = "fc8875e47bc0" +down_revision: Union[str, None] = "e4a1561c2b63" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,14 +22,14 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('tracks', sa.Column('telegram_reference', sa.JSON(), nullable=True)) - op.add_column('tracks', sa.Column('yt_id', sa.String(), nullable=True)) + op.add_column("tracks", sa.Column("telegram_reference", sa.JSON(), nullable=True)) + op.add_column("tracks", sa.Column("yt_id", sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('tracks', 'yt_id') - op.drop_column('tracks', 'telegram_reference') + op.drop_column("tracks", "yt_id") + op.drop_column("tracks", "telegram_reference") # ### end Alembic commands ### diff --git a/app/MusicProvider/Context.py b/app/MusicProvider/Context.py index 340de78..95ff15b 100644 --- a/app/MusicProvider/Context.py +++ b/app/MusicProvider/Context.py @@ -22,4 +22,4 @@ class MusicProviderContext: res = await self.strategy.fetch_track(track) if not res: return track - return res \ No newline at end of file + return res diff --git a/app/MusicProvider/SpotifyStrategy.py b/app/MusicProvider/SpotifyStrategy.py index c3cff6d..19f9831 100644 --- a/app/MusicProvider/SpotifyStrategy.py +++ b/app/MusicProvider/SpotifyStrategy.py @@ -9,7 +9,7 @@ from app.dependencies import get_session_context from sqlalchemy import select, update from app.models import User, Track -from app.MusicProvider.auth import refresh_token, get_oauth_creds +from app.MusicProvider.auth import refresh_token, get_oauth_creds, get_encoded_creds class SpotifyStrategy(MusicProviderStrategy): @@ -24,56 +24,59 @@ class SpotifyStrategy(MusicProviderStrategy): if not user: return None - if int(time.time()) < user.spotify_auth['refresh_at']: - return user.spotify_auth['access_token'] + if int(time.time()) < user.spotify_auth["refresh_at"]: + return user.spotify_auth["access_token"] - token, expires_in = await refresh_token('https://accounts.spotify.com/api/token', - user.spotify_auth['refresh_token'], - config.spotify.encoded, - config.proxy - ) + token, expires_in = await refresh_token( + "https://accounts.spotify.com/api/token", + user.spotify_auth["refresh_token"], + get_encoded_creds( + user.spotify_auth["client_id"], user.spotify_auth["client_secret"] + ), + config.proxy, + ) async with get_session_context() as session: - await session.execute( - update(User).where(User.id == self.user_id).values(spotify_auth=get_oauth_creds(token, - user.spotify_auth['refresh_token'], - expires_in)) + user = await session.get(User, self.user_id) + user.spotify_auth.update( + get_oauth_creds(token, user.spotify_auth["refresh_token"], expires_in) ) - await session.commit() return token async def request(self, endpoint, token): user_headers = { - 'Authorization': 'Bearer ' + token, - 'Content-Type': 'application/json' + "Authorization": "Bearer " + token, + "Content-Type": "application/json", } async with aiohttp.ClientSession(proxy=config.proxy) as session: - resp = await session.get(f'https://api.spotify.com/v1{endpoint}', headers=user_headers) + resp = await session.get( + f"https://api.spotify.com/v1{endpoint}", headers=user_headers + ) if resp.status != 200: return None return await resp.json() @staticmethod def convert_track(track: dict): - if track['type'] != 'track': + if track["type"] != "track": return None return Track( - name=track['name'], - artist=', '.join(x['name'] for x in track['artists']), - cover_url=track['album']['images'][0]['url'], - spotify_id=track['id'] + name=track["name"], + artist=", ".join(x["name"] for x in track["artists"]), + cover_url=track["album"]["images"][0]["url"], + spotify_id=track["id"], ) async def get_tracks(self, token) -> list[Track]: current, recent = await asyncio.gather( - self.request('/me/player/currently-playing', token), - self.request('/me/player/recently-played', token) + self.request("/me/player/currently-playing", token), + self.request("/me/player/recently-played", token), ) tracks = [] if current: - tracks.append(self.convert_track(current['item'])) - for item in recent['items']: - tracks.append(self.convert_track(item['track'])) + tracks.append(self.convert_track(current["item"])) + for item in recent["items"]: + tracks.append(self.convert_track(item["track"])) tracks = [x for x in tracks if x] tracks = list(dict.fromkeys(tracks)) @@ -94,4 +97,4 @@ class SpotifyStrategy(MusicProviderStrategy): return track.spotify_id -__all__ = ['SpotifyStrategy'] +__all__ = ["SpotifyStrategy"] diff --git a/app/MusicProvider/Strategy.py b/app/MusicProvider/Strategy.py index 15a18ba..98307c7 100644 --- a/app/MusicProvider/Strategy.py +++ b/app/MusicProvider/Strategy.py @@ -25,4 +25,4 @@ class MusicProviderStrategy(ABC): @abstractmethod def track_id(self, track: Track): - pass \ No newline at end of file + pass diff --git a/app/MusicProvider/YMusicStrategy.py b/app/MusicProvider/YMusicStrategy.py index d12e354..8cb3682 100644 --- a/app/MusicProvider/YMusicStrategy.py +++ b/app/MusicProvider/YMusicStrategy.py @@ -11,7 +11,6 @@ from yandex_music import ClientAsync, TracksList from yandex_music.utils.request_async import Request - class YandexMusicStrategy(MusicProviderStrategy): async def fetch_track(self, track: Track) -> Track: async with get_session_context() as session: @@ -27,18 +26,23 @@ class YandexMusicStrategy(MusicProviderStrategy): if not user: return None - if int(time.time()) < user.ymusic_auth['refresh_at']: - return user.ymusic_auth['access_token'] + if int(time.time()) < user.ymusic_auth["refresh_at"]: + return user.ymusic_auth["access_token"] - token, expires_in = await refresh_token('https://oauth.yandex.com/token', - user.ymusic_auth['refresh_token'], - config.ymusic.encoded - ) + token, expires_in = await refresh_token( + "https://oauth.yandex.com/token", + user.ymusic_auth["refresh_token"], + config.ymusic.encoded, + ) async with get_session_context() as session: await session.execute( - update(User).where(User.id == self.user_id).values(spotify_auth=get_oauth_creds(token, - user.ymusic_auth['refresh_token'], - expires_in)) + update(User) + .where(User.id == self.user_id) + .values( + spotify_auth=get_oauth_creds( + token, user.ymusic_auth["refresh_token"], expires_in + ) + ) ) await session.commit() return token @@ -54,9 +58,9 @@ class YandexMusicStrategy(MusicProviderStrategy): return [ Track( name=x.title, - artist=', '.join([art.name for art in x.artists]), + artist=", ".join([art.name for art in x.artists]), ymusic_id=x.id, - cover_url=x.cover_uri + cover_url=x.cover_uri, ) for x in tracks ] diff --git a/app/MusicProvider/auth.py b/app/MusicProvider/auth.py index 4cb2b52..bfe9d71 100644 --- a/app/MusicProvider/auth.py +++ b/app/MusicProvider/auth.py @@ -1,3 +1,4 @@ +import base64 import time import aiohttp @@ -5,22 +6,25 @@ import aiohttp def get_oauth_creds(token, refresh_token, expires_in): return { - 'access_token': token, - 'refresh_token': refresh_token, - 'refresh_at': int(time.time()) + expires_in + "access_token": token, + "refresh_token": refresh_token, + "refresh_at": int(time.time()) + expires_in, } async def refresh_token(endpoint, refresh_token, creds, proxy=None): token_headers = { "Content-Type": "application/x-www-form-urlencoded", - 'Authorization': 'Basic ' + creds - } - token_data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token + "Authorization": "Basic " + creds, } + token_data = {"grant_type": "refresh_token", "refresh_token": refresh_token} async with aiohttp.ClientSession(proxy=proxy) as session: resp = await session.post(endpoint, data=token_data, headers=token_headers) resp = await resp.json() - return resp['access_token'], resp['expires_in'] + return resp["access_token"], resp["expires_in"] + + +def get_encoded_creds(client_id, client_secret): + return base64.b64encode(client_id.encode() + b":" + client_secret.encode()).decode( + "utf-8" + ) diff --git a/app/__init__.py b/app/__init__.py index 995e905..44fc32c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,7 +9,8 @@ from telethon.tl.types import ( UpdateBotInlineSend, TypeInputFile, DocumentAttributeAudio, - InputPeerSelf, InputDocument + InputPeerSelf, + InputDocument, ) from telethon.tl.custom import InlineBuilder from telethon import functions @@ -29,14 +30,14 @@ from app.models import Track, User from app.youtube_api import name_to_youtube, download_youtube logging.basicConfig( - format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", level=logging.INFO, ) logger = logging.getLogger(__name__) -client = TelegramClient('nowplaying', config.api_id, config.api_hash) -client.parse_mode = 'html' +client = TelegramClient("nowplaying", config.api_id, config.api_hash) +client.parse_mode = "html" cache = LRUCache(maxsize=100) @@ -46,63 +47,62 @@ async def get_user(user_id): def get_spotify_link(user_id) -> str: - params = { - 'state': user_id - } - return f"https://music.mootfrost.dev/spotify/authorize?{urllib.parse.urlencode(params)}" + params = {"state": user_id} + return f'{config.root_url}/spotify?{urllib.parse.urlencode(params)}' def get_ymusic_link(user_id) -> str: params = { - 'response_type': 'code', - 'client_id': config.ymusic.client_id, - 'state': user_id + "response_type": "code", + "client_id": config.ymusic.client_id, + "state": user_id, } return f"https://oauth.yandex.ru/authorize?{urllib.parse.urlencode(params)}" -@client.on(events.NewMessage(pattern='/start')) +@client.on(events.NewMessage(pattern="/start")) async def start(e: events.NewMessage.Event): - payload = { - 'tg_id': e.chat_id, - 'exp': int(time.time()) + 900 - } - enc_user_id = jwt.encode(payload, config.jwt_secret, algorithm='HS256') + payload = {"tg_id": e.chat_id, "exp": int(time.time()) + 900} + enc_user_id = jwt.encode(payload, config.jwt_secret, algorithm="HS256") buttons = [ - Button.url('Link Spotify', get_spotify_link(enc_user_id)), - Button.url('Link Yandex music', get_ymusic_link(enc_user_id)), + Button.url("Link Spotify", get_spotify_link(enc_user_id)), + Button.url("Link Yandex music", get_ymusic_link(enc_user_id)), ] - await e.respond("""Hi! I can help you share music you listen on Spotify or Yandex music. + await e.respond( + """Hi! I can help you share music you listen on Spotify or Yandex music. To use just type @listensharebot and select track. Press button below to authorize your account first """, - buttons=buttons) + buttons=buttons, + ) -@client.on(events.NewMessage(pattern='/default')) +@client.on(events.NewMessage(pattern="/default")) async def change_default(e: events.NewMessage.Event): user = await get_user(e.chat_id) if not user: - return await e.respond('Please link your account first') + return await e.respond("Please link your account first") buttons = [] if user.spotify_auth: - buttons.append(Button.inline('Spotify', 'default_spotify')) + buttons.append(Button.inline("Spotify", "default_spotify")) if user.ymusic_auth: - buttons.append(Button.inline('Yandex music', 'default_ymusic')) + buttons.append(Button.inline("Yandex music", "default_ymusic")) - await e.respond('Select service you want to use as default', buttons=buttons) + await e.respond("Select service you want to use as default", buttons=buttons) -@client.on(events.CallbackQuery(pattern='default_*')) +@client.on(events.CallbackQuery(pattern="default_*")) async def set_default(e: events.CallbackQuery.Event): async with get_session_context() as session: await session.execute( - update(User).where(User.id == e.sender_id).values(default=str(e.data).split('_')[1][:-1]) + update(User) + .where(User.id == e.sender_id) + .values(default=str(e.data).split("_")[1][:-1]) ) await session.commit() - await e.respond('Default service updated') + await e.respond("Default service updated") async def fetch_file(url) -> bytes: @@ -115,30 +115,24 @@ async def fetch_file(url) -> bytes: # TODO: make faster and somehow fix cover not displaying in response async def update_dummy_file_cover(cover_url: str): cover = await fetch_file(cover_url) - dummy_name = 'app/empty.mp3' + dummy_name = "app/empty.mp3" audio = ID3(dummy_name) - audio.delall('APIC') - audio.add(APIC( - encoding=3, - mime='image/jpeg', - type=3, - desc='Cover', - data=cover - )) + audio.delall("APIC") + audio.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover)) res = io.BytesIO() audio.save(res) - dummy_file = await client.upload_file(res.getvalue(), file_name='empty.mp3') + dummy_file = await client.upload_file(res.getvalue(), file_name="empty.mp3") async def build_response(track: Track, track_id: str, links: str): if not track.telegram_id: - dummy_file = await client.upload_file('app/empty.mp3') - buttons = [Button.inline('Loading', 'loading')] + dummy_file = await client.upload_file("app/empty.mp3") + buttons = [Button.inline("Loading", "loading")] else: dummy_file = InputDocument( id=track.telegram_id, access_hash=track.telegram_access_hash, - file_reference=track.telegram_file_reference + file_reference=track.telegram_file_reference, ) buttons = None return await InlineBuilder(client).document( @@ -146,7 +140,7 @@ async def build_response(track: Track, track_id: str, links: str): title=track.name, description=track.artist, id=track_id, - mime_type='audio/mpeg', + mime_type="audio/mpeg", attributes=[ DocumentAttributeAudio( duration=1, @@ -157,7 +151,7 @@ async def build_response(track: Track, track_id: str, links: str): ) ], text=links, - buttons=buttons + buttons=buttons, ) @@ -165,8 +159,10 @@ async def build_response(track: Track, track_id: str, links: str): async def query_list(e: events.InlineQuery.Event): user = await get_user(e.sender_id) if not user: - return await e.answer(switch_pm='Link account first', switch_pm_param='link') - if user.spotify_auth and (str(e.text) == 's' or user.default == 'spotify' and not str(e.text)): + return await e.answer(switch_pm="Link account first", switch_pm_param="link") + if user.spotify_auth and ( + str(e.text) == "s" or user.default == "spotify" and not str(e.text) + ): ctx = MusicProviderContext(SpotifyStrategy(e.sender_id)) else: ctx = MusicProviderContext(YandexMusicStrategy(e.sender_id)) @@ -177,7 +173,9 @@ async def query_list(e: events.InlineQuery.Event): track = await ctx.get_cached_track(track) music_id = ctx.strategy.track_id(track) cache[music_id] = track - result.append(await build_response(track, music_id, ctx.strategy.song_link(track))) + result.append( + await build_response(track, music_id, ctx.strategy.song_link(track)) + ) await e.answer(result) @@ -192,12 +190,11 @@ async def track_to_file(track): title=track.name, performer=track.artist, waveform=None, - )] + ) + ], ) uploaded_media = await client( - functions.messages.UploadMediaRequest( - InputPeerSelf(), media=media - ) + functions.messages.UploadMediaRequest(InputPeerSelf(), media=media) ) return get_input_document(uploaded_media.document) @@ -211,13 +208,11 @@ async def cache_file(track): async def download_track(track): yt_id = await asyncio.get_event_loop().run_in_executor( - None, functools.partial(name_to_youtube, f'{track.name} - {track.artist}') + None, functools.partial(name_to_youtube, f"{track.name} - {track.artist}") ) track.yt_id = yt_id async with get_session_context() as session: - existing = await session.scalar( - select(Track).where(Track.yt_id == yt_id) - ) + existing = await session.scalar(select(Track).where(Track.yt_id == yt_id)) if existing and existing.telegram_id: updated = False @@ -233,7 +228,7 @@ async def download_track(track): return InputDocument( id=existing.telegram_id, access_hash=existing.telegram_access_hash, - file_reference=existing.telegram_file_reference + file_reference=existing.telegram_file_reference, ) file = await track_to_file(track) @@ -256,8 +251,8 @@ async def send_track(e: UpdateBotInlineSend): async def main(): await client.start(bot_token=config.bot_token) - logger.info('Bot started') + logger.info("Bot started") await client.run_until_disconnected() -__all__ = ['main'] \ No newline at end of file +__all__ = ["main"] diff --git a/app/__main__.py b/app/__main__.py index 9d782d5..601234d 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -6,7 +6,6 @@ async def run(): await main() -if __name__ == '__main__': +if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(run()) - diff --git a/app/callback_listener.py b/app/callback_listener.py index 24888da..99b0ea8 100644 --- a/app/callback_listener.py +++ b/app/callback_listener.py @@ -1,8 +1,10 @@ +import time import urllib.parse from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from fastapi.responses import FileResponse, RedirectResponse +from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles import uvicorn from telethon import TelegramClient @@ -15,8 +17,8 @@ from app.models.user import User from config import config, OauthCreds from app.MusicProvider.auth import get_oauth_creds -client = TelegramClient('nowplaying_callback', config.api_id, config.api_hash) -client.parse_mode = 'html' +client = TelegramClient("nowplaying_callback", config.api_id, config.api_hash) +client.parse_mode = "html" @asynccontextmanager @@ -27,7 +29,8 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) -app.mount('/static', StaticFiles(directory='app/static', html=True), name='static') +app.mount("/static", StaticFiles(directory="app/static", html=True), name="static") +templates = Jinja2Templates(directory="app/static") class LinkException(Exception): @@ -36,33 +39,36 @@ class LinkException(Exception): @app.exception_handler(LinkException) async def link_exception_handler(request: Request, exc: LinkException): - return FileResponse('app/static/error.html', media_type='text/html') + return FileResponse("app/static/error.html", media_type="text/html") -async def code_to_token(code: str, uri: str, creds: OauthCreds, proxy=None) -> tuple[str, str, int]: +async def code_to_token( + code: str, uri: str, creds: OauthCreds, proxy=None +) -> tuple[str, str, int]: token_headers = { "Authorization": "Basic " + creds.encoded, - "Content-Type": "application/x-www-form-urlencoded" + "Content-Type": "application/x-www-form-urlencoded", } token_data = { "grant_type": "authorization_code", "code": code, - "redirect_uri": creds.redirect + "redirect_uri": creds.redirect, } async with aiohttp.ClientSession(proxy=proxy) as session: resp = await session.post(uri, data=token_data, headers=token_headers) resp = await resp.json() - if 'access_token' not in resp: + if "access_token" not in resp: raise LinkException() - return resp['access_token'], resp['refresh_token'], int(resp['expires_in']) + return resp["access_token"], resp["refresh_token"], int(resp["expires_in"]) -def get_decoded_id(string: str): +def decode_jwt(string: str): try: - return jwt.decode(string, config.jwt_secret, algorithms=['HS256'])['tg_id'] + return jwt.decode(string, config.jwt_secret, algorithms=['HS256']) except: raise LinkException() + second_provider_notification = """ \n\nYou just added second service, it will be used as default. If you want to use other one time just type y for Yandex music. or s for Spotify. @@ -72,11 +78,11 @@ You can change default service using /default command. def get_spotify_link(client_id, state) -> str: params = { - 'client_id': client_id, - 'response_type': 'code', - 'redirect_uri': config.spotify.redirect, - 'scope': 'user-read-recently-played user-read-currently-playing', - 'state': state + "client_id": client_id, + "response_type": "code", + "redirect_uri": f'{config.root_url}/spotify/callback', + "scope": "user-read-recently-played user-read-currently-playing", + "state": state, } return f"https://accounts.spotify.com/authorize?{urllib.parse.urlencode(params)}" @@ -87,72 +93,66 @@ class SpotifyAuthorizeRequest(BaseModel): state: str -@app.post('/spotify/authorize') -async def spotify_authorize(data: SpotifyAuthorizeRequest, session: AsyncSession = Depends(get_session)): - user_id = get_decoded_id(data.state) - creds = { - 'client_id': data.client_id, - 'client_secret': data.client_secret - } - user = await session.get(User, user_id) - if user: - user.spotify_auth = creds - else: - user = User(id=user_id, - spotify_auth=creds, - default='spotify' - ) - session.add(user) - await session.commit() - return {'redirect_url': get_spotify_link(data.client_id, client.state)} +@app.get("/spotify") +async def spotify_link(request: Request, state: str): + return templates.TemplateResponse(request=request, name='authorize_spotify.html', context={'state': state, + 'redirect_url': f'{config.root_url}/spotify/callback'}) -@app.get('/spotify/callback') -async def spotify_callback(code: str, state: str, session: AsyncSession = Depends(get_session)): - user_id = get_decoded_id(state) - token, refresh_token, expires_in = await code_to_token(code, 'https://accounts.spotify.com/api/token', config.spotify) +@app.post("/spotify/authorize") +async def spotify_authorize(data: SpotifyAuthorizeRequest): + user_id = decode_jwt(data.state)['tg_id'] + creds = {"client_id": data.client_id, "client_secret": data.client_secret, 'tg_id': user_id, 'exp': int(time.time()) + 300} + return {"redirect_url": get_spotify_link(data.client_id, jwt.encode(creds, config.jwt_secret, algorithm='HS256'))} + + +@app.get("/spotify/callback") +async def spotify_callback( + code: str, state: str, session: AsyncSession = Depends(get_session) +): + data = decode_jwt(state) + token, refresh_token, expires_in = await code_to_token( + code, "https://accounts.spotify.com/api/token", OauthCreds(client_id=data['client_id'], client_secret=data['client_secret'], redirect=f'{config.root_url}/spotify/callback') + ) creds = get_oauth_creds(token, refresh_token, expires_in) - user = await session.get(User, user_id) + creds['client_secret'] = data['client_secret'] + creds['client_id'] = data['client_id'] + user = await session.get(User, data['tg_id']) if user: user.spotify_auth = creds - user.default = 'spotify' + user.default = "spotify" else: - user = User(id=user_id, - spotify_auth=creds, - default='spotify' - ) + user = User(id=data['tg_id'], spotify_auth=creds, default="spotify") session.add(user) - await session.commit() reply = "Account linked!" if user.spotify_auth: reply += second_provider_notification - await client.send_message(user_id, reply) - return FileResponse('app/static/success.html', media_type='text/html') + await client.send_message(data['tg_id'], reply) + return FileResponse("app/static/success.html", media_type="text/html") - -@app.get('/ym/callback') -async def ym_callback(state: str, code: str, cid: str, session: AsyncSession = Depends(get_session)): - user_id = get_decoded_id(state) - token, refresh_token, expires_in = await code_to_token(code, 'https://oauth.yandex.com/token', config.ymusic, config.proxy) +@app.get("/ym/callback") +async def ym_callback( + state: str, code: str, cid: str, session: AsyncSession = Depends(get_session) +): + user_id = decode_jwt(state)['tg_id'] + token, refresh_token, expires_in = await code_to_token( + code, "https://oauth.yandex.com/token", config.ymusic, config.proxy + ) creds = get_oauth_creds(token, refresh_token, expires_in) user = await session.get(User, user_id) if user: user.ymusic_auth = creds - user.default = 'ymusic' + user.default = "ymusic" else: - user = User(id=user_id, - ymusic_auth=creds, - default='ymusic' - ) + user = User(id=user_id, ymusic_auth=creds, default="ymusic") session.add(user) - await session.commit() reply = "Account linked! Note, that currently bot only allows to share tracks from Liked playlist." if user.spotify_auth: reply += second_provider_notification await client.send_message(user_id, reply) - return FileResponse('app/static/success.html', media_type='text/html') + return FileResponse("app/static/success.html", media_type="text/html") -if __name__ == '__main__': - uvicorn.run(app, host='0.0.0.0', port=8080) +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/app/config.py b/app/config.py index cf7906e..364e0a0 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,6 @@ -import base64 - from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import BaseModel +from app.MusicProvider.auth import get_encoded_creds class OauthCreds(BaseModel): @@ -11,7 +10,7 @@ class OauthCreds(BaseModel): @property def encoded(self) -> str: - return base64.b64encode(self.client_id.encode() + b':' + self.client_secret.encode()).decode("utf-8") + return get_encoded_creds(self.client_id, self.client_secret) class GoogleApiCreds(BaseModel): @@ -20,21 +19,22 @@ class GoogleApiCreds(BaseModel): class Config(BaseSettings): - model_config = SettingsConfigDict(env_nested_delimiter='_', - env_nested_max_split=1) + model_config = SettingsConfigDict(env_nested_delimiter="_", env_nested_max_split=1) + bot_token: str api_id: int api_hash: str + db_string: str - spotify: OauthCreds + yt: GoogleApiCreds ymusic: OauthCreds - proxy: str | None = None - socks_proxy: str | None = None + proxy: str + root_url: str jwt_secret: str -config = Config(_env_file='.env') +config = Config(_env_file=".env") -__all__ = ['config', 'OauthCreds'] +__all__ = ["config", "OauthCreds"] diff --git a/app/db.py b/app/db.py index ca5a06f..a02eaeb 100644 --- a/app/db.py +++ b/app/db.py @@ -9,4 +9,4 @@ async_session = async_sessionmaker( expire_on_commit=False, ) -__all__ = ['engine', 'async_session'] +__all__ = ["engine", "async_session"] diff --git a/app/dependencies.py b/app/dependencies.py index 4c8226d..0b94def 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -5,11 +5,11 @@ from app.db import async_session async def get_session() -> AsyncSession: - async with async_session() as session: + async with async_session() as session, session.begin(): yield session @asynccontextmanager async def get_session_context() -> AsyncSession: - async with async_session() as session: + async with async_session() as session, session.begin(): yield session diff --git a/app/models/__init__.py b/app/models/__init__.py index 0f90465..87dab7d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,3 +1,3 @@ from app.models.base import Base from app.models.user import User -from app.models.track import Track \ No newline at end of file +from app.models.track import Track diff --git a/app/models/base.py b/app/models/base.py index 1c2dcc4..fa2b68a 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - pass \ No newline at end of file + pass diff --git a/app/models/track.py b/app/models/track.py index dc15efa..18b7a46 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -6,7 +6,7 @@ from sqlalchemy import BigInteger, LargeBinary, Integer, JSON class Track(Base): - __tablename__ = 'tracks' + __tablename__ = "tracks" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger) @@ -28,7 +28,9 @@ class Track(Base): def __eq__(self, other): if not isinstance(other, Track): return NotImplemented - return (self.spotify_id or self.ymusic_id) == (other.spotify_id or other.spotify_id) + return (self.spotify_id or self.ymusic_id) == ( + other.spotify_id or other.spotify_id + ) -__all__ = ['Track'] +__all__ = ["Track"] diff --git a/app/models/user.py b/app/models/user.py index 64d210e..e9458dd 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -7,7 +7,7 @@ from sqlalchemy import BigInteger class User(Base): - __tablename__ = 'users' + __tablename__ = "users" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) spotify_auth: Mapped[dict] = mapped_column(JSON, default={}) @@ -16,4 +16,4 @@ class User(Base): default: Mapped[str] -__all__ = ['User'] +__all__ = ["User"] diff --git a/app/static/authorize_spotify.html b/app/static/authorize_spotify.html index bbf5bdd..d0c00bb 100644 --- a/app/static/authorize_spotify.html +++ b/app/static/authorize_spotify.html @@ -71,11 +71,11 @@ -

Spotify API Setup

+

One more step

  1. Visit Spotify Developer Dashboard
  2. Log in and create an app
  3. -
  4. Fill any name and description. Set redirect url to: {{}}
  5. +
  6. Fill any name and description. Set redirect url to: {{ redirect_url }}
  7. Copy your Client ID and Client Secret
@@ -84,7 +84,7 @@ - +