From a237aa3e82b52f713b161ec963c9c6004bd739c7 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 5 Apr 2025 18:30:33 +0300 Subject: [PATCH] Update spotify fetching, add user id salting --- app/MusicProvider/SpotifyStrategy.py | 90 +++++++++++++++++----------- app/__init__.py | 15 +++-- app/callback_listener.py | 83 +++++++++---------------- app/config.py | 6 +- pyproject.toml | 2 + static/error.html | 47 +++++++++++++++ static/success.html | 47 +++++++++++++++ success.html | 40 ------------- uv.lock | 47 +++++++++++++++ 9 files changed, 240 insertions(+), 137 deletions(-) create mode 100644 static/error.html create mode 100644 static/success.html delete mode 100644 success.html diff --git a/app/MusicProvider/SpotifyStrategy.py b/app/MusicProvider/SpotifyStrategy.py index 2ccf947..573c819 100644 --- a/app/MusicProvider/SpotifyStrategy.py +++ b/app/MusicProvider/SpotifyStrategy.py @@ -1,9 +1,10 @@ +import asyncio import base64 import time import aiohttp -from app.config import config +from app.config import config, spotify_creds from app.MusicProvider.Strategy import MusicProviderStrategy from app.dependencies import get_session, get_session_context from sqlalchemy import select, update @@ -11,10 +12,38 @@ from sqlalchemy import select, update from app.models import User, Track -creds = base64.b64encode(config.spotify.client_id.encode() + b':' + config.spotify.client_secret.encode()).decode("utf-8") +def convert_track(track: dict): + 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'] + ) + + +async def refresh_token(refresh_token): + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + 'Authorization': 'Basic ' + spotify_creds + } + token_data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token + } + async with aiohttp.ClientSession() as session: + resp = await session.post("https://accounts.spotify.com/api/token", data=token_data, headers=token_headers) + resp = await resp.json() + return resp['access_token'], resp['expires_in'] class SpotifyStrategy(MusicProviderStrategy): + def __init__(self, user_id): + super().__init__(user_id) + self.token = None + async def handle_token(self): async with get_session_context() as session: res = await session.execute(select(User).where(User.id == self.user_id)) @@ -25,55 +54,44 @@ class SpotifyStrategy(MusicProviderStrategy): if int(time.time()) < user.spotify_refresh_at: return user.spotify_access_token - token_headers = { - "Content-Type": "application/x-www-form-urlencoded", - 'Authorization': 'Basic ' + creds - } - token_data = { - "grant_type": "refresh_token", - "refresh_token": user.spotify_refresh_token - } - async with aiohttp.ClientSession() as session: - resp = await session.post("https://accounts.spotify.com/api/token", data=token_data, headers=token_headers) - resp = await resp.json() - token, expires_in = resp['access_token'], resp['expires_in'] + token, expires_in = await refresh_token(user.spotify_refresh_token) async with get_session_context() as session: await session.execute( update(User).where(User.id == self.user_id).values(spotify_access_token=token, - spotify_refresh_at=int(time.time()) + int(expires_in) - ) + spotify_refresh_at=int(time.time()) + int(expires_in)) ) await session.commit() return token - async def get_tracks(self, token) -> list[Track]: + async def request(self, endpoint, token): user_headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } - user_params = { - 'limit': 1 - } - res = [] async with aiohttp.ClientSession() as session: - resp = await session.get('https://api.spotify.com/v1/me/player/recently-played', params=user_params, headers=user_headers) - data = await resp.json() - for i, el in enumerate(data['items']): - track = el['track'] - resp = await session.get(f'https://api.spotify.com/v1/albums/{track['album']['id']}', headers=user_headers) - album = await resp.json() - cover = album['images'][0] - res.append(Track( - name=track['name'], - artist=', '.join(x['name'] for x in track['artists']), - cover_url=cover['url'], - spotify_id=track['id'] - )) - return res + resp = await session.get(f'https://api.spotify.com/v1{endpoint}', headers=user_headers) + return await resp.json() + + 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) + ) + tracks = [] + if current: + tracks.append(convert_track(current['item'])) + for item in recent['items']: + tracks.append(convert_track(item['track'])) + + tracks = [x for x in tracks if x] + return tracks async def fetch_track(self, track: Track): async with get_session_context() as session: resp = await session.execute( select(Track).where(Track.spotify_id == track.spotify_id) ) - return resp.scalars().first() \ No newline at end of file + return resp.scalars().first() + + +__all__ = ['SpotifyStrategy'] diff --git a/app/__init__.py b/app/__init__.py index 9b9ae38..991db1e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,12 +1,13 @@ import asyncio import functools import io +import time + import aiohttp from telethon import TelegramClient, events, Button from telethon.tl.types import ( UpdateBotInlineSend, TypeInputFile, - InputFile, DocumentAttributeAudio, InputPeerSelf, InputDocument ) @@ -16,6 +17,7 @@ import urllib.parse from mutagen.id3 import ID3, APIC import logging from cachetools import LRUCache +import jwt from app.MusicProvider import MusicProviderContext, SpotifyStrategy @@ -49,12 +51,17 @@ async def start(e: events.NewMessage.Event): @client.on(events.CallbackQuery(pattern='connect_spotify')) async def connect_spotify(e: events.CallbackQuery.Event): + payload = { + 'tg_id': e.sender_id, + 'exp': int(time.time()) + 300 + } + params = { 'client_id': config.spotify.client_id, 'response_type': 'code', 'redirect_uri': config.spotify.redirect, - 'scope': 'user-read-recently-played', - 'state': f'tg_{e.sender_id}' + 'scope': 'user-read-recently-played user-read-currently-playing', + 'state': jwt.encode(payload, config.jwt_secret, algorithm='HS256') } await e.respond('Link your Spotify account', buttons=[Button.url('Link', f"https://accounts.spotify.com/authorize?{urllib.parse.urlencode(params)}")]) @@ -130,7 +137,7 @@ async def build_response(e: events.InlineQuery.Event, track: Track): @client.on(events.InlineQuery()) async def query_list(e: events.InlineQuery.Event): context = MusicProviderContext(SpotifyStrategy(e.sender_id)) - tracks = await context.get_tracks() + tracks = (await context.get_tracks())[:5] result = [] for track in tracks: diff --git a/app/callback_listener.py b/app/callback_listener.py index d5874d2..712bf90 100644 --- a/app/callback_listener.py +++ b/app/callback_listener.py @@ -1,36 +1,44 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Depends +from fastapi import FastAPI, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession -from fastapi.responses import HTMLResponse +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles import uvicorn from telethon import TelegramClient -import base64 import aiohttp import time +import jwt from app.dependencies import get_session from app.models.user import User -from config import config +from config import config, spotify_creds client = TelegramClient('nowplaying_callback', config.api_id, config.api_hash) @asynccontextmanager async def lifespan(app: FastAPI): - global r await client.connect() await client.sign_in(bot_token=config.bot_token) yield app = FastAPI(lifespan=lifespan) -creds = base64.b64encode(config.spotify.client_id.encode() + b':' + config.spotify.client_secret.encode()).decode( - "utf-8") +app.mount('/static', StaticFiles(directory='static', html=True), name='static') -async def get_spotify_token(code: str, user_id: int): +class LinkException(Exception): + pass + + +@app.exception_handler(LinkException) +async def unicorn_exception_handler(request: Request, exc: LinkException): + return FileResponse('static/error.html', status_code=400) + + +async def get_spotify_token(code: str): token_headers = { - "Authorization": "Basic " + creds, + "Authorization": "Basic " + spotify_creds, "Content-Type": "application/x-www-form-urlencoded" } token_data = { @@ -43,57 +51,20 @@ async def get_spotify_token(code: str, user_id: int): resp = await session.post("https://accounts.spotify.com/api/token", data=token_data, headers=token_headers) resp = await resp.json() + if 'access_token' not in resp: + raise LinkException() return resp['access_token'], resp['refresh_token'], int(resp['expires_in']) -def generate_success_reponse(): - content = """ - - - - - Success - - - -

@nowlisten bot

-

Success! Now you can return to the bot

- -""" - return HTMLResponse(content=content) - - @app.get('/spotify_callback') async def spotify_callback(code: str, state: str, session: AsyncSession = Depends(get_session)): - user_id = int(state.replace('tg_', '')) - token, refresh_token, expires_in = await get_spotify_token(code, user_id) + try: + user_id = jwt.decode(state, config.jwt_secret, algorithms=['HS256'])['tg_id'] + except: + raise LinkException() + + + token, refresh_token, expires_in = await get_spotify_token(code) user = await session.get(User, user_id) if user: user.spotify_access_token = token @@ -108,7 +79,7 @@ async def spotify_callback(code: str, state: str, session: AsyncSession = Depend session.add(user) await session.commit() await client.send_message(user_id, "Account linked!") - return generate_success_reponse() + return FileResponse('static/success.html', media_type='text/html') if __name__ == '__main__': diff --git a/app/config.py b/app/config.py index e770dc6..38c2dcc 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,5 @@ +import base64 + from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import BaseModel @@ -23,8 +25,10 @@ class Config(BaseSettings): spotify: SpotifyCreds yt: GoogleApiCreds proxy: str = '' + jwt_secret: str config = Config(_env_file='.env') +spotify_creds = base64.b64encode(config.spotify.client_id.encode() + b':' + config.spotify.client_secret.encode()).decode("utf-8") -__all__ = ['config'] \ No newline at end of file +__all__ = ['config', 'spotify_creds'] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fc9fc4a..29a5a61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,13 @@ dependencies = [ "mutagen>=1.47.0", "psycopg2>=2.9.10", "pydantic-settings>=2.8.1", + "pyjwt>=2.10.1", "pytaglib>=3.0.1", "requests>=2.32.3", "sqlalchemy[asyncio]>=2.0.40", "telethon>=1.39.0", "uvicorn>=0.34.0", + "yandex-music>=2.2.0", "yt-dlp>=2025.3.31", "ytmusicapi>=1.10.3", ] diff --git a/static/error.html b/static/error.html new file mode 100644 index 0000000..aae3f07 --- /dev/null +++ b/static/error.html @@ -0,0 +1,47 @@ + + + + + + Success + + + +
+

Something went wrong

+

Try to link account again

+ + \ No newline at end of file diff --git a/static/success.html b/static/success.html new file mode 100644 index 0000000..f053d8b --- /dev/null +++ b/static/success.html @@ -0,0 +1,47 @@ + + + + + + Success + + + +
+

Success!

+

Now you can return to the bot.

+ + \ No newline at end of file diff --git a/success.html b/success.html deleted file mode 100644 index 8d71719..0000000 --- a/success.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - Success - - - -Music Bot Logo -

Success! Now you can return to the bot

- - \ No newline at end of file diff --git a/uv.lock b/uv.lock index 7edf106..5946fb4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,15 @@ version = 1 requires-python = ">=3.13" +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -372,11 +381,13 @@ dependencies = [ { name = "mutagen" }, { name = "psycopg2" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "pytaglib" }, { name = "requests" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "telethon" }, { name = "uvicorn" }, + { name = "yandex-music" }, { name = "yt-dlp" }, { name = "ytmusicapi" }, ] @@ -392,11 +403,13 @@ requires-dist = [ { name = "mutagen", specifier = ">=1.47.0" }, { name = "psycopg2", specifier = ">=2.9.10" }, { name = "pydantic-settings", specifier = ">=2.8.1" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "pytaglib", specifier = ">=3.0.1" }, { name = "requests", specifier = ">=2.32.3" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.40" }, { name = "telethon", specifier = ">=1.39.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, + { name = "yandex-music", specifier = ">=2.2.0" }, { name = "yt-dlp", specifier = ">=2025.3.31" }, { name = "ytmusicapi", specifier = ">=1.10.3" }, ] @@ -531,6 +544,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, +] + [[package]] name = "pytaglib" version = "3.0.1" @@ -568,6 +599,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + [[package]] name = "rsa" version = "4.9" @@ -683,6 +719,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, ] +[[package]] +name = "yandex-music" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "requests", extra = ["socks"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/85/9f1d9e1327b558d74ee43e3d3f84b7b7e536c79a372cf83fcbf392ab2d3c/yandex-music-2.2.0.tar.gz", hash = "sha256:46beaacf8206de212323264c89e0bd4f4e2e626483dfa58f95e78cacd1a102cf", size = 168538 } + [[package]] name = "yarl" version = "1.18.3"