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"]))