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..bd048d4
--- /dev/null
+++ b/fiksofficial/python-modules/stream.py
@@ -0,0 +1 @@
+# 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
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/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
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"]))