Add yandex strategy

This commit is contained in:
unknown 2025-04-09 00:24:18 +03:00
parent 22e9cf9a1f
commit 40ec2bc6b8
12 changed files with 219 additions and 86 deletions

View file

@ -1,8 +1,8 @@
"""edit Track
"""add yandex music fileds
Revision ID: d99a58c0c384
Revision ID: e4a1561c2b63
Revises:
Create Date: 2025-04-04 22:08:49.345416
Create Date: 2025-04-05 21:22:09.221527
"""
from typing import Sequence, Union
@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd99a58c0c384'
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
@ -26,18 +26,20 @@ def upgrade() -> None:
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=False),
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.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=False),
sa.Column('spotify_refresh_token', sa.String(), nullable=False),
sa.Column('spotify_refresh_at', 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 ###

View file

@ -0,0 +1,34 @@
"""add yandex music fileds
Revision ID: fc8875e47bc0
Revises: e4a1561c2b63
Create Date: 2025-04-08 23:34:01.812378
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
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
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))
# ### 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')
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
from app.dependencies import get_session_context
from app.models import Track, User
from app.MusicProvider.Strategy import MusicProviderStrategy
from sqlalchemy import select
from yandex_music import ClientAsync, TracksList
class YandexMusicStrategy(MusicProviderStrategy):
async def fetch_track(self, track: Track) -> Track:
async with get_session_context() as session:
resp = await session.execute(
select(Track).where(Track.ymusic_id == track.ymusic_id)
)
return resp.scalars().first()
async def handle_token(self) -> str:
async with get_session_context() as session:
res = await session.execute(select(User).where(User.id == self.user_id))
user: User = res.scalars().first()
if not user:
return None
return user.ymusic_token
async def get_tracks(self, token) -> list[Track]:
client = await ClientAsync(token).init()
liked: TracksList = await client.users_likes_tracks()
tracks = await client.tracks([x.id for x in liked.tracks[:5]])
return [
Track(
name=x.title,
artist=', '.join([art.name for art in x.artists]),
ymusic_id=x.id,
cover_url=x.cover_uri
)
for x in tracks
]

View file

@ -1,3 +1,4 @@
from app.MusicProvider.Context import MusicProviderContext
from app.MusicProvider.SpotifyStrategy import SpotifyStrategy
from app.MusicProvider.YMusicStrategy import YandexMusicStrategy
from app.MusicProvider.Strategy import MusicProviderStrategy

View file

@ -17,10 +17,11 @@ import urllib.parse
from mutagen.id3 import ID3, APIC
import logging
from cachetools import LRUCache
from sqlalchemy import select
import jwt
from app.MusicProvider import MusicProviderContext, SpotifyStrategy
from app.MusicProvider import MusicProviderContext, SpotifyStrategy, YandexMusicStrategy
from app.config import config
from app.dependencies import get_session, get_session_context
from app.models import Track
@ -35,36 +36,42 @@ logger = logging.getLogger(__name__)
client = TelegramClient('nowplaying', config.api_id, config.api_hash)
client.parse_mode = 'html'
dummy_file: TypeInputFile = None
cache = LRUCache(maxsize=100)
def get_link_account_keyboard():
return [Button.inline('Spotify', 'connect_spotify')]
@client.on(events.NewMessage(pattern='/start'))
async def start(e: events.NewMessage.Event):
await e.respond("Hello! I'm a bot that lets you download music you listen in inline mode. Press button below to connect your account.",
buttons=get_link_account_keyboard())
@client.on(events.CallbackQuery(pattern='connect_spotify'))
async def connect_spotify(e: events.CallbackQuery.Event):
payload = {
'tg_id': e.sender_id,
'exp': int(time.time()) + 300
}
def get_spotify_link(user_id):
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': jwt.encode(payload, config.jwt_secret, algorithm='HS256')
'state': user_id
}
await e.respond('Link your Spotify account',
buttons=[Button.url('Link', f"https://accounts.spotify.com/authorize?{urllib.parse.urlencode(params)}")])
return f"https://accounts.spotify.com/authorize?{urllib.parse.urlencode(params)}"
def get_ymusic_link(user_id):
params = {
'response_type': 'token',
'client_id': config.ym.client_id,
'state': user_id
}
return f"https://oauth.yandex.ru/authorize?{urllib.parse.urlencode(params)}"
@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')
buttons = [
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 muisc\n\nPress button below to authorize your account first",
buttons=buttons)
async def fetch_file(url):
@ -74,11 +81,6 @@ async def fetch_file(url):
return await response.read()
async def update_dummy_file():
global dummy_file
dummy_file = await client.upload_file('empty.mp3')
def get_track_links(track_id):
return f'<a href="https://open.spotify.com/track/{track_id}">Spotify</a> | <a href="https://song.link/s/{track_id}">Other</a>'
@ -98,16 +100,14 @@ async def update_dummy_file_cover(cover_url: str):
))
res = io.BytesIO()
audio.save(res)
global dummy_file
dummy_file = await client.upload_file(res.getvalue(), file_name='empty.mp3')
async def build_response(e: events.InlineQuery.Event, track: Track):
if not track.telegram_id:
await update_dummy_file()
dummy_file = await client.upload_file('empty.mp3')
buttons = [Button.inline('Loading', 'loading')]
else:
global dummy_file
dummy_file = InputDocument(
id=track.telegram_id,
access_hash=track.telegram_access_hash,
@ -136,7 +136,7 @@ async def build_response(e: events.InlineQuery.Event, track: Track):
@client.on(events.InlineQuery())
async def query_list(e: events.InlineQuery.Event):
context = MusicProviderContext(SpotifyStrategy(e.sender_id))
context = MusicProviderContext(YandexMusicStrategy(e.sender_id))
tracks = (await context.get_tracks())[:5]
result = []
@ -147,11 +147,8 @@ async def query_list(e: events.InlineQuery.Event):
await e.answer(result)
async def get_track_file(track):
yt_id = await asyncio.get_event_loop().run_in_executor(
None, functools.partial(name_to_youtube, f'{track.name} - {track.artist}')
)
audio, duration = await download_youtube(yt_id)
async def track_to_file(track):
audio, duration = await download_youtube(track.yt_id)
_, media, _ = await client._file_to_media(
audio,
attributes=[
@ -178,23 +175,53 @@ async def cache_file(track):
await session.commit()
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}')
)
async with get_session_context() as session:
existing = await session.scalar(
select(Track).where(Track.yt_id == yt_id)
)
if existing and existing.telegram_id:
updated = False
if not existing.spotify_id and track.spotify_id:
existing.spotify_id = track.spotify_id
updated = True
if not existing.ymusic_id and track.ymusic_id:
existing.ymusic_id = track.ymusic_id
updated = True
if updated:
await session.commit()
return InputDocument(
id=existing.telegram_id,
access_hash=existing.telegram_access_hash,
file_reference=existing.telegram_file_reference
)
file = await track_to_file(track)
track.telegram_id = file.id
track.telegram_access_hash = file.access_hash
track.telegram_file_reference = file.file_reference
track.yt_id = yt_id
await cache_file(track)
return file
@client.on(events.Raw([UpdateBotInlineSend]))
async def send_track(e: UpdateBotInlineSend):
track = cache[e.id]
if track.telegram_id:
return
file = await get_track_file(track)
track.telegram_id = file.id
track.telegram_access_hash = file.access_hash
track.telegram_file_reference = file.file_reference
await cache_file(track)
file = await download_track(track)
await client.edit_message(e.msg_id, file=file, text=get_track_links(e.id))
async def main():
await client.start(bot_token=config.bot_token)
await update_dummy_file()
logger.info('Bot started')
await client.run_until_disconnected()

View file

@ -56,13 +56,16 @@ async def get_spotify_token(code: str):
return resp['access_token'], resp['refresh_token'], int(resp['expires_in'])
@app.get('/spotify_callback')
async def spotify_callback(code: str, state: str, session: AsyncSession = Depends(get_session)):
def get_decoded_id(string: str):
try:
user_id = jwt.decode(state, config.jwt_secret, algorithms=['HS256'])['tg_id']
return jwt.decode(string, config.jwt_secret, algorithms=['HS256'])['tg_id']
except:
raise LinkException()
@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 get_spotify_token(code)
user = await session.get(User, user_id)
if user:
@ -81,5 +84,21 @@ async def spotify_callback(code: str, state: str, session: AsyncSession = Depend
return FileResponse('static/success.html', media_type='text/html')
@app.get('/ym_callback')
async def ym_callback(state: str, access_token: str, session: AsyncSession = Depends(get_session)):
user_id = get_decoded_id(state)
user = await session.get(User, user_id)
if user:
user.ymusic_token = access_token
else:
user = User(id=user_id,
ymusic_token=access_token
)
session.add(user)
await session.commit()
await client.send_message(user_id, "Account linked!")
return FileResponse('static/success.html', media_type='text/html')
if __name__ == '__main__':
uvicorn.run(app, host='0.0.0.0', port=8080)

View file

@ -10,6 +10,10 @@ class SpotifyCreds(BaseModel):
redirect: str
class YandexMusicCreds(BaseModel):
client_id: str
class GoogleApiCreds(BaseModel):
client_id: str
client_secret: str
@ -24,6 +28,8 @@ class Config(BaseSettings):
db_string: str
spotify: SpotifyCreds
yt: GoogleApiCreds
ym: YandexMusicCreds
proxy: str = ''
jwt_secret: str

View file

@ -2,21 +2,26 @@ from typing import Optional
from app.models import Base
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, LargeBinary, Integer
from sqlalchemy import BigInteger, LargeBinary, Integer, JSON
class Track(Base):
__tablename__ = 'tracks'
id: Mapped[int] = mapped_column(primary_key=True)
telegram_reference: Mapped[Optional[dict]] = mapped_column(JSON)
telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger)
telegram_access_hash: Mapped[Optional[int]] = mapped_column(BigInteger)
telegram_file_reference: Mapped[Optional[bytes]] = mapped_column(LargeBinary)
spotify_id: Mapped[str]
yt_id: Mapped[Optional[str]]
spotify_id: Mapped[Optional[str]]
ymusic_id: Mapped[Optional[str]]
name: Mapped[str]
artist: Mapped[str]
cover_url: Mapped[str]
used_times: Mapped[str] = mapped_column(Integer, default=1)
used_times: Mapped[int] = mapped_column(Integer, default=1)

View file

@ -1,3 +1,5 @@
from typing import Optional
from app.models import Base
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
@ -6,9 +8,12 @@ from sqlalchemy import String
class User(Base):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(primary_key=True)
spotify_access_token: Mapped[str] = mapped_column(String())
spotify_refresh_token: Mapped[str]
spotify_refresh_at: Mapped[int]
spotify_access_token: Mapped[Optional[str]]
spotify_refresh_token: Mapped[Optional[str]]
spotify_refresh_at: Mapped[Optional[int]]
ymusic_token: Mapped[Optional[str]]
__all__ = ['User']
__all__ = ['User']

View file

@ -1,12 +1,26 @@
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
from yandex_music import Client
# Open MP3 file
audio = MP3("empty.mp3", ID3=ID3)
client = Client('').init()
print(client.queues_list())
# Check if APIC (cover art) exists
cover_art = audio.tags.getall("APIC")
if cover_art:
print("✅ Cover art is embedded successfully.")
else:
print("❌ Cover art is missing!")
queues = client.tracks(19801159)
print(queues)
# Последняя проигрываемая очередь всегда в начале списка
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())
title = last_track.title
print(f'Сейчас играет: {artists} - {title}')
try:
lyrics = last_track.get_lyrics('LRC')
print(lyrics.fetch_lyrics())
print(f'\nИсточник: {lyrics.major.pretty_name}')
except NotFoundError:
print('Текст песни отсутствует')

View file

@ -15,7 +15,6 @@ dependencies = [
"psycopg2>=2.9.10",
"pydantic-settings>=2.8.1",
"pyjwt>=2.10.1",
"pytaglib>=3.0.1",
"requests>=2.32.3",
"sqlalchemy[asyncio]>=2.0.40",
"telethon>=1.39.0",

15
uv.lock generated
View file

@ -382,7 +382,6 @@ dependencies = [
{ name = "psycopg2" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pytaglib" },
{ name = "requests" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "telethon" },
@ -404,7 +403,6 @@ requires-dist = [
{ name = "psycopg2", specifier = ">=2.9.10" },
{ name = "pydantic-settings", specifier = ">=2.8.1" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pytaglib", specifier = ">=3.0.1" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.40" },
{ name = "telethon", specifier = ">=1.39.0" },
@ -562,19 +560,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 },
]
[[package]]
name = "pytaglib"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/f5/90d40088d4839b50b6aaa1de403d12053acfc7724901f7749d9724039882/pytaglib-3.0.1.tar.gz", hash = "sha256:b9255765d72e237e8b2843f2df3763f7ad3fdc6b6b3ffdd027c3f373c9bdd787", size = 500599 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/f8/a2a9eaa79d200f5acf1d8a48b38db042668dc3e67bf56b569b11c5d30349/pytaglib-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1173675b8868498ed135cc488abfc70bc2016dc78f615a5b27adc042afd3ef9a", size = 765166 },
{ url = "https://files.pythonhosted.org/packages/94/7e/739313be7611fd6f6d18407059255118ff4d09576afeacdd6246c2c98f1e/pytaglib-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b56cdbd3c029308634f4efa2134d91f74714eacfb6f43c0ed430fe74fa5cc09", size = 790362 },
{ url = "https://files.pythonhosted.org/packages/ba/cc/2bf2511ab9a4adbd4eb3cdf35a6ec429e629bf5212d59c50b0ee4de3ede6/pytaglib-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:041a559015167691b14ac017657e0f7ab5e5398ebe7936dbbcd9940cd4277e24", size = 1932489 },
{ url = "https://files.pythonhosted.org/packages/b7/89/7c43fbe9f5c89c552ff5ea4bdfdc9e73f343a764d4dca67583ad293dd1f2/pytaglib-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08e513093a1e3859b6d10319f8dc2509891b0b602b0ed84cad64b59d5e0a87e2", size = 2813214 },
{ url = "https://files.pythonhosted.org/packages/87/d9/a81226deee5f687edc5e914b7c0a4f1f2cfd1302fb265be37025cf9b352b/pytaglib-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:a65f310df7cd78e4218a7bb581da7775192fc81dfcb2612da1ed93091c8671b9", size = 256377 },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"