From 9a29d5ec99c9d89a6a39e4b099af7ed32259c4f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 01:30:41 +0000 Subject: [PATCH 1/3] Added and updated repositories 2026-03-23 01:30:41 --- fiksofficial/python-modules/full.txt | 3 +- fiksofficial/python-modules/libs/pyupdater.py | 222 +++++++++ fiksofficial/python-modules/stream.py | 435 ++++++++++++++++++ mead0wsss/mead0wsMods/SenderGifts.py | 6 + radiocycle/Modules/LastFm.py | 56 ++- 5 files changed, 692 insertions(+), 30 deletions(-) create mode 100644 fiksofficial/python-modules/libs/pyupdater.py create mode 100644 fiksofficial/python-modules/stream.py 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"])) From 2d4181c22e6c26483c23c50506c6f2eb0bf7eb0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 01:31:17 +0000 Subject: [PATCH 2/3] Updated modules.json after parse 2026-03-23 01:31:17 --- modules.json | 154 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 9 deletions(-) diff --git a/modules.json b/modules.json index 0b12db1..c2b2fb0 100644 --- a/modules.json +++ b/modules.json @@ -52266,13 +52266,7 @@ "_doc_text_ru": "Текст, который будет написан рядом с файлом", "_doc_username_ru": "Ваш username с last.fm", "nick_error_ru": " Укажите ваш никнейм с last.fm", - "uploading_ru": "🕔 Загрузка баннера...", - "name_jp": "LastFm", - "no_track_jp": " 現在再生中のトラックはありません", - "_doc_text_jp": "ファイルの横に表示されるテキスト", - "_doc_username_jp": "Last.fmのユーザー名", - "nick_error_jp": " Last.fmのニックネームを入力してください", - "uploading_jp": "🕔 バナーをアップロード中..." + "uploading_ru": "🕔 Загрузка баннера..." }, "has_on_load": false, "has_on_unload": false, @@ -62648,6 +62642,148 @@ "has_on_unload": false, "class_cmd_names": {} }, + "fiksofficial/python-modules/stream.py": { + "name": "StreamMod", + "description": "📡 RTMP media streaming", + "cls_doc": { + "ru": "📡 RTMP стриминг медиафайлов" + }, + "meta": { + "pic": null, + "banner": null, + "developer": null + }, + "commands": [ + { + "stream": "[reply to media] — start stream or add to queue | (RU) [ответ на медиа] – запустить трансляцию" + }, + { + "streamctl": "– open stream control panel | (RU) – панель управления трансляцией" + }, + { + "streamstop": "– stop stream and clear queue | (RU) – остановить трансляцию и очистить очередь" + } + ], + "new_commands": [ + { + "name": "stream", + "original_name": "stream", + "description": { + "default": "[reply to media] — start stream or add to queue", + "ru": "[ответ на медиа] – запустить трансляцию" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "streamctl", + "original_name": "streamctl", + "description": { + "default": "– open stream control panel", + "ru": "– панель управления трансляцией" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "streamstop", + "original_name": "streamstop", + "description": { + "default": "– stop stream and clear queue", + "ru": "– остановить трансляцию и очистить очередь" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + } + ], + "inline_handlers": [], + "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...", + "status_active_ru": "▶️ Трансляция идёт\n\n{icon} {file}\n⏱ Время: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 В очереди: {queue}", + "status_idle_ru": "⏸ Трансляция не активна", + "status_queue_ru": "\n📋 В очереди: {n}", + "stopped_ru": "⏹ Трансляция остановлена.", + "no_rtmp_ru": "❌ RTMP не настроен!\nНажми кнопку чтобы задать прямо сейчас.", + "downloading_ru": "⏳ Скачиваю…", + "dl_failed_ru": "❌ Не удалось скачать файл.", + "queued_ru": "📋 Добавлено в очередь ({n} шт.)\n{icon} {file}", + "not_running_ru": "Не запущено", + "queue_empty_ru": "Очередь пуста", + "queue_header_ru": "📋 Очередь:\n", + "settings_title_ru": "⚙️ Настройки трансляции", + "btn_stop_ru": "⏹ Стоп", + "btn_queue_ru": "📋 Очередь", + "btn_refresh_ru": "🔄 Обновить", + "btn_settings_ru": "⚙️ Настройки", + "btn_status_ru": "📊 Статус", + "btn_back_ru": "🔙 Назад", + "btn_preset_ru": "🎞 Пресет: {v}", + "btn_tune_ru": "🎭 Tune: {v}", + "btn_vbr_ru": "🎥 Видео: {v}", + "btn_abr_ru": "🔊 Аудио: {v}", + "btn_fps_ru": "📐 FPS: {v}", + "btn_res_ru": "🖥 Разр: {v}", + "btn_threads_ru": "🧵 Треды: {v}", + "btn_rtmps_ru": "📡 RTMP URL", + "btn_key_ru": "🔑 Ключ", + "btn_set_rtmps_ru": "📡 Задать RTMP URL", + "btn_set_key_ru": "🔑 Задать ключ", + "ph_vbr_ru": "Битрейт видео, напр. 2000k", + "ph_abr_ru": "Битрейт аудио, напр. 128k", + "ph_threads_ru": "Потоков (0 = авто)", + "ph_rtmps_ru": "rtmp://a.rtmp.youtube.com/live2", + "ph_key_ru": "Ключ трансляции..." + }, + "has_on_load": false, + "has_on_unload": false, + "class_cmd_names": {} + }, "fiksofficial/python-modules/lyrics.py": { "name": "LyricsMod", "description": "Модуль для поиска текста песни через Genius API напрямую", @@ -82294,7 +82430,7 @@ } }, "meta": { - "total_modules": 1021, - "generated_at": "2026-03-11T01:22:17.528364" + "total_modules": 1022, + "generated_at": "2026-03-23T01:31:16.986395" } } \ No newline at end of file From 59e3bc900f315e1f8826581f847bc26436cb7d75 Mon Sep 17 00:00:00 2001 From: Macsim <134152147+MuRuLOSE@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:56:54 +0300 Subject: [PATCH 3/3] Removed stream.py because of security measures --- fiksofficial/python-modules/stream.py | 436 +------------------------- 1 file changed, 1 insertion(+), 435 deletions(-) diff --git a/fiksofficial/python-modules/stream.py b/fiksofficial/python-modules/stream.py index 771b5f2..bd048d4 100644 --- a/fiksofficial/python-modules/stream.py +++ b/fiksofficial/python-modules/stream.py @@ -1,435 +1 @@ - -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 +# Security issue in this module. RTMP Key doesn't hide in config with vaildator Hidden, because of that, we will wait for update from developer to fix it