Add soundcloud support
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
from .search import ServiceSearchFilter
|
from .search import ServiceSearchFilter, ServiceSearchMultiletterFilter
|
||||||
from .url import MusicUrlFilter
|
from .url import MusicUrlFilter
|
||||||
|
|||||||
@@ -11,3 +11,15 @@ class ServiceSearchFilter(BaseFilter):
|
|||||||
inline_query.query.startswith(self.service_letter) and
|
inline_query.query.startswith(self.service_letter) and
|
||||||
inline_query.query != self.service_letter
|
inline_query.query != self.service_letter
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSearchMultiletterFilter(BaseFilter):
|
||||||
|
def __init__(self, service_lettes: list[str]):
|
||||||
|
self.service_letter = [f'{letter}:' for letter in service_lettes]
|
||||||
|
|
||||||
|
async def __call__(self, inline_query: InlineQuery):
|
||||||
|
return (
|
||||||
|
any(inline_query.query.startswith(letter) for letter in
|
||||||
|
self.service_letter) and
|
||||||
|
inline_query.query not in self.service_letter
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class MusicUrlFilter(BaseFilter):
|
|||||||
'spotify.link',
|
'spotify.link',
|
||||||
'deezer.page.link',
|
'deezer.page.link',
|
||||||
'deezer.com',
|
'deezer.com',
|
||||||
|
'soundcloud.com'
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
|
|
||||||
from . import on_inline_spotify, on_inline_deezer, on_inline_youtube
|
from . import (on_inline_spotify, on_inline_deezer, on_inline_youtube,
|
||||||
|
on_inline_soundcloud)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
router.include_routers(
|
router.include_routers(
|
||||||
on_inline_spotify.router,
|
on_inline_spotify.router,
|
||||||
on_inline_deezer.router,
|
on_inline_deezer.router,
|
||||||
on_inline_youtube.router,
|
on_inline_youtube.router,
|
||||||
|
on_inline_soundcloud.router
|
||||||
)
|
)
|
||||||
|
|||||||
24
bot/handlers/inline_song/on_inline_soundcloud.py
Normal file
24
bot/handlers/inline_song/on_inline_soundcloud.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from aiogram.types import InlineQuery
|
||||||
|
|
||||||
|
from bot.results.soundcloud import get_soundcloud_search_results
|
||||||
|
from bot.filters import ServiceSearchMultiletterFilter
|
||||||
|
from bot.modules.settings import UserSettings
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.inline_query(ServiceSearchMultiletterFilter(['c', 'с']))
|
||||||
|
async def search_soundcloud_inline_query(
|
||||||
|
inline_query: InlineQuery,
|
||||||
|
settings: UserSettings
|
||||||
|
):
|
||||||
|
await inline_query.answer(
|
||||||
|
await get_soundcloud_search_results(
|
||||||
|
inline_query.query.removeprefix('c:').removesuffix('с:'),
|
||||||
|
settings
|
||||||
|
),
|
||||||
|
cache_time=0,
|
||||||
|
is_personal=True
|
||||||
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
from . import spotify, deezer, youtube, recode_cached, suppress_verify
|
from . import spotify, deezer, youtube, soundcloud, recode_cached, suppress_verify
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ router.include_routers(
|
|||||||
spotify.router,
|
spotify.router,
|
||||||
deezer.router,
|
deezer.router,
|
||||||
youtube.router,
|
youtube.router,
|
||||||
|
soundcloud.router,
|
||||||
recode_cached.router,
|
recode_cached.router,
|
||||||
suppress_verify.router,
|
suppress_verify.router,
|
||||||
)
|
)
|
||||||
|
|||||||
39
bot/handlers/on_chosen/soundcloud.py
Normal file
39
bot/handlers/on_chosen/soundcloud.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from aiogram import Router, Bot, F
|
||||||
|
from aiogram.types import (
|
||||||
|
BufferedInputFile, URLInputFile, InputMediaAudio,
|
||||||
|
ChosenInlineResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.modules.soundcloud import soundcloud, SoundCloudBytestream
|
||||||
|
from bot.utils.config import config
|
||||||
|
from bot.modules.database import db
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.chosen_inline_result(F.result_id.startswith('sc::'))
|
||||||
|
async def on_new_chosen(chosen_result: ChosenInlineResult, bot: Bot):
|
||||||
|
bytestream: SoundCloudBytestream = await (await soundcloud.downloader.from_id(
|
||||||
|
chosen_result.result_id.removeprefix('sc::')
|
||||||
|
)).to_bytestream()
|
||||||
|
|
||||||
|
audio = await bot.send_audio(
|
||||||
|
chat_id=config.telegram.files_chat,
|
||||||
|
audio=BufferedInputFile(
|
||||||
|
file=bytestream.file,
|
||||||
|
filename=bytestream.filename,
|
||||||
|
),
|
||||||
|
thumbnail=URLInputFile(bytestream.song.thumbnail),
|
||||||
|
title=bytestream.song.name,
|
||||||
|
duration=bytestream.duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.soundcloud[bytestream.song.id] = audio.audio.file_id
|
||||||
|
|
||||||
|
await bot.edit_message_media(
|
||||||
|
inline_message_id=chosen_result.inline_message_id,
|
||||||
|
media=InputMediaAudio(media=audio.audio.file_id),
|
||||||
|
reply_markup=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.occasionally_write()
|
||||||
@@ -7,7 +7,7 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
@router.chosen_inline_result(
|
@router.chosen_inline_result(
|
||||||
F.result_id.startswith('deezc::')
|
F.result_id.startswith('deezc::') | F.result_id.startswith('scc::')
|
||||||
)
|
)
|
||||||
async def on_unneeded_cached_chosen(chosen_result: ChosenInlineResult, bot: Bot):
|
async def on_unneeded_cached_chosen(chosen_result: ChosenInlineResult, bot: Bot):
|
||||||
await bot.edit_message_reply_markup(
|
await bot.edit_message_reply_markup(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class BaseSongItem:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self):
|
||||||
return f"{self.all_artists} - {self.name}"
|
return f"{self.all_artists} - {self.name}" if self.artists else self.name
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.full_name
|
return self.full_name
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Db(object):
|
|||||||
self.spotify = DBDict('spotify')
|
self.spotify = DBDict('spotify')
|
||||||
self.deezer = DBDict('deezer')
|
self.deezer = DBDict('deezer')
|
||||||
self.youtube = DBDict('youtube')
|
self.youtube = DBDict('youtube')
|
||||||
|
self.soundcloud = DBDict('soundcloud')
|
||||||
self.recoded = DBDict('recoded')
|
self.recoded = DBDict('recoded')
|
||||||
|
|
||||||
async def write(self):
|
async def write(self):
|
||||||
|
|||||||
10
bot/modules/soundcloud/__init__.py
Normal file
10
bot/modules/soundcloud/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .soundcloud import SoundCloud
|
||||||
|
from .downloader import SoundCloudBytestream
|
||||||
|
from bot.utils.config import config
|
||||||
|
|
||||||
|
|
||||||
|
soundcloud = SoundCloud(
|
||||||
|
client_id=config.tokens.soundcloud.client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ['soundcloud', 'SoundCloudBytestream']
|
||||||
118
bot/modules/soundcloud/downloader.py
Normal file
118
bot/modules/soundcloud/downloader.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from attrs import define
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .driver import SoundCloudDriver
|
||||||
|
from .song import SongItem
|
||||||
|
|
||||||
|
import m3u8
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class SoundCloudBytestream:
|
||||||
|
file: bytes
|
||||||
|
filename: str
|
||||||
|
duration: int
|
||||||
|
song: SongItem
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(
|
||||||
|
cls,
|
||||||
|
bytes_: bytes,
|
||||||
|
filename: str,
|
||||||
|
duration: int,
|
||||||
|
song: SongItem
|
||||||
|
):
|
||||||
|
return cls(
|
||||||
|
file=bytes_,
|
||||||
|
filename=filename,
|
||||||
|
duration=int(duration / 1000),
|
||||||
|
song=song
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class Downloader:
|
||||||
|
driver: SoundCloudDriver
|
||||||
|
download_url: str
|
||||||
|
duration: int
|
||||||
|
filename: str
|
||||||
|
method: Callable
|
||||||
|
song: SongItem
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def build(
|
||||||
|
cls,
|
||||||
|
song_id: str,
|
||||||
|
driver: SoundCloudDriver
|
||||||
|
):
|
||||||
|
track = await driver.get_track(song_id)
|
||||||
|
song = SongItem.from_soundcloud(track)
|
||||||
|
|
||||||
|
if url := cls._try_get_progressive(track['media']['transcodings']):
|
||||||
|
method = cls._progressive
|
||||||
|
else:
|
||||||
|
url = track['media']['transcodings'][0]['url']
|
||||||
|
method = cls._hls if \
|
||||||
|
(track['media']['transcodings'][0]['format']['protocol']
|
||||||
|
== 'hls') else cls._progressive
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
driver=driver,
|
||||||
|
duration=track['duration'],
|
||||||
|
method=method,
|
||||||
|
download_url=url,
|
||||||
|
filename=f'{track["title"]}.mp3',
|
||||||
|
song=song
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _try_get_progressive(urls: list) -> str | None:
|
||||||
|
for transcode in urls:
|
||||||
|
if transcode['format']['protocol'] == 'progressive':
|
||||||
|
return transcode['url']
|
||||||
|
|
||||||
|
async def _progressive(self, url: str) -> bytes:
|
||||||
|
return await self.driver.engine.read_data(
|
||||||
|
url=(await self.driver.engine.get(
|
||||||
|
url
|
||||||
|
))['url']
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _hls(self, url: str) -> bytes:
|
||||||
|
m3u8_obj = m3u8.loads(
|
||||||
|
(await self.driver.engine.read_data(
|
||||||
|
(await self.driver.engine.get(
|
||||||
|
url=url
|
||||||
|
))['url']
|
||||||
|
)).decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
content = bytearray()
|
||||||
|
for segment in m3u8_obj.files:
|
||||||
|
content.extend(
|
||||||
|
await self.driver.engine.read_data(
|
||||||
|
url=segment,
|
||||||
|
append_client_id=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def to_bytestream(self) -> SoundCloudBytestream:
|
||||||
|
return SoundCloudBytestream.from_bytes(
|
||||||
|
bytes_=await self.method(self, self.download_url),
|
||||||
|
filename=self.filename,
|
||||||
|
duration=self.duration,
|
||||||
|
song=self.song
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class DownloaderBuilder:
|
||||||
|
driver: SoundCloudDriver
|
||||||
|
|
||||||
|
async def from_id(self, song_id: str):
|
||||||
|
return await Downloader.build(
|
||||||
|
song_id=song_id,
|
||||||
|
driver=self.driver
|
||||||
|
)
|
||||||
30
bot/modules/soundcloud/driver.py
Normal file
30
bot/modules/soundcloud/driver.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from attrs import define
|
||||||
|
|
||||||
|
from .engine import SoundCloudEngine
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class SoundCloudDriver:
|
||||||
|
engine: SoundCloudEngine
|
||||||
|
|
||||||
|
async def get_track(self, track_id: int | str):
|
||||||
|
return await self.engine.call(
|
||||||
|
f'tracks/{track_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def search(self, query: str, limit: int = 30):
|
||||||
|
return (await self.engine.call(
|
||||||
|
'search/tracks',
|
||||||
|
params={
|
||||||
|
'q': query,
|
||||||
|
'limit': limit
|
||||||
|
}
|
||||||
|
))['collection']
|
||||||
|
|
||||||
|
async def resolve_url(self, url: str):
|
||||||
|
return await self.engine.call(
|
||||||
|
'resolve',
|
||||||
|
params={
|
||||||
|
'url': url
|
||||||
|
}
|
||||||
|
)
|
||||||
34
bot/modules/soundcloud/engine.py
Normal file
34
bot/modules/soundcloud/engine.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from attrs import define
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class SoundCloudEngine:
|
||||||
|
client_id: str
|
||||||
|
|
||||||
|
async def call(self, request_point: str, params: dict = None):
|
||||||
|
return await self.get(
|
||||||
|
url=f'https://api-v2.soundcloud.com/{request_point}',
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, url: str, params: dict = None):
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
url,
|
||||||
|
params=(params or {}) | {
|
||||||
|
'client_id': self.client_id,
|
||||||
|
},
|
||||||
|
) as r:
|
||||||
|
return await r.json()
|
||||||
|
|
||||||
|
async def read_data(self, url: str, params: dict = None,
|
||||||
|
append_client_id: bool = True):
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
url,
|
||||||
|
params=(params or {}) | ({
|
||||||
|
'client_id': self.client_id,
|
||||||
|
} if append_client_id else {}),
|
||||||
|
) as r:
|
||||||
|
return await r.content.read()
|
||||||
55
bot/modules/soundcloud/song.py
Normal file
55
bot/modules/soundcloud/song.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from attrs import define
|
||||||
|
|
||||||
|
from ..common.song import BaseSongItem
|
||||||
|
from .driver import SoundCloudDriver
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class SongItem(BaseSongItem):
|
||||||
|
@classmethod
|
||||||
|
def from_soundcloud(cls, song_item: dict):
|
||||||
|
return cls(
|
||||||
|
name=song_item['title'],
|
||||||
|
id=str(song_item['id']),
|
||||||
|
artists=[],
|
||||||
|
thumbnail=(song_item['artwork_url'] or song_item['user']['avatar_url'] or
|
||||||
|
'https://soundcloud.com/images/default_avatar_large.png')
|
||||||
|
.replace('large.jpg', 't300x300.jpg'),
|
||||||
|
preview_url=None
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_artists(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class Songs(object):
|
||||||
|
driver: SoundCloudDriver
|
||||||
|
|
||||||
|
async def search(self, query: str, limit: int = 30) -> list[SongItem] | None:
|
||||||
|
r = await self.driver.search(query, limit=limit)
|
||||||
|
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return [SongItem.from_soundcloud(item) for item in r][:limit]
|
||||||
|
|
||||||
|
async def search_one(self, query: str) -> SongItem | None:
|
||||||
|
return (await self.search(query, limit=1) or [None])[0]
|
||||||
|
|
||||||
|
async def from_id(self, song_id: str) -> SongItem | None:
|
||||||
|
r = await self.driver.get_track(song_id)
|
||||||
|
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return SongItem.from_soundcloud(r)
|
||||||
|
|
||||||
|
async def from_url(self, url: str) -> SongItem | None:
|
||||||
|
r = await self.driver.resolve_url(url)
|
||||||
|
|
||||||
|
if r is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return SongItem.from_soundcloud(r)
|
||||||
12
bot/modules/soundcloud/soundcloud.py
Normal file
12
bot/modules/soundcloud/soundcloud.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from .engine import SoundCloudEngine
|
||||||
|
from .driver import SoundCloudDriver
|
||||||
|
from .song import Songs
|
||||||
|
from .downloader import DownloaderBuilder
|
||||||
|
|
||||||
|
|
||||||
|
class SoundCloud(object):
|
||||||
|
def __init__(self, client_id: str):
|
||||||
|
self.engine = SoundCloudEngine(client_id=client_id)
|
||||||
|
self.driver = SoundCloudDriver(engine=self.engine)
|
||||||
|
self.songs = Songs(driver=self.driver)
|
||||||
|
self.downloader = DownloaderBuilder(driver=self.driver)
|
||||||
@@ -28,3 +28,8 @@ async def get_id(recognised: RecognisedService):
|
|||||||
else:
|
else:
|
||||||
url = await get_url_after_redirect(recognised.parse_result.geturl())
|
url = await get_url_after_redirect(recognised.parse_result.geturl())
|
||||||
return url.split('/')[-1].split('?')[0]
|
return url.split('/')[-1].split('?')[0]
|
||||||
|
|
||||||
|
elif recognised.name == 'sc':
|
||||||
|
if not recognised.parse_result.netloc.startswith('on'):
|
||||||
|
return recognised.parse_result.geturl()
|
||||||
|
return await get_url_after_redirect(recognised.parse_result.geturl())
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ from bot.modules.database.db import DBDict
|
|||||||
from bot.modules.youtube import youtube
|
from bot.modules.youtube import youtube
|
||||||
from bot.modules.spotify import spotify
|
from bot.modules.spotify import spotify
|
||||||
from bot.modules.deezer import deezer
|
from bot.modules.deezer import deezer
|
||||||
|
from bot.modules.soundcloud import soundcloud
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RecognisedService:
|
class RecognisedService:
|
||||||
name: Literal['yt', 'spot', 'deez']
|
name: Literal['yt', 'spot', 'deez', 'sc']
|
||||||
db_table: DBDict
|
db_table: DBDict
|
||||||
by_id_func: Callable | Awaitable
|
by_id_func: Callable | Awaitable
|
||||||
parse_result: ParseResult
|
parse_result: ParseResult
|
||||||
@@ -42,5 +43,12 @@ def recognise_music_service(url: str) -> RecognisedService | None:
|
|||||||
by_id_func=deezer.songs.from_id,
|
by_id_func=deezer.songs.from_id,
|
||||||
parse_result=url
|
parse_result=url
|
||||||
)
|
)
|
||||||
|
elif url.netloc.endswith('soundcloud.com'):
|
||||||
|
return RecognisedService(
|
||||||
|
name='sc',
|
||||||
|
db_table=db.soundcloud,
|
||||||
|
by_id_func=soundcloud.songs.from_url,
|
||||||
|
parse_result=url
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|||||||
6
bot/results/soundcloud/__init__.py
Normal file
6
bot/results/soundcloud/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from .search import get_soundcloud_search_results
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_soundcloud_search_results'
|
||||||
|
]
|
||||||
23
bot/results/soundcloud/search.py
Normal file
23
bot/results/soundcloud/search.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from aiogram.types import (
|
||||||
|
InlineQueryResultDocument, InlineQueryResultCachedAudio
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.modules.soundcloud import soundcloud
|
||||||
|
from bot.modules.database import db
|
||||||
|
from bot.modules.settings import UserSettings
|
||||||
|
|
||||||
|
from ..common.search import get_common_search_result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_soundcloud_search_results(query: str, settings: UserSettings) -> list[
|
||||||
|
InlineQueryResultDocument | InlineQueryResultCachedAudio
|
||||||
|
]:
|
||||||
|
return [
|
||||||
|
await get_common_search_result(
|
||||||
|
audio=audio,
|
||||||
|
db_table=db.soundcloud,
|
||||||
|
service_id='sc',
|
||||||
|
settings=settings
|
||||||
|
)
|
||||||
|
for audio in await soundcloud.songs.search(query, limit=50)
|
||||||
|
]
|
||||||
@@ -15,5 +15,8 @@ client_secret = ''
|
|||||||
[tokens.deezer]
|
[tokens.deezer]
|
||||||
arl = ''
|
arl = ''
|
||||||
|
|
||||||
|
[tokens.soundcloud]
|
||||||
|
client_id = ''
|
||||||
|
|
||||||
[tokens.genius]
|
[tokens.genius]
|
||||||
client_acces = ''
|
client_acces = ''
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ python = "^3.11"
|
|||||||
aiogram = "^3.1.1"
|
aiogram = "^3.1.1"
|
||||||
rich = "^13.6.0"
|
rich = "^13.6.0"
|
||||||
py-deezer = "^1.1.4.post1"
|
py-deezer = "^1.1.4.post1"
|
||||||
soundcloud-lib = "^0.6.1"
|
|
||||||
shazamio = { path = "lib/ShazamIO" }
|
shazamio = { path = "lib/ShazamIO" }
|
||||||
sqlitedict = "^2.1.0"
|
sqlitedict = "^2.1.0"
|
||||||
spotipy = "^2.23.0"
|
spotipy = "^2.23.0"
|
||||||
@@ -21,6 +20,7 @@ pydub = "^0.25.1"
|
|||||||
aiohttp = "^3.8.6"
|
aiohttp = "^3.8.6"
|
||||||
nest-asyncio = "^1.5.8"
|
nest-asyncio = "^1.5.8"
|
||||||
icecream = "^2.1.3"
|
icecream = "^2.1.3"
|
||||||
|
m3u8 = "^3.6.0"
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
Reference in New Issue
Block a user