Merge pull request 'Merge spotify auth strategy' (#1) from Spotify-Rewrite into master
All checks were successful
Deploy Service / deploy (push) Successful in 28s

Reviewed-on: #1
This commit is contained in:
Mootfrost 2025-04-17 22:44:10 +03:00
commit eae7fceb48
28 changed files with 601 additions and 270 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,4 +22,4 @@ class MusicProviderContext:
res = await self.strategy.fetch_track(track)
if not res:
return track
return res
return res

View file

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

View file

@ -25,4 +25,4 @@ class MusicProviderStrategy(ABC):
@abstractmethod
def track_id(self, track: Track):
pass
pass

View file

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

View file

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

View file

@ -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,67 +47,62 @@ 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)}"
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()) + 300
}
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:
@ -119,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(
@ -150,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,
@ -161,7 +151,7 @@ async def build_response(track: Track, track_id: str, links: str):
)
],
text=links,
buttons=buttons
buttons=buttons,
)
@ -169,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))
@ -181,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)
@ -196,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)
@ -215,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
@ -237,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)
@ -260,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']
__all__ = ["main"]

View file

@ -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())

View file

@ -1,12 +1,15 @@
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
import aiohttp
import time
from pydantic import BaseModel
import jwt
from app.dependencies import get_session
@ -14,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
@ -26,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):
@ -35,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 <b>y for Yandex music</b>. or <b>s for Spotify</b>.
@ -69,52 +76,83 @@ You can change default service using /default command.
"""
@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)
def get_spotify_link(client_id, state) -> str:
params = {
"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)}"
class SpotifyAuthorizeRequest(BaseModel):
client_id: str
client_secret: str
state: str
@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.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)

View file

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

View file

@ -9,4 +9,4 @@ async_session = async_sessionmaker(
expire_on_commit=False,
)
__all__ = ['engine', 'async_session']
__all__ = ["engine", "async_session"]

View file

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

View file

@ -1,3 +1,3 @@
from app.models.base import Base
from app.models.user import User
from app.models.track import Track
from app.models.track import Track

View file

@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
pass

View file

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

View file

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

View file

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotify API Authorization</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;
padding: 20px;
}
h1 {
font-size: 1.5rem;
color: #1f2937;
margin-bottom: 1rem;
text-align: center;
}
ol {
padding-left: 1.25rem;
margin-bottom: 1.5rem;
color: #4b5563;
font-size: 0.95rem;
max-width: 400px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
color: #374151;
font-size: 0.95rem;
}
input {
width: 100%;
max-width: 400px;
padding: 0.6rem;
margin-bottom: 1rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 1rem;
background-color: white;
}
button {
background-color: #22c55e;
color: white;
border: none;
padding: 0.75rem;
font-size: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 400px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #16a34a;
}
</style>
</head>
<body>
<h1>One more step</h1>
<ol>
<li>Visit <a href="https://developer.spotify.com/dashboard" target="_blank">Spotify Developer Dashboard</a></li>
<li>Log in and create an app</li>
<li>Fill any name and description. Set redirect url to: {{ redirect_url }}</li>
<li>Copy your <strong>Client ID</strong> and <strong>Client Secret</strong></li>
</ol>
<label for="clientId">Client ID</label>
<input type="text" id="clientId" placeholder="Enter your Client ID" />
<label for="clientSecret">Client Secret</label>
<input type="password" id="clientSecret" placeholder="Enter your Client Secret" />
<input type="hidden" id="state" value="{{ state }}">
<button id="authorizeBtn" onclick="">Authorize</button>
<script>
document.getElementById('authorizeBtn').addEventListener('click', async () => {
const clientId = document.getElementById('clientId').value;
const clientSecret = document.getElementById('clientSecret').value;
try {
const response = await fetch(window.location.origin + '/spotify/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, state: document.querySelector('#state').value }),
});
if (!response.ok) {
throw new Error('Authorization failed');
}
const data = await response.json();
if (data.redirect_url) {
window.location.href = data.redirect_url;
} else {
alert('No redirect URL returned');
}
} catch (error) {
console.error('Error:', error);
alert('Something went wrong during authorization.');
}
});
</script>
</body>
</html>

104
app/static/privacy.html Normal file
View file

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Privacy Policy | NowPlaying Bot</title>
<style>
body {
font-family: system-ui, sans-serif;
background-color: #f9f9f9;
color: #333;
line-height: 1.6;
margin: 0;
padding: 0;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-radius: 8px;
}
h1, h2, h3 {
color: #111;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.container {
padding: 1.25rem;
}
}
</style>
</head>
<body>
<div class="container">
<section>
<h1>Privacy Policy</h1>
<p><strong>Last updated:</strong> 2025.04.17</p>
<h2>1. Information We Collect</h2>
<p>When you connect your music accounts, we collect the following data:</p>
<h3>Spotify</h3>
<ul>
<li>App Client ID & Secret (used server-side for API access)</li>
<li>Access Token (to retrieve your recently played tracks)</li>
</ul>
<h3>Yandex Music</h3>
<ul>
<li>Yandex Token (used to retrieve your recently played tracks)</li>
</ul>
<p>We do <strong>not</strong> collect or store any other personal information.</p>
<h2>2. How We Use Your Data</h2>
<p>We use the collected tokens <strong>only</strong> to:</p>
<ul>
<li>Access your recently played or currently playing music</li>
<li>Generate a message or preview to share on Telegram</li>
</ul>
<p>We do <strong>not</strong> use your data for analytics, advertising, tracking, or any other purposes.</p>
<h2>3. Data Storage & Security</h2>
<ul>
<li>Tokens are stored securely and encrypted on our server.</li>
<li>We do <strong>not</strong> share your information with any third parties.</li>
<li>Only essential server processes have access to these tokens.</li>
</ul>
<h2>4. Data Retention</h2>
<ul>
<li>Tokens are retained only as long as you use the bot.</li>
<li>You can delete your stored data at any time by messaging the bot with the command <code>/logout</code>.</li>
</ul>
<h2>5. Third-Party Services</h2>
<p>We rely on the official APIs of:</p>
<ul>
<li><a href="https://www.spotify.com" target="_blank" rel="noopener noreferrer">Spotify</a></li>
<li><a href="https://music.yandex.com" target="_blank" rel="noopener noreferrer">Yandex Music</a></li>
</ul>
<p>Please review their respective privacy policies for more information on how they handle your data.</br>
We also comply with telegram <a href="https://telegram.org/privacy-tpa" target="_blank" rel="noopener noreferrer">Standard Privacy Policy for Bots and Mini Apps</a>.</p>
<h2>6. Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Request access to your stored data</li>
<li>Revoke access at any time via Spotify or Yandex settings</li>
<li>Request permanent deletion of your data by messaging <code>/logout</code> to the bot</li>
</ul>
</section>
</div>
</body>
</html>

View file

@ -1,8 +1,8 @@
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
from yandex_music import Client
from yandex_music import Client
client = Client('').init()
client = Client("").init()
print(client.queues_list())
queues = client.tracks(19801159)
@ -13,14 +13,14 @@ last_queue = client.queue(queues[0].id)
last_track_id = last_queue.get_current_track()
last_track = last_track_id.fetch_track()
artists = ', '.join(last_track.artists_name())
artists = ", ".join(last_track.artists_name())
title = last_track.title
print(f'Сейчас играет: {artists} - {title}')
print(f"Сейчас играет: {artists} - {title}")
try:
lyrics = last_track.get_lyrics('LRC')
lyrics = last_track.get_lyrics("LRC")
print(lyrics.fetch_lyrics())
print(f'\nИсточник: {lyrics.major.pretty_name}')
print(f"\nИсточник: {lyrics.major.pretty_name}")
except NotFoundError:
print('Текст песни отсутствует')
print("Текст песни отсутствует")

View file

@ -8,26 +8,30 @@ from yt_dlp import YoutubeDL
from app.config import config
ytmusic = YTMusic('oauth.json',
oauth_credentials=OAuthCredentials(client_id=config.yt.client_id, client_secret=config.yt.client_secret))
ytmusic = YTMusic(
"oauth.json",
oauth_credentials=OAuthCredentials(
client_id=config.yt.client_id, client_secret=config.yt.client_secret
),
)
# if config.proxy:
# ytmusic.proxies = {'http': config.proxy, 'https': config.proxy}
def name_to_youtube(name: str):
results = ytmusic.search(name, 'songs', limit=2)
results = ytmusic.search(name, "songs", limit=2)
print(results[0])
return results[0]['videoId']
return results[0]["videoId"]
def _download(yt_id: str, directory: str):
params = {
'format': 'bestaudio',
'quiet': True,
'outtmpl': os.path.join(directory, 'dl.%(ext)s')
"format": "bestaudio",
"quiet": True,
"outtmpl": os.path.join(directory, "dl.%(ext)s"),
}
if config.socks_proxy:
params['proxy'] = config.socks_proxy
if config.proxy:
params["proxy"] = config.proxy
with YoutubeDL(params) as ydl:
return ydl.extract_info(yt_id)
@ -37,14 +41,14 @@ async def download_youtube(yt_id: str) -> tuple[BytesIO, int]:
info = await asyncio.get_event_loop().run_in_executor(
None, functools.partial(_download, yt_id, tmpdir)
)
duration = info['duration']
duration = info["duration"]
files = os.listdir(tmpdir)
assert len(files) == 1
fn = os.path.join(tmpdir, files[0])
fn2 = os.path.join(tmpdir, 'audio.mp3')
fn2 = os.path.join(tmpdir, "audio.mp3")
proc = await asyncio.create_subprocess_exec(
'ffmpeg',
'-i',
"ffmpeg",
"-i",
fn,
fn2,
stdin=asyncio.subprocess.DEVNULL,
@ -53,7 +57,7 @@ async def download_youtube(yt_id: str) -> tuple[BytesIO, int]:
)
await proc.wait()
assert proc.returncode == 0
with open(fn2, 'rb') as f:
with open(fn2, "rb") as f:
res = BytesIO(f.read())
res.name = os.path.basename(fn2)
return res, duration
return res, duration

View file

@ -10,6 +10,7 @@ dependencies = [
"asyncpg>=0.30.0",
"cachetools>=5.5.2",
"fastapi>=0.115.12",
"jinja2>=3.1.6",
"mediafile>=0.13.0",
"mutagen>=1.47.0",
"psycopg2>=2.9.10",

14
uv.lock generated
View file

@ -275,6 +275,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "mako"
version = "1.3.9"
@ -377,6 +389,7 @@ dependencies = [
{ name = "asyncpg" },
{ name = "cachetools" },
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "mediafile" },
{ name = "mutagen" },
{ name = "psycopg2" },
@ -398,6 +411,7 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30.0" },
{ name = "cachetools", specifier = ">=5.5.2" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "mediafile", specifier = ">=0.13.0" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "psycopg2", specifier = ">=2.9.10" },