diff --git a/fiksofficial/python-modules/full.txt b/fiksofficial/python-modules/full.txt index 68362d7..72e180b 100644 --- a/fiksofficial/python-modules/full.txt +++ b/fiksofficial/python-modules/full.txt @@ -23,4 +23,5 @@ point deviceinfo mpi aigenuser -github \ No newline at end of file +github +stream \ No newline at end of file diff --git a/fiksofficial/python-modules/libs/pyupdater.py b/fiksofficial/python-modules/libs/pyupdater.py new file mode 100644 index 0000000..df7e651 --- /dev/null +++ b/fiksofficial/python-modules/libs/pyupdater.py @@ -0,0 +1,222 @@ +# ______ ___ ___ _ _ +# ____ | ___ \ | \/ | | | | | +# / __ \| |_/ / _| . . | ___ __| |_ _| | ___ +# / / _` | __/ | | | |\/| |/ _ \ / _` | | | | |/ _ \ +# | | (_| | | | |_| | | | | (_) | (_| | |_| | | __/ +# \ \__,_\_| \__, \_| |_/\___/ \__,_|\__,_|_|\___| +# \____/ __/ | +# |___/ + +# На модуль распространяется лицензия "GNU General Public License v3.0" +# https://github.com/all-licenses/GNU-General-Public-License-v3.0 + +# meta developer: @pymodule +# requires: aiohttp + +import asyncio +import hashlib +import hmac +import logging +import re +import time + +from .. import loader + +logger = logging.getLogger(__name__) + +BLOB_RE = re.compile(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)") +RAW_RE = re.compile(r"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)") + +API_BASE = "https://37a5bcc11453.vps.myjino.ru" +BOT_USERNAME = "pyupdater_bot" +GITHUB_TOKEN = "" + +TOKEN_DB_KEY = "user_token" +TOKEN_PREFIX = "pyut_" + + +def _parse(url: str): + for pat in (BLOB_RE, RAW_RE): + m = pat.match(url.strip()) + if m: + return m.groups() + return None + + +def _gh_headers() -> dict: + h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + if GITHUB_TOKEN: + h["Authorization"] = f"Bearer {GITHUB_TOKEN}" + return h + + +def _sign(secret_key: str, api_key: str) -> str: + bucket = (int(time.time()) // 60) * 60 + msg = f"{api_key}:{bucket}".encode() + return hmac.new(secret_key.encode(), msg, hashlib.sha256).hexdigest() + + +class PyUpdaterLib(loader.Library): + developer = "@pyupdater" + version = (1, 0, 0) + + async def init(self): + await self._refresh_token() + + async def _refresh_token(self): + """Каждый раз при загрузке библиотеки запрашивает свежий токен у бота.""" + try: + sent = await self.client.send_message(BOT_USERNAME, "/token") + + response = None + for _ in range(20): + await asyncio.sleep(0.5) + msgs = await self.client.get_messages(BOT_USERNAME, limit=1) + if ( + msgs + and msgs[0].id != sent.id + and msgs[0].text + and msgs[0].text.startswith(TOKEN_PREFIX) + ): + response = msgs[0] + break + + if response is None: + logger.warning("PyUpdater: не получили токен от бота") + return + + self._lib_set(TOKEN_DB_KEY, response.text.strip()) + logger.info("PyUpdater: токен обновлён") + + async def _cleanup(): + await asyncio.sleep(3) + try: + await sent.delete() + await response.delete() + except Exception: + pass + + asyncio.create_task(_cleanup()) + + except Exception: + logger.exception("PyUpdater: ошибка при получении токена") + + async def check( + self, + module: loader.Module, + github_url: str, + api_key: str, + secret_key: str, + ) -> None: + parts = _parse(github_url) + if parts is None: + logger.warning("PyUpdater: cannot parse URL %r", github_url) + return + + owner, repo, branch, path = parts + module_name = module.strings["name"] + + asyncio.create_task(self._ping(api_key, secret_key, module.tg_id, module_name)) + asyncio.create_task(self._maybe_confirm_test(api_key, module.tg_id)) + + commits_url = ( + f"https://api.github.com/repos/{owner}/{repo}/commits" + f"?path={path}&sha={branch}&per_page=1" + ) + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}" + db_key = f"pyu_sha_{owner}_{repo}_{path.replace('/', '_')}" + saved_sha: str = module._db.get(module_name, db_key, "") + + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get( + commits_url, + headers=_gh_headers(), + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status in (403, 429): + logger.warning("PyUpdater: GitHub rate limit for %s", module_name) + return + resp.raise_for_status() + commits = await resp.json() + + if not commits: + return + + latest_sha: str = commits[0]["sha"] + if latest_sha == saved_sha: + return + + async with session.get( + raw_url, + headers=_gh_headers(), + timeout=aiohttp.ClientTimeout(total=20), + ) as resp: + resp.raise_for_status() + new_code = await resp.text() + + except asyncio.TimeoutError: + logger.warning("PyUpdater: timeout for %s", module_name) + return + except Exception: + logger.exception("PyUpdater: error checking %s", module_name) + return + + module._db.set(module_name, db_key, latest_sha) + logger.info("PyUpdater: reloading %s (commit %s)", module_name, latest_sha[:7]) + + loader_mod = next( + (m for m in module.allmodules.modules if m.__class__.__name__ == "LoaderMod"), + None, + ) + if loader_mod is None: + logger.error("PyUpdater: LoaderMod not found") + return + + asyncio.create_task(loader_mod.load_module(new_code, None, save_fs=True)) + + async def _maybe_confirm_test(self, api_key: str, user_tg_id: int) -> None: + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get( + f"{API_BASE}/test/pending", + params={"api_key": api_key}, + timeout=aiohttp.ClientTimeout(total=8), + ) as resp: + if resp.status != 200: + return + data = await resp.json() + if not data.get("pending"): + return + + await session.post( + f"{API_BASE}/test/confirm", + json={"api_key": api_key, "user_tg_id": user_tg_id}, + timeout=aiohttp.ClientTimeout(total=8), + ) + except Exception: + pass + + async def _ping(self, api_key: str, secret_key: str, user_tg_id: int, module_name: str) -> None: + user_token: str = self._lib_get(TOKEN_DB_KEY, "") + payload = { + "api_key": api_key, + "signature": _sign(secret_key, api_key), + "user_tg_id": user_tg_id, + "module_name": module_name, + } + if user_token: + payload["user_token"] = user_token + + try: + import aiohttp + async with aiohttp.ClientSession() as session: + await session.post( + f"{API_BASE}/ping", + json=payload, + timeout=aiohttp.ClientTimeout(total=8), + ) + except Exception: + pass diff --git a/fiksofficial/python-modules/stream.py b/fiksofficial/python-modules/stream.py new file mode 100644 index 0000000..771b5f2 --- /dev/null +++ b/fiksofficial/python-modules/stream.py @@ -0,0 +1,435 @@ + +import asyncio +import mimetypes +import os +import subprocess +import time + +from .. import loader, utils +from ..inline.types import InlineCall + +def detect_type(path: str) -> str: + mime, _ = mimetypes.guess_type(path) + if not mime: + return "video" + if mime.startswith("video"): + return "video" + if mime.startswith("audio"): + return "audio" + if mime.startswith("image"): + return "image" + return "video" + +TYPE_ICON = {"video": "🎬", "audio": "🎵", "image": "🖼️"} +PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"] +TUNES = ["zerolatency", "film", "animation", "grain", "stillimage", "fastdecode"] +SCALES = ["off", "426x240", "640x360", "854x480", "1280x720", "1920x1080", "2560x1440"] +FPS_OPT = [24, 25, 30, 48, 60] + +def build_cmd(file_path: str, rtmp_url: str, cfg: dict) -> list: + preset = cfg.get("preset", "veryfast") + tune = cfg.get("tune", "zerolatency") + vbr = cfg.get("vbitrate", "2000k") + abr = cfg.get("abitrate", "128k") + fps = str(cfg.get("fps", 30)) + res = cfg.get("resolution", None) + threads = str(cfg.get("threads", 0)) + gop = str(int(fps) * 2) + bufsize = str(int(vbr.replace("k", "")) * 2) + "k" + ftype = detect_type(file_path) + + base = ["ffmpeg", "-re", "-stream_loop", "-1", "-threads", threads] + vf_scale = f",scale={res}" if res else "" + common_v = [ + "-c:v", "libx264", "-preset", preset, "-tune", tune, + "-pix_fmt", "yuv420p", "-profile:v", "baseline", + "-r", fps, "-g", gop, "-keyint_min", gop, "-sc_threshold", "0", + "-b:v", vbr, "-maxrate", vbr, "-bufsize", bufsize, + ] + common_a = ["-c:a", "aac", "-b:a", abr, "-ar", "44100"] + out = ["-f", "flv", rtmp_url] + + if ftype == "video": + vf = ["-vf", f"scale=trunc(iw/2)*2:trunc(ih/2)*2{vf_scale}"] if res else [] + return base + ["-i", file_path] + common_v + vf + common_a + out + if ftype == "audio": + size = res or "1280x720" + return ( + base + + ["-i", file_path, "-f", "lavfi", "-i", f"color=c=black:s={size}:r={fps}"] + + ["-shortest"] + common_v + common_a + + ["-map", "1:v:0", "-map", "0:a:0"] + out + ) + if ftype == "image": + scale_vf = f"scale=trunc(iw/2)*2:trunc(ih/2)*2{vf_scale}" + return ( + base + + ["-loop", "1", "-i", file_path, "-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"] + + ["-vf", scale_vf] + common_v + + ["-shortest"] + common_a + + ["-map", "0:v:0", "-map", "1:a:0"] + out + ) + raise ValueError(f"Unsupported: {ftype}") + +@loader.tds +class StreamMod(loader.Module): + """📡 RTMP media streaming""" + strings = { + "name": "Stream", + "status_active": "▶️ Stream is live\n\n{icon} {file}\n⏱ Time: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 Queue: {queue}", + "status_idle": "⏸ Stream is not active", + "status_queue": "\n📋 Queue: {n}", + "stopped": "⏹ Stream stopped.", + "no_rtmp": "❌ RTMP not configured!\nTap a button to set it up.", + "downloading": "⏳ Downloading…", + "dl_failed": "❌ Failed to download file.", + "queued": "📋 Added to queue ({n})\n{icon} {file}", + "not_running": "Not running", + "queue_empty": "Queue is empty", + "queue_header": "📋 Queue:\n", + "settings_title": "⚙️ Stream settings", + "btn_stop": "⏹ Stop", + "btn_queue": "📋 Queue", + "btn_refresh": "🔄 Refresh", + "btn_settings": "⚙️ Settings", + "btn_status": "📊 Status", + "btn_back": "🔙 Back", + "btn_preset": "🎞 Preset: {v}", + "btn_tune": "🎭 Tune: {v}", + "btn_vbr": "🎥 Video: {v}", + "btn_abr": "🔊 Audio: {v}", + "btn_fps": "📐 FPS: {v}", + "btn_res": "🖥 Res: {v}", + "btn_threads": "🧵 Threads: {v}", + "btn_rtmps": "📡 RTMP URL", + "btn_key": "🔑 Stream key", + "btn_set_rtmps": "📡 Set RTMP URL", + "btn_set_key": "🔑 Set stream key", + "ph_vbr": "Video bitrate, e.g. 2000k", + "ph_abr": "Audio bitrate, e.g. 128k", + "ph_threads": "Thread count (0 = auto)", + "ph_rtmps": "rtmp://a.rtmp.youtube.com/live2", + "ph_key": "Stream key...", + } + + strings_ru = { + "_cls_doc": "📡 RTMP стриминг медиафайлов", + "status_active": "▶️ Трансляция идёт\n\n{icon} {file}\n⏱ Время: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 В очереди: {queue}", + "status_idle": "⏸ Трансляция не активна", + "status_queue": "\n📋 В очереди: {n}", + "stopped": "⏹ Трансляция остановлена.", + "no_rtmp": "❌ RTMP не настроен!\nНажми кнопку чтобы задать прямо сейчас.", + "downloading": "⏳ Скачиваю…", + "dl_failed": "❌ Не удалось скачать файл.", + "queued": "📋 Добавлено в очередь ({n} шт.)\n{icon} {file}", + "not_running": "Не запущено", + "queue_empty": "Очередь пуста", + "queue_header": "📋 Очередь:\n", + "settings_title": "⚙️ Настройки трансляции", + "btn_stop": "⏹ Стоп", + "btn_queue": "📋 Очередь", + "btn_refresh": "🔄 Обновить", + "btn_settings": "⚙️ Настройки", + "btn_status": "📊 Статус", + "btn_back": "🔙 Назад", + "btn_preset": "🎞 Пресет: {v}", + "btn_tune": "🎭 Tune: {v}", + "btn_vbr": "🎥 Видео: {v}", + "btn_abr": "🔊 Аудио: {v}", + "btn_fps": "📐 FPS: {v}", + "btn_res": "🖥 Разр: {v}", + "btn_threads": "🧵 Треды: {v}", + "btn_rtmps": "📡 RTMP URL", + "btn_key": "🔑 Ключ", + "btn_set_rtmps": "📡 Задать RTMP URL", + "btn_set_key": "🔑 Задать ключ", + "ph_vbr": "Битрейт видео, напр. 2000k", + "ph_abr": "Битрейт аудио, напр. 128k", + "ph_threads": "Потоков (0 = авто)", + "ph_rtmps": "rtmp://a.rtmp.youtube.com/live2", + "ph_key": "Ключ трансляции...", + } + + def __init__(self): + self._proc: subprocess.Popen | None = None + self._file: str | None = None + self._started: float | None = None + self._queue: list[str] = [] + self._qtask: asyncio.Task | None = None + self.config = loader.ModuleConfig( + loader.ConfigValue("rtmps", "", "Base RTMP URL (rtmp://...)"), + loader.ConfigValue("key", "", "Stream key"), + loader.ConfigValue("preset", "veryfast", "x264 preset", + validator=loader.validators.Choice(PRESETS)), + loader.ConfigValue("tune", "zerolatency","x264 tune", + validator=loader.validators.Choice(TUNES)), + loader.ConfigValue("vbitrate", "2000k", "Video bitrate (e.g. 1500k, 3000k)"), + loader.ConfigValue("abitrate", "128k", "Audio bitrate (e.g. 64k, 192k)"), + loader.ConfigValue("fps", 30, "Frames per second", + validator=loader.validators.Integer(minimum=1, maximum=120)), + loader.ConfigValue("resolution", "", "Output resolution (e.g. 1280x720, empty = no scaling)"), + loader.ConfigValue("threads", 0, "FFmpeg thread count (0 = auto)", + validator=loader.validators.Integer(minimum=0, maximum=64)), + loader.ConfigValue("loop", True, "Loop the file indefinitely", + validator=loader.validators.Boolean()), + loader.ConfigValue("reconnect", True, "Auto-restart on stream disconnect", + validator=loader.validators.Boolean()), + ) + + def _s(self, key: str, **kw) -> str: + return self.strings[key].format(**kw) if kw else self.strings[key] + + def _running(self) -> bool: + return self._proc is not None and self._proc.poll() is None + + def _stop(self): + if self._proc: + try: + self._proc.terminate() + self._proc.wait(timeout=5) + except Exception: + try: + self._proc.kill() + except Exception: + pass + self._proc = None + if self._file and os.path.exists(self._file): + try: + os.remove(self._file) + except Exception: + pass + self._file = None + self._started = None + + def _launch(self, path: str): + cfg = {k: self.config[k] for k in ("preset", "tune", "vbitrate", "abitrate", "fps", "threads")} + cfg["resolution"] = self.config["resolution"] or None + rtmp = f"{self.config['rtmps'].rstrip('/')}/{self.config['key']}" + self._proc = subprocess.Popen(build_cmd(path, rtmp, cfg), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self._file = path + self._started = time.time() + + def _elapsed(self) -> str: + if not self._started: + return "00:00:00" + e = int(time.time() - self._started) + return f"{e//3600:02d}:{(e%3600)//60:02d}:{e%60:02d}" + + def _status_text(self) -> str: + if not self._running(): + txt = self._s("status_idle") + if self._queue: + txt += self._s("status_queue", n=len(self._queue)) + return txt + ftype = detect_type(self._file or "") + rtmp = f"{self.config['rtmps'].rstrip('/')}/{self.config['key'][:4]}***" + return self._s( + "status_active", + icon=TYPE_ICON.get(ftype, "📄"), + file=os.path.basename(self._file or "?"), + elapsed=self._elapsed(), + pid=self._proc.pid if self._proc else "—", + rtmp=rtmp, + vbr=self.config["vbitrate"], + fps=self.config["fps"], + preset=self.config["preset"], + abr=self.config["abitrate"], + queue=len(self._queue), + ) + + def _res_label(self) -> str: + r = self.config["resolution"] + return r if r else "auto" + + def _thr_label(self) -> str: + t = self.config["threads"] + return str(t) if t else "auto" + + def _main_markup(self) -> list: + running = self._running() + return [ + [ + {"text": self._s("btn_stop"), "callback": self._cb_stop} if running + else {"text": self._s("btn_queue"), "callback": self._cb_queue}, + {"text": self._s("btn_refresh"), "callback": self._cb_refresh}, + ], + [ + {"text": self._s("btn_settings"), "callback": self._cb_settings}, + {"text": self._s("btn_status"), "callback": self._cb_status}, + ], + ] + + def _settings_markup(self) -> list: + return [ + [ + {"text": self._s("btn_preset", v=self.config["preset"]), "callback": self._cb_set_preset}, + {"text": self._s("btn_tune", v=self.config["tune"]), "callback": self._cb_set_tune}, + ], + [ + {"text": self._s("btn_vbr", v=self.config["vbitrate"]), + "input": self._s("ph_vbr"), "handler": self._ih_vbr}, + {"text": self._s("btn_abr", v=self.config["abitrate"]), + "input": self._s("ph_abr"), "handler": self._ih_abr}, + ], + [ + {"text": self._s("btn_fps", v=self.config["fps"]), "callback": self._cb_set_fps}, + {"text": self._s("btn_res", v=self._res_label()), "callback": self._cb_set_res}, + ], + [ + {"text": self._s("btn_threads", v=self._thr_label()), + "input": self._s("ph_threads"), "handler": self._ih_threads}, + ], + [ + {"text": self._s("btn_rtmps"), + "input": self._s("ph_rtmps"), "handler": self._ih_rtmps}, + {"text": self._s("btn_key"), + "input": self._s("ph_key"), "handler": self._ih_key}, + ], + [{"text": self._s("btn_back"), "callback": self._cb_back}], + ] + + async def _ih_vbr(self, call: InlineCall, query: str): + q = query.strip() + if q.endswith("k") and q[:-1].isdigit(): + self.config["vbitrate"] = q + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _ih_abr(self, call: InlineCall, query: str): + q = query.strip() + if q.endswith("k") and q[:-1].isdigit(): + self.config["abitrate"] = q + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _ih_threads(self, call: InlineCall, query: str): + q = query.strip() + if q.isdigit(): + self.config["threads"] = int(q) + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _ih_rtmps(self, call: InlineCall, query: str): + q = query.strip() + if q.startswith("rtmp"): + self.config["rtmps"] = q.rstrip("/") + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _ih_key(self, call: InlineCall, query: str): + q = query.strip() + if q: + self.config["key"] = q + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _cb_refresh(self, call: InlineCall): + await call.edit(self._status_text(), reply_markup=self._main_markup()) + + async def _cb_status(self, call: InlineCall): + await call.answer(self._elapsed() if self._running() else self._s("not_running")) + + async def _cb_stop(self, call: InlineCall): + self._queue.clear() + if self._qtask: + self._qtask.cancel() + self._qtask = None + self._stop() + await call.edit(self._s("stopped"), reply_markup=self._main_markup()) + + async def _cb_queue(self, call: InlineCall): + if not self._queue: + await call.answer(self._s("queue_empty"), show_alert=True) + return + lines = [f"{i}. {TYPE_ICON.get(detect_type(f), '📄')} {os.path.basename(f)}" + for i, f in enumerate(self._queue, 1)] + await call.answer(self._s("queue_header") + "\n".join(lines), show_alert=True) + + async def _cb_back(self, call: InlineCall): + await call.edit(self._status_text(), reply_markup=self._main_markup()) + + async def _cb_settings(self, call: InlineCall): + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _cb_set_preset(self, call: InlineCall): + cur = self.config["preset"] + self.config["preset"] = PRESETS[(PRESETS.index(cur) + 1) % len(PRESETS)] + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _cb_set_tune(self, call: InlineCall): + cur = self.config["tune"] + self.config["tune"] = TUNES[(TUNES.index(cur) + 1) % len(TUNES)] + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _cb_set_fps(self, call: InlineCall): + cur = self.config["fps"] + self.config["fps"] = FPS_OPT[(FPS_OPT.index(cur) + 1) % len(FPS_OPT)] if cur in FPS_OPT else 30 + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + async def _cb_set_res(self, call: InlineCall): + cur = self.config["resolution"] or "off" + idx = SCALES.index(cur) if cur in SCALES else 0 + nxt = SCALES[(idx + 1) % len(SCALES)] + self.config["resolution"] = "" if nxt == "off" else nxt + await call.edit(self._s("settings_title"), reply_markup=self._settings_markup()) + + @loader.command(ru_doc="[ответ на медиа] – запустить трансляцию") + async def stream(self, message): + """[reply to media] — start stream or add to queue""" + if not self.config["rtmps"] or not self.config["key"]: + await self.inline.form( + self._s("no_rtmp"), + message=message, + reply_markup=[ + [{"text": self._s("btn_set_rtmps"), "input": self._s("ph_rtmps"), "handler": self._ih_rtmps}], + [{"text": self._s("btn_set_key"), "input": self._s("ph_key"), "handler": self._ih_key}], + ], + ) + return + + reply = await message.get_reply_message() + if not reply or not reply.media: + await self.inline.form( + self._status_text(), + message=message, + reply_markup=self._main_markup(), + ) + return + + status = await utils.answer(message, self._s("downloading")) + path = await reply.download_media(file=f"/tmp/stream_{int(time.time())}") + if not path: + await status.edit(self._s("dl_failed")) + return + await status.delete() + + if self._running(): + self._queue.append(path) + await self.inline.form( + self._s("queued", n=len(self._queue), icon=TYPE_ICON.get(detect_type(path), "📄"), file=os.path.basename(path)), + message=message, + reply_markup=self._main_markup(), + ) + return + + self._stop() + self._launch(path) + await self.inline.form( + self._status_text(), + message=message, + reply_markup=self._main_markup(), + ) + + @loader.command(ru_doc="– панель управления трансляцией") + async def streamctl(self, message): + """– open stream control panel""" + await self.inline.form( + self._status_text(), + message=message, + reply_markup=self._main_markup(), + ) + + @loader.command(ru_doc="– остановить трансляцию и очистить очередь") + async def streamstop(self, message): + """– stop stream and clear queue""" + self._queue.clear() + if self._qtask: + self._qtask.cancel() + self._qtask = None + self._stop() + await utils.answer(message, self._s("stopped")) \ No newline at end of file diff --git a/mead0wsss/mead0wsMods/SenderGifts.py b/mead0wsss/mead0wsMods/SenderGifts.py index 730aa5d..195a232 100644 --- a/mead0wsss/mead0wsMods/SenderGifts.py +++ b/mead0wsss/mead0wsMods/SenderGifts.py @@ -88,6 +88,12 @@ class SenderGifts(loader.Module): "gifts": [ {"id": 5866352046986232958, "emoji": "🧸", "name": "8 Марта мишка", "price": 50}, ] + }, + "saint_patricks_day ": { + "name": "💰 День святого патрика", + "gifts": [ + {"id": 5893356958802511476, "emoji": "🧸", "name": "Лепрекон мишка", "price": 50}, + ] } } diff --git a/radiocycle/Modules/LastFm.py b/radiocycle/Modules/LastFm.py index 7069ef3..90ad508 100644 --- a/radiocycle/Modules/LastFm.py +++ b/radiocycle/Modules/LastFm.py @@ -39,11 +39,11 @@ class Banners: def _prepare_cover(self, size, radius): cover = Image.open(io.BytesIO(self.track_cover)).convert("RGBA") cover = cover.resize((size, size), Image.Resampling.LANCZOS) - + mask = Image.new("L", (size, size), 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255) - + output = Image.new("RGBA", (size, size), (0, 0, 0, 0)) output.paste(cover, (0, 0), mask=mask) return output @@ -59,15 +59,15 @@ class Banners: W, H = 1500, 600 padding = 60 cover_size = 480 - + font_bytes = requests.get(self.font_url).content title_font = self._get_font(55, font_bytes) artist_font = self._get_font(45, font_bytes) - lfm_font = self._get_font(35, font_bytes) + lfm_font = self._get_font(55, font_bytes) img = self._prepare_background(W, H) draw = ImageDraw.Draw(img) - + cover = self._prepare_cover(cover_size, 30) img.paste(cover, (padding, (H - cover_size) // 2), cover) @@ -88,8 +88,8 @@ class Banners: draw.text((text_x, text_y_start), display_title, font=title_font, fill="white") draw.text((text_x, text_y_start + 70), display_artist, font=artist_font, fill="#B3B3B3") - bar_y = 480 - draw.text((text_x, bar_y), "last.fm", font=lfm_font, fill="white") + text_y = 430 + draw.text((text_x, text_y), "last.fm", font=lfm_font, fill="white") by = io.BytesIO() img.save(by, format="PNG") @@ -98,24 +98,24 @@ class Banners: return by def vertical(self): - W, H = 1000, 1500 - padding = 80 + W, H = 1000, 1300 + padding = 60 cover_size = 800 - + font_bytes = requests.get(self.font_url).content title_font = self._get_font(60, font_bytes) artist_font = self._get_font(45, font_bytes) - lfm_font = self._get_font(35, font_bytes) + lfm_font = self._get_font(60, font_bytes) img = self._prepare_background(W, H) draw = ImageDraw.Draw(img) cover = self._prepare_cover(cover_size, 40) cover_x = (W - cover_size) // 2 - cover_y = 120 + cover_y = 100 img.paste(cover, (cover_x, cover_y), cover) - text_area_y = cover_y + cover_size + 120 + text_area_y = cover_y + cover_size + 60 text_width_limit = W - (padding * 2) display_title = self.title @@ -134,10 +134,10 @@ class Banners: artist_w = artist_font.getlength(display_artist) draw.text(((W - artist_w) / 2, text_area_y + 75), display_artist, font=artist_font, fill="#B3B3B3") - bar_y = text_area_y + 260 - + text_y = text_area_y + 180 + lfm_w = lfm_font.getlength("last.fm") - draw.text(((W - lfm_w) / 2, bar_y), "last.fm", font=lfm_font, fill="white") + draw.text(((W - lfm_w) / 2, text_y), "last.fm", font=lfm_font, fill="white") by = io.BytesIO() img.save(by, format="PNG") @@ -165,14 +165,6 @@ class lastfmmod(loader.Module): "nick_error": " Укажите ваш никнейм с last.fm", "uploading": "🕔 Загрузка баннера...", } - strings_jp = { - "name": "LastFm", - "no_track": " 現在再生中のトラックはありません", - "_doc_text": "ファイルの横に表示されるテキスト", - "_doc_username": "Last.fmのユーザー名", - "nick_error": " Last.fmのニックネームを入力してください", - "uploading": "🕔 バナーをアップロード中...", - } def __init__(self): self.config = loader.ModuleConfig( @@ -180,6 +172,7 @@ class lastfmmod(loader.Module): loader.ConfigValue("custom_text", "🤩 {song_name}{song_artist}", lambda: self.strings["_doc_text"]), loader.ConfigValue("font", "https://raw.githubusercontent.com/kamekuro/assets/master/fonts/Onest-Bold.ttf", "Custom font URL (ttf)"), loader.ConfigValue("banner_version", "horizontal", lambda: "Banner version", validator=loader.validators.Choice(["horizontal", "vertical"])), + loader.ConfigValue("fallback_cover", "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", "Fallback cover URL if track has no image"), ) @loader.command(alias="np") @@ -189,22 +182,27 @@ class lastfmmod(loader.Module): if not user: await self.invoke("config", "lastfm", message=message) return await utils.answer(message, self.strings["nick_error"]) - + try: + msg = await utils.answer(message, self.strings["uploading"]) + url = f'http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&nowplaying=true&user={user}&api_key=460cda35be2fbf4f28e8ea7a38580730&format=json' data = requests.get(url).json() track = next((t for t in data.get('recenttracks', {}).get('track', []) if t.get('@attr', {}).get('nowplaying')), None) if not track: - return await utils.answer(message, self.strings["no_track"]) + return await utils.answer(msg, self.strings["no_track"]) name = track.get('name', 'Unknown') artist = track.get('artist', {}).get('#text', 'Unknown') caption = self.config["custom_text"].format(song_artist=artist, song_name=name) imgs = track.get('image', []) cov_url = next((i['#text'] for i in imgs if i['size'] == 'extralarge'), imgs[-1]['#text'] if imgs else None) - if not cov_url: - return await utils.answer(message, caption) - msg = await utils.answer(message, self.strings["uploading"]) + if not cov_url or not str(cov_url).strip(): + cov_url = self.config["fallback_cover"] + + if not cov_url or not str(cov_url).strip(): + return await utils.answer(msg, caption) + cov_bytes = await utils.run_sync(requests.get, cov_url) banners = Banners(name, artist, cov_bytes.content, self.config["font"]) file = await utils.run_sync(getattr(banners, self.config["banner_version"]))