diff --git a/alembic/env.py b/alembic/env.py index 582194e..18dbe30 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -17,7 +17,7 @@ config = context.config # This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) -config.set_main_option('sqlalchemy.url', app_config.db_string) +config.set_main_option("sqlalchemy.url", app_config.db_string) # add your model's MetaData object here # for 'autogenerate' support diff --git a/alembic/versions/59bdc35f510c_change_creds_storage.py b/alembic/versions/59bdc35f510c_change_creds_storage.py index 56ec81b..96e45eb 100644 --- a/alembic/versions/59bdc35f510c_change_creds_storage.py +++ b/alembic/versions/59bdc35f510c_change_creds_storage.py @@ -5,6 +5,7 @@ Revises: fc8875e47bc0 Create Date: 2025-04-09 23:28:32.649596 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '59bdc35f510c' -down_revision: Union[str, None] = 'fc8875e47bc0' +revision: str = "59bdc35f510c" +down_revision: Union[str, None] = "fc8875e47bc0" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,22 +22,40 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('spotify_auth', sa.JSON(), nullable=False)) - op.add_column('users', sa.Column('ymusic_auth', sa.JSON(), nullable=False)) - op.drop_column('users', 'ymusic_token') - op.drop_column('users', 'spotify_refresh_token') - op.drop_column('users', 'spotify_refresh_at') - op.drop_column('users', 'spotify_access_token') + op.add_column("users", sa.Column("spotify_auth", sa.JSON(), nullable=False)) + op.add_column("users", sa.Column("ymusic_auth", sa.JSON(), nullable=False)) + op.drop_column("users", "ymusic_token") + op.drop_column("users", "spotify_refresh_token") + op.drop_column("users", "spotify_refresh_at") + op.drop_column("users", "spotify_access_token") # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('spotify_access_token', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.add_column('users', sa.Column('spotify_refresh_at', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('users', sa.Column('spotify_refresh_token', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.add_column('users', sa.Column('ymusic_token', sa.VARCHAR(), autoincrement=False, nullable=True)) - op.drop_column('users', 'ymusic_auth') - op.drop_column('users', 'spotify_auth') + op.add_column( + "users", + sa.Column( + "spotify_access_token", sa.VARCHAR(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "users", + sa.Column( + "spotify_refresh_at", sa.INTEGER(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "users", + sa.Column( + "spotify_refresh_token", sa.VARCHAR(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "users", + sa.Column("ymusic_token", sa.VARCHAR(), autoincrement=False, nullable=True), + ) + op.drop_column("users", "ymusic_auth") + op.drop_column("users", "spotify_auth") # ### end Alembic commands ### diff --git a/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py b/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py index 890a96c..16fd21c 100644 --- a/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py +++ b/alembic/versions/934c5c1d1f4a_id_from_int_to_bigint.py @@ -5,6 +5,7 @@ Revises: 9f96b664be50 Create Date: 2025-04-15 18:14:14.644341 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '934c5c1d1f4a' -down_revision: Union[str, None] = '9f96b664be50' +revision: str = "934c5c1d1f4a" +down_revision: Union[str, None] = "9f96b664be50" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,20 +22,26 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('tracks', 'id', - existing_type=sa.INTEGER(), - type_=sa.BigInteger(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "tracks", + "id", + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('tracks', 'id', - existing_type=sa.BigInteger(), - type_=sa.INTEGER(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "tracks", + "id", + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### diff --git a/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py b/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py index 35335c3..4d9934f 100644 --- a/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py +++ b/alembic/versions/9b7a211cc587_id_from_int_to_bigint.py @@ -5,6 +5,7 @@ Revises: 934c5c1d1f4a Create Date: 2025-04-15 18:17:57.302668 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '9b7a211cc587' -down_revision: Union[str, None] = '934c5c1d1f4a' +revision: str = "9b7a211cc587" +down_revision: Union[str, None] = "934c5c1d1f4a" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,20 +22,26 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('users', 'id', - existing_type=sa.INTEGER(), - type_=sa.BigInteger(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "users", + "id", + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('users', 'id', - existing_type=sa.BigInteger(), - type_=sa.INTEGER(), - existing_nullable=False, - autoincrement=True) + op.alter_column( + "users", + "id", + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False, + autoincrement=True, + ) # ### end Alembic commands ### diff --git a/alembic/versions/9f96b664be50_add_default_field.py b/alembic/versions/9f96b664be50_add_default_field.py index 1262fd8..e7e7974 100644 --- a/alembic/versions/9f96b664be50_add_default_field.py +++ b/alembic/versions/9f96b664be50_add_default_field.py @@ -5,6 +5,7 @@ Revises: 59bdc35f510c Create Date: 2025-04-10 21:33:30.353105 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision: str = '9f96b664be50' -down_revision: Union[str, None] = '59bdc35f510c' +revision: str = "9f96b664be50" +down_revision: Union[str, None] = "59bdc35f510c" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,14 +22,22 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('tracks', 'telegram_reference') - op.add_column('users', sa.Column('default', sa.String(), nullable=False)) + op.drop_column("tracks", "telegram_reference") + op.add_column("users", sa.Column("default", sa.String(), nullable=False)) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('users', 'default') - op.add_column('tracks', sa.Column('telegram_reference', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_column("users", "default") + op.add_column( + "tracks", + sa.Column( + "telegram_reference", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + ) # ### end Alembic commands ### diff --git a/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py b/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py index 2b60c0d..71a2318 100644 --- a/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py +++ b/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py @@ -1,10 +1,11 @@ """add yandex music fileds Revision ID: e4a1561c2b63 -Revises: +Revises: Create Date: 2025-04-05 21:22:09.221527 """ + from typing import Sequence, Union from alembic import op @@ -12,7 +13,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'e4a1561c2b63' +revision: str = "e4a1561c2b63" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,26 +22,28 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('tracks', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('telegram_id', sa.BigInteger(), nullable=True), - sa.Column('telegram_access_hash', sa.BigInteger(), nullable=True), - sa.Column('telegram_file_reference', sa.LargeBinary(), nullable=True), - sa.Column('spotify_id', sa.String(), nullable=True), - sa.Column('ymusic_id', sa.String(), nullable=True), - sa.Column('name', sa.String(), nullable=False), - sa.Column('artist', sa.String(), nullable=False), - sa.Column('cover_url', sa.String(), nullable=False), - sa.Column('used_times', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "tracks", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("telegram_id", sa.BigInteger(), nullable=True), + sa.Column("telegram_access_hash", sa.BigInteger(), nullable=True), + sa.Column("telegram_file_reference", sa.LargeBinary(), nullable=True), + sa.Column("spotify_id", sa.String(), nullable=True), + sa.Column("ymusic_id", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("artist", sa.String(), nullable=False), + sa.Column("cover_url", sa.String(), nullable=False), + sa.Column("used_times", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('spotify_access_token', sa.String(), nullable=True), - sa.Column('spotify_refresh_token', sa.String(), nullable=True), - sa.Column('spotify_refresh_at', sa.Integer(), nullable=True), - sa.Column('ymusic_token', sa.String(), nullable=True), - sa.PrimaryKeyConstraint('id') + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("spotify_access_token", sa.String(), nullable=True), + sa.Column("spotify_refresh_token", sa.String(), nullable=True), + sa.Column("spotify_refresh_at", sa.Integer(), nullable=True), + sa.Column("ymusic_token", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### @@ -48,6 +51,6 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('users') - op.drop_table('tracks') + op.drop_table("users") + op.drop_table("tracks") # ### end Alembic commands ### diff --git a/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py b/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py index 89426b2..629eeed 100644 --- a/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py +++ b/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py @@ -5,6 +5,7 @@ Revises: e4a1561c2b63 Create Date: 2025-04-08 23:34:01.812378 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'fc8875e47bc0' -down_revision: Union[str, None] = 'e4a1561c2b63' +revision: str = "fc8875e47bc0" +down_revision: Union[str, None] = "e4a1561c2b63" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,14 +22,14 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('tracks', sa.Column('telegram_reference', sa.JSON(), nullable=True)) - op.add_column('tracks', sa.Column('yt_id', sa.String(), nullable=True)) + op.add_column("tracks", sa.Column("telegram_reference", sa.JSON(), nullable=True)) + op.add_column("tracks", sa.Column("yt_id", sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('tracks', 'yt_id') - op.drop_column('tracks', 'telegram_reference') + op.drop_column("tracks", "yt_id") + op.drop_column("tracks", "telegram_reference") # ### end Alembic commands ### diff --git a/app/MusicProvider/Context.py b/app/MusicProvider/Context.py index 340de78..95ff15b 100644 --- a/app/MusicProvider/Context.py +++ b/app/MusicProvider/Context.py @@ -22,4 +22,4 @@ class MusicProviderContext: res = await self.strategy.fetch_track(track) if not res: return track - return res \ No newline at end of file + return res diff --git a/app/MusicProvider/SpotifyStrategy.py b/app/MusicProvider/SpotifyStrategy.py index c3cff6d..19f9831 100644 --- a/app/MusicProvider/SpotifyStrategy.py +++ b/app/MusicProvider/SpotifyStrategy.py @@ -9,7 +9,7 @@ from app.dependencies import get_session_context from sqlalchemy import select, update from app.models import User, Track -from app.MusicProvider.auth import refresh_token, get_oauth_creds +from app.MusicProvider.auth import refresh_token, get_oauth_creds, get_encoded_creds class SpotifyStrategy(MusicProviderStrategy): @@ -24,56 +24,59 @@ class SpotifyStrategy(MusicProviderStrategy): if not user: return None - if int(time.time()) < user.spotify_auth['refresh_at']: - return user.spotify_auth['access_token'] + if int(time.time()) < user.spotify_auth["refresh_at"]: + return user.spotify_auth["access_token"] - token, expires_in = await refresh_token('https://accounts.spotify.com/api/token', - user.spotify_auth['refresh_token'], - config.spotify.encoded, - config.proxy - ) + token, expires_in = await refresh_token( + "https://accounts.spotify.com/api/token", + user.spotify_auth["refresh_token"], + get_encoded_creds( + user.spotify_auth["client_id"], user.spotify_auth["client_secret"] + ), + config.proxy, + ) async with get_session_context() as session: - await session.execute( - update(User).where(User.id == self.user_id).values(spotify_auth=get_oauth_creds(token, - user.spotify_auth['refresh_token'], - expires_in)) + user = await session.get(User, self.user_id) + user.spotify_auth.update( + get_oauth_creds(token, user.spotify_auth["refresh_token"], expires_in) ) - await session.commit() return token async def request(self, endpoint, token): user_headers = { - 'Authorization': 'Bearer ' + token, - 'Content-Type': 'application/json' + "Authorization": "Bearer " + token, + "Content-Type": "application/json", } async with aiohttp.ClientSession(proxy=config.proxy) as session: - resp = await session.get(f'https://api.spotify.com/v1{endpoint}', headers=user_headers) + resp = await session.get( + f"https://api.spotify.com/v1{endpoint}", headers=user_headers + ) if resp.status != 200: return None return await resp.json() @staticmethod def convert_track(track: dict): - if track['type'] != 'track': + if track["type"] != "track": return None return Track( - name=track['name'], - artist=', '.join(x['name'] for x in track['artists']), - cover_url=track['album']['images'][0]['url'], - spotify_id=track['id'] + name=track["name"], + artist=", ".join(x["name"] for x in track["artists"]), + cover_url=track["album"]["images"][0]["url"], + spotify_id=track["id"], ) async def get_tracks(self, token) -> list[Track]: current, recent = await asyncio.gather( - self.request('/me/player/currently-playing', token), - self.request('/me/player/recently-played', token) + self.request("/me/player/currently-playing", token), + self.request("/me/player/recently-played", token), ) tracks = [] if current: - tracks.append(self.convert_track(current['item'])) - for item in recent['items']: - tracks.append(self.convert_track(item['track'])) + tracks.append(self.convert_track(current["item"])) + for item in recent["items"]: + tracks.append(self.convert_track(item["track"])) tracks = [x for x in tracks if x] tracks = list(dict.fromkeys(tracks)) @@ -94,4 +97,4 @@ class SpotifyStrategy(MusicProviderStrategy): return track.spotify_id -__all__ = ['SpotifyStrategy'] +__all__ = ["SpotifyStrategy"] diff --git a/app/MusicProvider/Strategy.py b/app/MusicProvider/Strategy.py index 15a18ba..98307c7 100644 --- a/app/MusicProvider/Strategy.py +++ b/app/MusicProvider/Strategy.py @@ -25,4 +25,4 @@ class MusicProviderStrategy(ABC): @abstractmethod def track_id(self, track: Track): - pass \ No newline at end of file + pass diff --git a/app/MusicProvider/YMusicStrategy.py b/app/MusicProvider/YMusicStrategy.py index d12e354..8cb3682 100644 --- a/app/MusicProvider/YMusicStrategy.py +++ b/app/MusicProvider/YMusicStrategy.py @@ -11,7 +11,6 @@ from yandex_music import ClientAsync, TracksList from yandex_music.utils.request_async import Request - class YandexMusicStrategy(MusicProviderStrategy): async def fetch_track(self, track: Track) -> Track: async with get_session_context() as session: @@ -27,18 +26,23 @@ class YandexMusicStrategy(MusicProviderStrategy): if not user: return None - if int(time.time()) < user.ymusic_auth['refresh_at']: - return user.ymusic_auth['access_token'] + if int(time.time()) < user.ymusic_auth["refresh_at"]: + return user.ymusic_auth["access_token"] - token, expires_in = await refresh_token('https://oauth.yandex.com/token', - user.ymusic_auth['refresh_token'], - config.ymusic.encoded - ) + token, expires_in = await refresh_token( + "https://oauth.yandex.com/token", + user.ymusic_auth["refresh_token"], + config.ymusic.encoded, + ) async with get_session_context() as session: await session.execute( - update(User).where(User.id == self.user_id).values(spotify_auth=get_oauth_creds(token, - user.ymusic_auth['refresh_token'], - expires_in)) + update(User) + .where(User.id == self.user_id) + .values( + spotify_auth=get_oauth_creds( + token, user.ymusic_auth["refresh_token"], expires_in + ) + ) ) await session.commit() return token @@ -54,9 +58,9 @@ class YandexMusicStrategy(MusicProviderStrategy): return [ Track( name=x.title, - artist=', '.join([art.name for art in x.artists]), + artist=", ".join([art.name for art in x.artists]), ymusic_id=x.id, - cover_url=x.cover_uri + cover_url=x.cover_uri, ) for x in tracks ] diff --git a/app/MusicProvider/auth.py b/app/MusicProvider/auth.py index 4cb2b52..bfe9d71 100644 --- a/app/MusicProvider/auth.py +++ b/app/MusicProvider/auth.py @@ -1,3 +1,4 @@ +import base64 import time import aiohttp @@ -5,22 +6,25 @@ import aiohttp def get_oauth_creds(token, refresh_token, expires_in): return { - 'access_token': token, - 'refresh_token': refresh_token, - 'refresh_at': int(time.time()) + expires_in + "access_token": token, + "refresh_token": refresh_token, + "refresh_at": int(time.time()) + expires_in, } async def refresh_token(endpoint, refresh_token, creds, proxy=None): token_headers = { "Content-Type": "application/x-www-form-urlencoded", - 'Authorization': 'Basic ' + creds - } - token_data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token + "Authorization": "Basic " + creds, } + token_data = {"grant_type": "refresh_token", "refresh_token": refresh_token} async with aiohttp.ClientSession(proxy=proxy) as session: resp = await session.post(endpoint, data=token_data, headers=token_headers) resp = await resp.json() - return resp['access_token'], resp['expires_in'] + return resp["access_token"], resp["expires_in"] + + +def get_encoded_creds(client_id, client_secret): + return base64.b64encode(client_id.encode() + b":" + client_secret.encode()).decode( + "utf-8" + ) diff --git a/app/__init__.py b/app/__init__.py index 98ef18b..44fc32c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,7 +9,8 @@ from telethon.tl.types import ( UpdateBotInlineSend, TypeInputFile, DocumentAttributeAudio, - InputPeerSelf, InputDocument + InputPeerSelf, + InputDocument, ) from telethon.tl.custom import InlineBuilder from telethon import functions @@ -29,14 +30,14 @@ from app.models import Track, User from app.youtube_api import name_to_youtube, download_youtube logging.basicConfig( - format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", level=logging.INFO, ) logger = logging.getLogger(__name__) -client = TelegramClient('nowplaying', config.api_id, config.api_hash) -client.parse_mode = 'html' +client = TelegramClient("nowplaying", config.api_id, config.api_hash) +client.parse_mode = "html" cache = LRUCache(maxsize=100) @@ -46,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'] \ No newline at end of file +__all__ = ["main"] diff --git a/app/__main__.py b/app/__main__.py index 9d782d5..601234d 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -6,7 +6,6 @@ async def run(): await main() -if __name__ == '__main__': +if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(run()) - diff --git a/app/callback_listener.py b/app/callback_listener.py index 2050128..99b0ea8 100644 --- a/app/callback_listener.py +++ b/app/callback_listener.py @@ -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 y for Yandex music. or s for Spotify. @@ -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) diff --git a/app/config.py b/app/config.py index cf7906e..a8ea384 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,6 @@ -import base64 - from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import BaseModel +from app.MusicProvider.auth import get_encoded_creds class OauthCreds(BaseModel): @@ -11,7 +10,7 @@ class OauthCreds(BaseModel): @property def encoded(self) -> str: - return base64.b64encode(self.client_id.encode() + b':' + self.client_secret.encode()).decode("utf-8") + return get_encoded_creds(self.client_id, self.client_secret) class GoogleApiCreds(BaseModel): @@ -20,21 +19,22 @@ class GoogleApiCreds(BaseModel): class Config(BaseSettings): - model_config = SettingsConfigDict(env_nested_delimiter='_', - env_nested_max_split=1) + model_config = SettingsConfigDict(env_nested_delimiter="_", env_nested_max_split=1) + bot_token: str api_id: int api_hash: str + db_string: str - spotify: OauthCreds + yt: GoogleApiCreds ymusic: OauthCreds - proxy: str | None = None - socks_proxy: str | None = None + proxy: str = '' + root_url: str jwt_secret: str -config = Config(_env_file='.env') +config = Config(_env_file=".env") -__all__ = ['config', 'OauthCreds'] +__all__ = ["config", "OauthCreds"] diff --git a/app/db.py b/app/db.py index ca5a06f..a02eaeb 100644 --- a/app/db.py +++ b/app/db.py @@ -9,4 +9,4 @@ async_session = async_sessionmaker( expire_on_commit=False, ) -__all__ = ['engine', 'async_session'] +__all__ = ["engine", "async_session"] diff --git a/app/dependencies.py b/app/dependencies.py index 4c8226d..0b94def 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -5,11 +5,11 @@ from app.db import async_session async def get_session() -> AsyncSession: - async with async_session() as session: + async with async_session() as session, session.begin(): yield session @asynccontextmanager async def get_session_context() -> AsyncSession: - async with async_session() as session: + async with async_session() as session, session.begin(): yield session diff --git a/app/models/__init__.py b/app/models/__init__.py index 0f90465..87dab7d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,3 +1,3 @@ from app.models.base import Base from app.models.user import User -from app.models.track import Track \ No newline at end of file +from app.models.track import Track diff --git a/app/models/base.py b/app/models/base.py index 1c2dcc4..fa2b68a 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - pass \ No newline at end of file + pass diff --git a/app/models/track.py b/app/models/track.py index dc15efa..18b7a46 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -6,7 +6,7 @@ from sqlalchemy import BigInteger, LargeBinary, Integer, JSON class Track(Base): - __tablename__ = 'tracks' + __tablename__ = "tracks" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger) @@ -28,7 +28,9 @@ class Track(Base): def __eq__(self, other): if not isinstance(other, Track): return NotImplemented - return (self.spotify_id or self.ymusic_id) == (other.spotify_id or other.spotify_id) + return (self.spotify_id or self.ymusic_id) == ( + other.spotify_id or other.spotify_id + ) -__all__ = ['Track'] +__all__ = ["Track"] diff --git a/app/models/user.py b/app/models/user.py index 64d210e..e9458dd 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -7,7 +7,7 @@ from sqlalchemy import BigInteger class User(Base): - __tablename__ = 'users' + __tablename__ = "users" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) spotify_auth: Mapped[dict] = mapped_column(JSON, default={}) @@ -16,4 +16,4 @@ class User(Base): default: Mapped[str] -__all__ = ['User'] +__all__ = ["User"] diff --git a/app/static/authorize_spotify.html b/app/static/authorize_spotify.html new file mode 100644 index 0000000..d0c00bb --- /dev/null +++ b/app/static/authorize_spotify.html @@ -0,0 +1,121 @@ + + + + + + Spotify API Authorization + + + + +

One more step

+
    +
  1. Visit Spotify Developer Dashboard
  2. +
  3. Log in and create an app
  4. +
  5. Fill any name and description. Set redirect url to: {{ redirect_url }}
  6. +
  7. Copy your Client ID and Client Secret
  8. +
+ + + + + + + + + + + + diff --git a/app/static/privacy.html b/app/static/privacy.html new file mode 100644 index 0000000..13f2f8a --- /dev/null +++ b/app/static/privacy.html @@ -0,0 +1,104 @@ + + + + + + Privacy Policy | NowPlaying Bot + + + +
+
+

Privacy Policy

+

Last updated: 2025.04.17

+ +

1. Information We Collect

+

When you connect your music accounts, we collect the following data:

+

Spotify

+ +

Yandex Music

+ +

We do not collect or store any other personal information.

+ +

2. How We Use Your Data

+

We use the collected tokens only to:

+ +

We do not use your data for analytics, advertising, tracking, or any other purposes.

+ +

3. Data Storage & Security

+ + +

4. Data Retention

+ + +

5. Third-Party Services

+

We rely on the official APIs of:

+ +

Please review their respective privacy policies for more information on how they handle your data.
+ We also comply with telegram Standard Privacy Policy for Bots and Mini Apps.

+

6. Your Rights

+

You have the right to:

+ +
+
+ + diff --git a/app/sus.py b/app/sus.py index 660e62a..79c1a07 100644 --- a/app/sus.py +++ b/app/sus.py @@ -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("Текст песни отсутствует") diff --git a/app/youtube_api.py b/app/youtube_api.py index eb74788..c44b85e 100644 --- a/app/youtube_api.py +++ b/app/youtube_api.py @@ -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 \ No newline at end of file + return res, duration diff --git a/pyproject.toml b/pyproject.toml index eda4e78..9bce440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index bc3cd65..f3c392f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },