# Proprietary License Agreement # Copyright (c) 2024-29 CodWiz # Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use the Software for personal and non-commercial purposes, subject to the following conditions: # 1. The Software may not be modified, altered, or otherwise changed in any way without the explicit written permission of the author. # 2. Redistribution of the Software, in original or modified form, is strictly prohibited without the explicit written permission of the author. # 3. The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author or copyright holder be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software. # 4. Any use of the Software must include the above copyright notice and this permission notice in all copies or substantial portions of the Software. # 5. By using the Software, you agree to be bound by the terms and conditions of this license. # For any inquiries or requests for permissions, please contact codwiz@yandex.ru. # --------------------------------------------------------------------------------- # Name: SoundCloud # Description: Card with the currently playing track on SoundCloud # Author: @hikka_mods # --------------------------------------------------------------------------------- # meta developer: @hikka_mods # scope: SoundCloud # scope: SoundCloud 0.0.2 # requires: requests pillow yt-dlp # --------------------------------------------------------------------------------- import contextlib import dataclasses import functools import hashlib import io import logging from typing import Dict, List, Optional import requests from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont from telethon.tl.types import Message from yt_dlp import YoutubeDL from .. import loader, utils logger = logging.getLogger(__name__) _API = "https://api-v2.soundcloud.com" _COVER_HQ = "-t500x500" _ORANGE = (255, 85, 0) _DIM = (155, 155, 170) _FADED = (100, 100, 115) _CARD_BG = (255, 255, 255, 14) _CARD_ACTIVE = (255, 255, 255, 26) _BAR_MUTED = (255, 255, 255, 16) @dataclasses.dataclass(frozen=True) class TrackInfo: """Parsed SoundCloud track metadata.""" track_id: int title: str artist: str duration_ms: int permalink: str cover_url: str genre: str plays: int likes: int reposts: int comments: int @classmethod def parse(cls, raw: dict) -> "TrackInfo": u = raw.get("user") or {} return cls( track_id=raw.get("id", 0), title=raw.get("title") or "Unknown", artist=u.get("username") or "Unknown", duration_ms=raw.get("duration") or raw.get("full_duration") or 0, permalink=raw.get("permalink_url") or "", cover_url=raw.get("artwork_url") or u.get("avatar_url") or "", genre=raw.get("genre") or "", plays=raw.get("playback_count") or 0, likes=raw.get("likes_count") or raw.get("favoritings_count") or 0, reposts=raw.get("reposts_count") or 0, comments=raw.get("comment_count") or 0, ) @property def duration_fmt(self) -> str: s = self.duration_ms // 1000 return f"{s // 60}:{s % 60:02d}" @property def hq_cover(self) -> str: return self.cover_url.replace("-large", _COVER_HQ) def _compact(n: int) -> str: """Format large numbers: 12500 → 12.5K.""" if n >= 1_000_000: return f"{n / 1_000_000:.1f}M" if n >= 1_000: return f"{n / 1_000:.1f}K" return str(n) class _Fonts: """Cached font loader from raw bytes.""" __slots__ = ("_raw", "_loaded") def __init__(self, data: bytes): self._raw = data self._loaded: Dict[int, ImageFont.FreeTypeFont] = {} def __call__(self, size: int) -> ImageFont.FreeTypeFont: if size not in self._loaded: self._loaded[size] = ImageFont.truetype(io.BytesIO(self._raw), size) return self._loaded[size] def fit(self, text: str, max_w: int, hi: int, lo: int) -> ImageFont.FreeTypeFont: for s in range(hi, lo - 1, -2): f = self(s) if f.getlength(text) <= max_w: return f return self(lo) def _ellipsis(text: str, font: ImageFont.FreeTypeFont, max_w: int) -> str: """Truncate text with '…' using binary search.""" if font.getlength(text) <= max_w: return text lo, hi = 0, len(text) while lo < hi: mid = (lo + hi + 1) // 2 if font.getlength(text[:mid] + "…") <= max_w: lo = mid else: hi = mid - 1 return text[:lo] + "…" def _center_text(draw, text, font, y, canvas_w, fill="white"): bb = draw.textbbox((0, 0), text, font=font) draw.text(((canvas_w - bb[2] + bb[0]) // 2, y), text, font=font, fill=fill) def _frosted_bg(src: bytes, w: int, h: int, dim: float = 0.25) -> Image.Image: """Blurred & dimmed background from cover art.""" img = Image.open(io.BytesIO(src)).convert("RGBA") small = img.resize((max(w // 5, 1), max(h // 5, 1)), Image.Resampling.BILINEAR) small = small.filter(ImageFilter.GaussianBlur(12)) result = small.resize((w, h), Image.Resampling.BILINEAR) return ImageEnhance.Brightness(result).enhance(dim) def _gradient( w: int, h: int, vertical: bool = True, c_from=(0, 0, 0, 160), c_to=(0, 0, 0, 40) ) -> Image.Image: """Fast linear gradient via 1px strip resize.""" length = h if vertical else w strip = Image.new("RGBA", (1, length) if vertical else (length, 1)) px = strip.load() for i in range(length): t = i / max(length - 1, 1) rgba = tuple(int(c_from[c] + (c_to[c] - c_from[c]) * t) for c in range(4)) if vertical: px[0, i] = rgba else: px[i, 0] = rgba return strip.resize((w, h), Image.Resampling.BILINEAR) def _round_corners(img: Image.Image, r: int) -> Image.Image: mask = Image.new("L", img.size, 0) ImageDraw.Draw(mask).rounded_rectangle((0, 0, *img.size), r, fill=255) out = Image.new("RGBA", img.size, (0, 0, 0, 0)) out.paste(img, mask=mask) return out def _rounded_cover(data: bytes, size: int, r: int) -> Image.Image: img = Image.open(io.BytesIO(data)).convert("RGBA") img = img.resize((size, size), Image.Resampling.LANCZOS) return _round_corners(img, r) def _place_cover( base: Image.Image, cover_data: bytes, size: int, radius: int, pos: tuple, shadow_blur: int = 20, shadow_alpha: int = 50, ): """Place cover with colored drop shadow (offset downward).""" cover = _rounded_cover(cover_data, size, radius) avg = cover.resize((1, 1), Image.Resampling.BILINEAR).getpixel((0, 0)) pad = shadow_blur * 2 offset_y = 8 canvas = Image.new( "RGBA", (size + pad * 2, size + pad * 2 + offset_y), (0, 0, 0, 0) ) shadow_shape = Image.new("RGBA", (size, size), (0, 0, 0, 0)) ImageDraw.Draw(shadow_shape).rounded_rectangle( (0, 0, size, size), radius, fill=(*avg[:3], shadow_alpha) ) canvas.paste(shadow_shape, (pad, pad + offset_y), shadow_shape) canvas = canvas.filter(ImageFilter.GaussianBlur(shadow_blur)) canvas.paste(cover, (pad, pad), cover) base.paste(canvas, (pos[0] - pad, pos[1] - pad), canvas) def _waveform(draw, x, y, w, h, bars=45, color=_ORANGE, muted=_BAR_MUTED, prog=0.0): """Waveform visualization bars with sha256-seeded heights.""" bw = max(w // (bars * 2), 2) gap = (w - bw * bars) // max(bars - 1, 1) seed = hashlib.sha256(f"sc{bars}".encode()).digest() for i in range(bars): bx = x + i * (bw + gap) amp = seed[i % len(seed)] / 255 bh = int(h * (0.25 + amp * 0.75)) by = y + (h - bh) // 2 c = color if i / bars <= prog else muted draw.rounded_rectangle((bx, by, bx + bw, by + bh), bw // 2, fill=c) def _badge( draw, text, font, x, y, fg="white", bg=(255, 255, 255, 18), px=12, py=5 ) -> int: """Rounded pill badge. Returns width.""" bb = font.getbbox(text) tw, th = bb[2] - bb[0], bb[3] - bb[1] pw, ph = tw + px * 2, th + py * 2 draw.rounded_rectangle((x, y, x + pw, y + ph), ph // 2, fill=bg) draw.text((x + px, y + py), text, font=font, fill=fg) return pw def _export(img: Image.Image, name: str = "soundcloud.png") -> io.BytesIO: buf = io.BytesIO() img.save(buf, "PNG", optimize=True) buf.seek(0) buf.name = name return buf class CardFactory: """Generates visual cards for SoundCloud tracks.""" def __init__(self, fonts: _Fonts): self._f = fonts def square(self, track: TrackInfo, cover: bytes) -> io.BytesIO: """Square now-playing card (800×800).""" S = 800 p = 45 bg = _frosted_bg(cover, S, S, 0.22) bg = Image.alpha_composite( bg, _gradient(S, S, True, (0, 0, 0, 50), (0, 0, 0, 190)) ) draw = ImageDraw.Draw(bg) bf = self._f(12) draw.text((p, p), "SOUNDCLOUD", font=bf, fill=_ORANGE) lw = bf.getlength("SOUNDCLOUD") draw.line([(p, p + 17), (p + lw, p + 17)], fill=(*_ORANGE, 100), width=2) cs = 310 cx, cy = (S - cs) // 2, p + 32 _place_cover(bg, cover, cs, 14, (cx, cy), shadow_blur=25, shadow_alpha=50) draw = ImageDraw.Draw(bg) wy = cy + cs + 30 _waveform(draw, p + 35, wy, S - p * 2 - 70, 26, bars=50) tf = self._f(13) draw.text((p + 35, wy + 30), "0:00", font=tf, fill=_FADED) ds = track.duration_fmt draw.text((S - p - 35 - tf.getlength(ds), wy + 30), ds, font=tf, fill=_FADED) tw = S - p * 2 ty = wy + 56 title_f = self._f.fit(track.title, tw, 36, 20) _center_text(draw, _ellipsis(track.title, title_f, tw), title_f, ty, S) af = self._f.fit(track.artist, tw, 24, 16) _center_text(draw, _ellipsis(track.artist, af, tw), af, ty + 44, S, _DIM) sy = ty + 92 sf = self._f(14) parts = [] if track.genre: parts.append(track.genre) if track.plays: parts.append(f"▶ {_compact(track.plays)}") if track.likes: parts.append(f"♥ {_compact(track.likes)}") if not parts: parts.append(track.duration_fmt) _center_text(draw, " · ".join(parts), sf, sy, S, _FADED) return _export(_round_corners(bg, 22)) def horizontal(self, track: TrackInfo, cover: bytes) -> io.BytesIO: """Wide now-playing card (1200×400).""" W, H = 1200, 400 p = 40 cs = 280 bg = _frosted_bg(cover, W, H, 0.22) bg = Image.alpha_composite( bg, _gradient(W, H, False, (0, 0, 0, 180), (0, 0, 0, 60)) ) cvy = (H - cs) // 2 _place_cover(bg, cover, cs, 14, (p, cvy), shadow_blur=20, shadow_alpha=40) draw = ImageDraw.Draw(bg) bf = self._f(11) draw.text((p, p - 6), "SOUNDCLOUD", font=bf, fill=_ORANGE) if track.genre: gf = self._f(12) gt = track.genre.upper() draw.text((W - p - gf.getlength(gt), p - 6), gt, font=gf, fill=_FADED) tx = p + cs + 50 tw = W - tx - p tty = cvy + 10 title_f = self._f.fit(track.title, tw, 36, 22) draw.text( (tx, tty), _ellipsis(track.title, title_f, tw), font=title_f, fill="white", ) af = self._f(22) draw.text( (tx, tty + 50), _ellipsis(track.artist, af, tw), font=af, fill=_DIM, ) by = tty + 98 bx = tx pill_f = self._f(14) bw = _badge( draw, track.duration_fmt, pill_f, bx, by, fg=_ORANGE, bg=(*_ORANGE, 35), ) bx += bw + 8 if track.plays: bw = _badge(draw, f"▶ {_compact(track.plays)}", pill_f, bx, by, fg=_DIM) bx += bw + 8 if track.likes: _badge(draw, f"♥ {_compact(track.likes)}", pill_f, bx, by, fg=_DIM) wy = cvy + cs - 50 _waveform(draw, tx, wy, tw, 22, bars=55) wf = self._f(12) draw.text((tx, wy + 26), "0:00", font=wf, fill=_FADED) ds = track.duration_fmt draw.text((tx + tw - wf.getlength(ds), wy + 26), ds, font=wf, fill=_FADED) return _export(_round_corners(bg, 20)) def history(self, tracks: List[TrackInfo], fetch_cover) -> io.BytesIO: """History card with dynamic height based on track count.""" W = 1200 p = 36 row_h = 120 gap = 8 hdr = 55 n = len(tracks) H = p * 2 + hdr + n * row_h + (n - 1) * gap bg_data = fetch_cover(tracks[0].hq_cover) bg = _frosted_bg(bg_data, W, H, 0.18) bg = Image.alpha_composite(bg, Image.new("RGBA", (W, H), (0, 0, 0, 150))) draw = ImageDraw.Draw(bg) hf = self._f(14) draw.text((p, p), "SOUNDCLOUD", font=hf, fill=_ORANGE) thf = self._f(22) draw.text((p, p + 20), "Listening History", font=thf, fill="white") lw = hf.getlength("SOUNDCLOUD") draw.rounded_rectangle((p, p + 48, p + lw, p + 50), 1, fill=_ORANGE) ct = f"{n} tracks" draw.text((W - p - hf.getlength(ct), p + 22), ct, font=hf, fill=_FADED) title_f = self._f(22) artist_f = self._f(16) time_f = self._f(14) num_f = self._f(12) cp = 12 cvsz = row_h - cp * 2 card_w = W - p * 2 yo = p + hdr + 8 for idx, trk in enumerate(tracks): ry = int(yo) card = Image.new("RGBA", (card_w, row_h), (0, 0, 0, 0)) cd = ImageDraw.Draw(card) cd.rounded_rectangle( (0, 0, card_w, row_h), 12, fill=_CARD_ACTIVE if idx == 0 else _CARD_BG, ) if idx == 0: cd.rounded_rectangle((0, 0, 4, row_h), 2, fill=_ORANGE) region = bg.crop((p, ry, p + card_w, ry + row_h)) bg.paste(Image.alpha_composite(region, card), (p, ry)) try: cv_data = fetch_cover(trk.hq_cover) cv = _rounded_cover(cv_data, cvsz, 8) bg.paste(cv, (p + cp + 6, ry + cp), cv) except Exception: pass draw = ImageDraw.Draw(bg) nt = f"{idx + 1:02d}" nw = num_f.getlength(nt) nx = p + cp + 6 + (cvsz - nw) // 2 ny = ry + cp + cvsz - 18 draw.rounded_rectangle( (nx - 3, ny - 1, nx + nw + 3, ny + 14), 3, fill=(0, 0, 0, 170) ) draw.text((nx, ny - 1), nt, font=num_f, fill=_ORANGE) txt_x = p + cp + cvsz + 24 txt_w = card_w - cvsz - cp * 3 - 24 - 70 ty_center = ry + (row_h - 58) // 2 draw.text( (txt_x, ty_center), _ellipsis(trk.title, title_f, txt_w), font=title_f, fill="white", ) draw.text( (txt_x, ty_center + 30), _ellipsis(trk.artist, artist_f, txt_w), font=artist_f, fill=_DIM, ) dt = trk.duration_fmt dw = time_f.getlength(dt) draw.text( (p + card_w - cp - dw - 8, ty_center + 4), dt, font=time_f, fill=_FADED, ) if trk.plays: pt = f"▶ {_compact(trk.plays)}" pw = time_f.getlength(pt) draw.text( (p + card_w - cp - pw - 8, ty_center + 24), pt, font=time_f, fill=_FADED, ) yo += row_h + gap return _export(_round_corners(bg, 20), "soundcloud_history.png") def _require_token(func): """Decorator: ensure oauth_token is configured.""" @functools.wraps(func) async def wrapper(self, message, *a, **kw): if not self.config["oauth_token"]: return await utils.answer(message, self.strings("no_token")) return await func(self, message, *a, **kw) return wrapper def _catch_errors(func): """Decorator: log & report exceptions to user.""" @functools.wraps(func) async def wrapper(self, message, *a, **kw): try: return await func(self, message, *a, **kw) except Exception: logger.exception("SoundCloud: %s failed", func.__name__) with contextlib.suppress(Exception): import traceback await utils.answer( message, self.strings("error").format(traceback.format_exc()) ) return wrapper @loader.tds class SoundCloudMod(loader.Module): """Display the currently playing SoundCloud track as a stylized card.""" strings = { "name": "SoundCloud", "no_token": ( "\u274c" " Set oauth_token in module config\n\n" "\U0001f511 Get it via extension:\n" "\u2022 Chromium\n" "\u2022 Firefox\n" "\u2022 Or via DevTools: Application \u2192 Cookies \u2192 " "oauth_token" ), "nothing": ( "" " Nothing is playing right now" ), "error": ( "" " Error\n{}" ), "wait_card": ( "\n\n🕔" " Generating card…" ), "wait_dl": ( "\n\n🕔 Downloading…" ), "dl_fail": ( "\n\n" " Download failed" ), } strings_ru = { "no_token": ( "" " Установи oauth_token" " в конфиге модуля\n\n" "🔑 Получить токен:\n" "• Chromium\n" "• Firefox\n" "• Или через DevTools: Application → Cookies → " "oauth_token" ), "nothing": ( "" " Сейчас ничего не играет" ), "error": ( "" " Ошибка\n{}" ), "wait_card": ( "\n\n🕔" " Генерация карточки…" ), "wait_dl": ( "\n\n🕔 Скачивание…" ), "dl_fail": ( "\n\n" " Ошибка скачивания" ), } def __init__(self): self._font_data: Optional[bytes] = None self._font_src: Optional[str] = None self.config = loader.ModuleConfig( loader.ConfigValue( "show_banner", True, "Generate image card", validator=loader.validators.Boolean(), ), loader.ConfigValue( "banner_type", "square", "Card layout", validator=loader.validators.Choice(["square", "horizontal"]), ), loader.ConfigValue( "template", ( "🎧" " Now playing: {artist} — {track}\n" "🕓" " {duration}{genre}\n" "🔗" " SoundCloud" ), "Message template. Placeholders: {track}, {artist}," " {url}, {duration}, {genre}, {stats}", validator=loader.validators.String(), ), loader.ConfigValue( "font", "https://github.com/web-fonts/ttf/raw/refs/heads/master/alk-sanet-webfont.ttf", "URL to .ttf font file", validator=loader.validators.String(), ), loader.ConfigValue( "oauth_token", "", "SoundCloud OAuth token", validator=loader.validators.String(), ), loader.ConfigValue( "history_count", 5, "Tracks in history (3–5)", validator=loader.validators.Integer(minimum=3, maximum=5), ), ) def _headers(self) -> dict: return { "Authorization": f"OAuth {self.config['oauth_token']}", "Accept": "application/json", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ), } async def _get(self, path: str, **params) -> Optional[dict]: try: r = await utils.run_sync( requests.get, f"{_API}{path}", headers=self._headers(), params=params, timeout=5, ) if r.status_code == 200: return r.json() except Exception: logger.debug("SC API %s failed", path) return None async def _load_font(self) -> bytes: url = self.config["font"] if self._font_data and self._font_src == url: return self._font_data data = await utils.run_sync(lambda: requests.get(url, timeout=10).content) self._font_data = data self._font_src = url return data async def _load_cover(self, url: str) -> Optional[bytes]: try: hq = url.replace("-large", _COVER_HQ) r = await utils.run_sync(requests.get, hq, timeout=10) if r.status_code == 200: return r.content except Exception: pass return None async def _current(self) -> Optional[TrackInfo]: for ep in ("/me/play-history/tracks", "/me/activities", "/stream"): data = await self._get(ep, limit=3) if not data: continue for item in data.get("collection", []): raw = item.get("track") or item if raw and "title" in raw and (raw.get("duration") or 0) > 0: return TrackInfo.parse(raw) return None async def _recent(self, count: int) -> List[TrackInfo]: data = await self._get("/me/play-history/tracks", limit=count) if not data: return [] return [ TrackInfo.parse(it["track"]) for it in data.get("collection", []) if it.get("track") and "title" in it["track"] ] async def _download(self, url: str) -> Optional[bytes]: try: token = self.config["oauth_token"] opts = { "format": "best[ext=mp3]/best", "quiet": True, "no_warnings": True, "http_headers": { "Authorization": f"OAuth {token}", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ), }, } def _run(): with YoutubeDL(opts) as ydl: info = ydl.extract_info(url, download=False) audio = info.get("url") if audio: r = requests.get(audio, timeout=60) if r.status_code == 200: return r.content return None return await utils.run_sync(_run) except Exception as e: logger.error("Download failed: %s", e) return None def _format_message(self, t: TrackInfo) -> str: genre_part = f" | {utils.escape_html(t.genre)}" if t.genre else "" stats = [] if t.plays: stats.append(f"▶ {_compact(t.plays)}") if t.likes: stats.append(f"♥ {_compact(t.likes)}") return self.config["template"].format( track=utils.escape_html(t.title), artist=utils.escape_html(t.artist), duration=t.duration_fmt, url=t.permalink, genre=genre_part, stats=" · ".join(stats), ) def _format_detail(self, t: TrackInfo) -> str: parts = [t.duration_fmt] if t.genre: parts.append(utils.escape_html(t.genre)) if t.plays: parts.append(f"▶ {_compact(t.plays)}") if t.likes: parts.append(f"♥ {_compact(t.likes)}") info = " | ".join(parts) return ( f"🎧" f" {utils.escape_html(t.artist)} — {utils.escape_html(t.title)}\n" f"🕓 {info}\n" f"🔗" f" SoundCloud" ) @_catch_errors @_require_token @loader.command( ru_doc="— Показать карточку текущего трека", en_doc="— Show current track card", ) async def scnow(self, message: Message): track = await self._current() if not track: return await utils.answer(message, self.strings("nothing")) text = self._format_message(track) if not (self.config["show_banner"] and track.cover_url): return await utils.answer(message, text) msg = await utils.answer(message, text + self.strings("wait_card")) cover = await self._load_cover(track.cover_url) if not cover: return await utils.answer(msg, text) font_data = await self._load_font() factory = CardFactory(_Fonts(font_data)) render = ( factory.square if self.config["banner_type"] == "square" else factory.horizontal ) card = await utils.run_sync(render, track, cover) await utils.answer(msg, text, file=card) @_catch_errors @_require_token @loader.command( ru_doc="— Скачать текущий трек", en_doc="— Download current track", ) async def scnowt(self, message: Message): track = await self._current() if not track: return await utils.answer(message, self.strings("nothing")) text = self._format_detail(track) msg = await utils.answer(message, text + self.strings("wait_dl")) audio = await self._download(track.permalink) if not audio: return await utils.answer(msg, text + self.strings("dl_fail")) buf = io.BytesIO(audio) buf.name = f"{track.artist} - {track.title}.mp3" await utils.answer(msg, text, file=buf) @_catch_errors @_require_token @loader.command( ru_doc="— История прослушивания", en_doc="— Listening history", ) async def schistory(self, message: Message): tracks = await self._recent(self.config["history_count"]) if not tracks: return await utils.answer(message, self.strings("nothing")) text = ( "📜" " История прослушивания:\n\n" ) for i, t in enumerate(tracks, 1): parts = [t.duration_fmt] if t.genre: parts.append(utils.escape_html(t.genre)) if t.plays: parts.append(f"▶ {_compact(t.plays)}") meta = " | ".join(parts) text += ( f"{i}. {utils.escape_html(t.artist)} —" f" {utils.escape_html(t.title)}\n" f" 🕓" f" {meta} | Link\n\n" ) if not self.config["show_banner"]: return await utils.answer(message, text) msg = await utils.answer(message, text + self.strings("wait_card")) try: font_data = await self._load_font() def _render(): factory = CardFactory(_Fonts(font_data)) def fetcher(u): return requests.get(u, timeout=10).content return factory.history(tracks, fetcher) card = await utils.run_sync(_render) await utils.answer(msg, text, file=card) except Exception: await utils.answer(msg, text)