diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py index 876b352..19d6bcd 100644 --- a/bot/filters/__init__.py +++ b/bot/filters/__init__.py @@ -1,2 +1,2 @@ -from .search import ServiceSearchFilter +from .search import ServiceSearchFilter, ServiceSearchMultiletterFilter from .url import MusicUrlFilter diff --git a/bot/filters/search.py b/bot/filters/search.py index 588a31b..86d18fb 100644 --- a/bot/filters/search.py +++ b/bot/filters/search.py @@ -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 + ) diff --git a/bot/filters/url.py b/bot/filters/url.py index 6f925a1..d2c2755 100644 --- a/bot/filters/url.py +++ b/bot/filters/url.py @@ -25,6 +25,7 @@ class MusicUrlFilter(BaseFilter): 'spotify.link', 'deezer.page.link', 'deezer.com', + 'soundcloud.com' ] ) ) diff --git a/bot/handlers/inline_song/__init__.py b/bot/handlers/inline_song/__init__.py index 354a94d..7fddc2e 100644 --- a/bot/handlers/inline_song/__init__.py +++ b/bot/handlers/inline_song/__init__.py @@ -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 ) diff --git a/bot/handlers/inline_song/on_inline_soundcloud.py b/bot/handlers/inline_song/on_inline_soundcloud.py new file mode 100644 index 0000000..2618cc2 --- /dev/null +++ b/bot/handlers/inline_song/on_inline_soundcloud.py @@ -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 + ) diff --git a/bot/handlers/on_chosen/__init__.py b/bot/handlers/on_chosen/__init__.py index ce4b25b..4d1ec12 100644 --- a/bot/handlers/on_chosen/__init__.py +++ b/bot/handlers/on_chosen/__init__.py @@ -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, ) diff --git a/bot/handlers/on_chosen/soundcloud.py b/bot/handlers/on_chosen/soundcloud.py new file mode 100644 index 0000000..973acc6 --- /dev/null +++ b/bot/handlers/on_chosen/soundcloud.py @@ -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() diff --git a/bot/handlers/on_chosen/suppress_verify.py b/bot/handlers/on_chosen/suppress_verify.py index 71ef9c0..b073544 100644 --- a/bot/handlers/on_chosen/suppress_verify.py +++ b/bot/handlers/on_chosen/suppress_verify.py @@ -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( diff --git a/bot/modules/common/song/song.py b/bot/modules/common/song/song.py index 21b7099..b2f822f 100644 --- a/bot/modules/common/song/song.py +++ b/bot/modules/common/song/song.py @@ -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 diff --git a/bot/modules/database/db.py b/bot/modules/database/db.py index 4e8ee7e..5d67f09 100644 --- a/bot/modules/database/db.py +++ b/bot/modules/database/db.py @@ -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): diff --git a/bot/modules/soundcloud/__init__.py b/bot/modules/soundcloud/__init__.py new file mode 100644 index 0000000..da4c00d --- /dev/null +++ b/bot/modules/soundcloud/__init__.py @@ -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'] diff --git a/bot/modules/soundcloud/downloader.py b/bot/modules/soundcloud/downloader.py new file mode 100644 index 0000000..15d7a29 --- /dev/null +++ b/bot/modules/soundcloud/downloader.py @@ -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 + ) diff --git a/bot/modules/soundcloud/driver.py b/bot/modules/soundcloud/driver.py new file mode 100644 index 0000000..de63309 --- /dev/null +++ b/bot/modules/soundcloud/driver.py @@ -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 + } + ) diff --git a/bot/modules/soundcloud/engine.py b/bot/modules/soundcloud/engine.py new file mode 100644 index 0000000..9ce4ba1 --- /dev/null +++ b/bot/modules/soundcloud/engine.py @@ -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() diff --git a/bot/modules/soundcloud/song.py b/bot/modules/soundcloud/song.py new file mode 100644 index 0000000..5ca7c61 --- /dev/null +++ b/bot/modules/soundcloud/song.py @@ -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) diff --git a/bot/modules/soundcloud/soundcloud.py b/bot/modules/soundcloud/soundcloud.py new file mode 100644 index 0000000..03cb8e9 --- /dev/null +++ b/bot/modules/soundcloud/soundcloud.py @@ -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) diff --git a/bot/modules/url/id_getter.py b/bot/modules/url/id_getter.py index 4c1150b..21b0fc3 100644 --- a/bot/modules/url/id_getter.py +++ b/bot/modules/url/id_getter.py @@ -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()) diff --git a/bot/modules/url/recognise.py b/bot/modules/url/recognise.py index 78809d7..4be60d7 100644 --- a/bot/modules/url/recognise.py +++ b/bot/modules/url/recognise.py @@ -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 diff --git a/bot/results/soundcloud/__init__.py b/bot/results/soundcloud/__init__.py new file mode 100644 index 0000000..ea67cc2 --- /dev/null +++ b/bot/results/soundcloud/__init__.py @@ -0,0 +1,6 @@ +from .search import get_soundcloud_search_results + + +__all__ = [ + 'get_soundcloud_search_results' +] diff --git a/bot/results/soundcloud/search.py b/bot/results/soundcloud/search.py new file mode 100644 index 0000000..8c6b662 --- /dev/null +++ b/bot/results/soundcloud/search.py @@ -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) + ] diff --git a/config.toml.example b/config.toml.example index a9bb666..f6c85be 100644 --- a/config.toml.example +++ b/config.toml.example @@ -15,5 +15,8 @@ client_secret = '' [tokens.deezer] arl = '' +[tokens.soundcloud] +client_id = '' + [tokens.genius] client_acces = '' diff --git a/pyproject.toml b/pyproject.toml index fe351f5..77fa6ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]