Add soundcloud support

This commit is contained in:
BarsTiger
2023-11-13 23:08:58 +02:00
parent e99ba9daa3
commit bc2663c17c
22 changed files with 391 additions and 7 deletions

View File

@@ -1,2 +1,2 @@
from .search import ServiceSearchFilter
from .search import ServiceSearchFilter, ServiceSearchMultiletterFilter
from .url import MusicUrlFilter

View File

@@ -11,3 +11,15 @@ class ServiceSearchFilter(BaseFilter):
inline_query.query.startswith(self.service_letter) and
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
)

View File

@@ -25,6 +25,7 @@ class MusicUrlFilter(BaseFilter):
'spotify.link',
'deezer.page.link',
'deezer.com',
'soundcloud.com'
]
)
)

View File

@@ -1,10 +1,12 @@
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.include_routers(
on_inline_spotify.router,
on_inline_deezer.router,
on_inline_youtube.router,
on_inline_soundcloud.router
)

View 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
)

View File

@@ -1,5 +1,5 @@
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()
@@ -7,6 +7,7 @@ router.include_routers(
spotify.router,
deezer.router,
youtube.router,
soundcloud.router,
recode_cached.router,
suppress_verify.router,
)

View 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()

View File

@@ -7,7 +7,7 @@ router = Router()
@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):
await bot.edit_message_reply_markup(

View File

@@ -15,7 +15,7 @@ class BaseSongItem:
@property
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):
return self.full_name

View File

@@ -20,6 +20,7 @@ class Db(object):
self.spotify = DBDict('spotify')
self.deezer = DBDict('deezer')
self.youtube = DBDict('youtube')
self.soundcloud = DBDict('soundcloud')
self.recoded = DBDict('recoded')
async def write(self):

View 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']

View 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
)

View 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
}
)

View 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()

View 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)

View 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)

View File

@@ -28,3 +28,8 @@ async def get_id(recognised: RecognisedService):
else:
url = await get_url_after_redirect(recognised.parse_result.geturl())
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())

View File

@@ -9,11 +9,12 @@ from bot.modules.database.db import DBDict
from bot.modules.youtube import youtube
from bot.modules.spotify import spotify
from bot.modules.deezer import deezer
from bot.modules.soundcloud import soundcloud
@dataclass
class RecognisedService:
name: Literal['yt', 'spot', 'deez']
name: Literal['yt', 'spot', 'deez', 'sc']
db_table: DBDict
by_id_func: Callable | Awaitable
parse_result: ParseResult
@@ -42,5 +43,12 @@ def recognise_music_service(url: str) -> RecognisedService | None:
by_id_func=deezer.songs.from_id,
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:
return None

View File

@@ -0,0 +1,6 @@
from .search import get_soundcloud_search_results
__all__ = [
'get_soundcloud_search_results'
]

View 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)
]

View File

@@ -15,5 +15,8 @@ client_secret = ''
[tokens.deezer]
arl = ''
[tokens.soundcloud]
client_id = ''
[tokens.genius]
client_acces = ''

View File

@@ -10,7 +10,6 @@ python = "^3.11"
aiogram = "^3.1.1"
rich = "^13.6.0"
py-deezer = "^1.1.4.post1"
soundcloud-lib = "^0.6.1"
shazamio = { path = "lib/ShazamIO" }
sqlitedict = "^2.1.0"
spotipy = "^2.23.0"
@@ -21,6 +20,7 @@ pydub = "^0.25.1"
aiohttp = "^3.8.6"
nest-asyncio = "^1.5.8"
icecream = "^2.1.3"
m3u8 = "^3.6.0"
[build-system]