Add yandex strategy
This commit is contained in:
parent
22e9cf9a1f
commit
40ec2bc6b8
12 changed files with 219 additions and 86 deletions
|
@ -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 ###
|
34
alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py
Normal file
34
alembic/versions/fc8875e47bc0_add_yandex_music_fileds.py
Normal 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 ###
|
36
app/MusicProvider/YMusicStrategy.py
Normal file
36
app/MusicProvider/YMusicStrategy.py
Normal 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
|
||||
]
|
|
@ -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
|
111
app/__init__.py
111
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'<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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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']
|
||||
|
|
30
app/sus.py
30
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!")
|
||||
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('Текст песни отсутствует')
|
||||
|
|
|
@ -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
15
uv.lock
generated
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue