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"