Update spotify fetching, add user id salting

This commit is contained in:
unknown 2025-04-05 18:30:33 +03:00
parent 33b2bfaf23
commit a237aa3e82
9 changed files with 240 additions and 137 deletions

View file

@ -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()
return resp.scalars().first()
__all__ = ['SpotifyStrategy']

View file

@ -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:

View file

@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Success</title>
<style>
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #ffffff;
font-family: 'Poppins', sans-serif;
text-align: center;
color: #000;
}
img {
max-width: 120px;
margin-bottom: 20px;
}
h1 {
font-size: 28px;
font-weight: 600;
color: #000;
margin-bottom: 10px;
}
p {
font-size: 18px;
font-weight: 400;
opacity: 0.7;
}
</style>
</head>
<body>
<h1>@nowlisten bot</h1>
<h1>Success! Now you can return to the bot</h1>
</body>
</html>"""
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__':

View file

@ -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']
__all__ = ['config', 'spotify_creds']

View file

@ -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",
]

47
static/error.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Success</title>
<style>
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
flex-direction: column;
}
.logo {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.icon {
font-size: 3rem;
color: #22c55e;
margin-bottom: 1rem;
}
h1 {
font-size: 2rem;
color: #1f2937;
margin-bottom: 0.5rem;
}
p {
font-size: 1.125rem;
color: #4b5563;
}
</style>
</head>
<body>
<div class="icon"></div>
<h1>Something went wrong</h1>
<p>Try to link account again</p>
</body>
</html>

47
static/success.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Success</title>
<style>
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
flex-direction: column;
}
.logo {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.icon {
font-size: 3rem;
color: #22c55e;
margin-bottom: 1rem;
}
h1 {
font-size: 2rem;
color: #1f2937;
margin-bottom: 0.5rem;
}
p {
font-size: 1.125rem;
color: #4b5563;
}
</style>
</head>
<body>
<div class="icon"></div>
<h1>Success!</h1>
<p>Now you can return to the bot.</p>
</body>
</html>

View file

@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Success</title>
<style>
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #ffffff;
font-family: 'Poppins', sans-serif;
text-align: center;
color: #000;
}
img {
max-width: 120px;
margin-bottom: 20px;
}
h1 {
font-size: 28px;
font-weight: 600;
color: #000;
margin-bottom: 10px;
}
p {
font-size: 18px;
font-weight: 400;
opacity: 0.7;
}
</style>
</head>
<body>
<img src="bot-logo.png" alt="Music Bot Logo">
<h1>Success! Now you can return to the bot</h1>
</body>
</html>

47
uv.lock generated
View file

@ -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"