Merge spotify auth strategy #1
28 changed files with 601 additions and 270 deletions
|
@ -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
|
||||
|
|
|
@ -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 ###
|
||||
|
|
|
@ -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 ###
|
||||
|
|
|
@ -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 ###
|
||||
|
|
|
@ -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 ###
|
||||
|
|
|
@ -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 ###
|
||||
|
|
|
@ -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 ###
|
||||
|
|
|
@ -22,4 +22,4 @@ class MusicProviderContext:
|
|||
res = await self.strategy.fetch_track(track)
|
||||
if not res:
|
||||
return track
|
||||
return res
|
||||
return res
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -25,4 +25,4 @@ class MusicProviderStrategy(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def track_id(self, track: Track):
|
||||
pass
|
||||
pass
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
117
app/__init__.py
117
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']
|
||||
__all__ = ["main"]
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -9,4 +9,4 @@ async_session = async_sessionmaker(
|
|||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
__all__ = ['engine', 'async_session']
|
||||
__all__ = ["engine", "async_session"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase
|
|||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
pass
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
||||
|
|
121
app/static/authorize_spotify.html
Normal file
121
app/static/authorize_spotify.html
Normal 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
104
app/static/privacy.html
Normal 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>
|
14
app/sus.py
14
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("Текст песни отсутствует")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
14
uv.lock
generated
|
@ -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" },
|
||||
|
|
Loading…
Add table
Reference in a new issue