Update spotify fetching, add user id salting
This commit is contained in:
parent
33b2bfaf23
commit
a237aa3e82
9 changed files with 240 additions and 137 deletions
|
@ -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']
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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__':
|
||||
|
|
|
@ -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']
|
|
@ -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
47
static/error.html
Normal 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
47
static/success.html
Normal 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>
|
40
success.html
40
success.html
|
@ -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
47
uv.lock
generated
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue