diff --git a/alembic/versions/d99a58c0c384_edit_track.py b/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py similarity index 69% rename from alembic/versions/d99a58c0c384_edit_track.py rename to alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py index 0b69ab2..2b60c0d 100644 --- a/alembic/versions/d99a58c0c384_edit_track.py +++ b/alembic/versions/e4a1561c2b63_add_yandex_music_fileds.py @@ -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 ### diff --git a/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py b/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py new file mode 100644 index 0000000..89426b2 --- /dev/null +++ b/alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py @@ -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 ### diff --git a/app/MusicProvider/YMusicStrategy.py b/app/MusicProvider/YMusicStrategy.py new file mode 100644 index 0000000..105dae2 --- /dev/null +++ b/app/MusicProvider/YMusicStrategy.py @@ -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 + ] diff --git a/app/MusicProvider/__init__.py b/app/MusicProvider/__init__.py index 89ae439..17a126e 100644 --- a/app/MusicProvider/__init__.py +++ b/app/MusicProvider/__init__.py @@ -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 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 991db1e..f4ab098 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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'Spotify | Other' @@ -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() diff --git a/app/callback_listener.py b/app/callback_listener.py index 1d9e52c..706abb4 100644 --- a/app/callback_listener.py +++ b/app/callback_listener.py @@ -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) diff --git a/app/config.py b/app/config.py index 38c2dcc..89a61f7 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/models/track.py b/app/models/track.py index b3d040f..9dc0590 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -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) diff --git a/app/models/user.py b/app/models/user.py index cb9508d..dc388b6 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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'] \ No newline at end of file +__all__ = ['User'] diff --git a/app/sus.py b/app/sus.py index acd6a65..660e62a 100644 --- a/app/sus.py +++ b/app/sus.py @@ -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!") \ No newline at end of file +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('Текст песни отсутствует') diff --git a/pyproject.toml b/pyproject.toml index 29a5a61..eda4e78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index 5946fb4..bc3cd65 100644 --- a/uv.lock +++ b/uv.lock @@ -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"