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 from .url import MusicUrlFilter

View File

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

View File

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

View File

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

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

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( @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(

View File

@@ -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

View File

@@ -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):

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

View File

@@ -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

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] [tokens.deezer]
arl = '' arl = ''
[tokens.soundcloud]
client_id = ''
[tokens.genius] [tokens.genius]
client_acces = '' client_acces = ''

View File

@@ -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]