# -*- coding: utf-8 -*- """ TipBit.py — OBS-скрипт (Python) для виджета алертов TipBit Версия: 1.0.0 Автор: TipBit Файл: obs/TipBit.py Функциональный паритет с obs/TipBit.lua — альтернатива для тех, у кого в OBS включён Python (Инструменты → Скрипты → «Python-настройки» → путь к интерпретатору). --------------------------------------------------------------------------- НАЗНАЧЕНИЕ --------------------------------------------------------------------------- Скрипт в один клик добавляет в выбранную (или текущую) сцену OBS источник типа «Браузер» (browser_source) с адресом виджета алертов TipBit: ${TIPBIT_PUBLIC_URL}/w/ Виджет — обычная веб-страница с прозрачным фоном: по умолчанию 1920x1080, контент центрирован, поэтому подходит и 800x450. Звук алертов воспроизводит сам виджет через WebAudio, поэтому громкость источника в микшере по умолчанию НЕ меняется — звук донатов идёт в эфир. Галочка «Отключить звук источника в микшере» нужна только тем, у кого звук алертов идёт другим путём (при ней звук донатов в трансляцию не попадёт). --------------------------------------------------------------------------- УСТАНОВКА --------------------------------------------------------------------------- 1. Сохраните TipBit.py в любую папку (например C:\\TipBit\\TipBit.py). 2. OBS: «Инструменты» → «Скрипты» → «+» → выберите TipBit.py. Если Python в OBS не настроен: «Инструменты» → «Скрипты» → вкладка «Python-настройки» → укажите путь к python3.x.dll (Python 3.6/3.11). 3. В кабинете TipBit откройте раздел «Алерты и виджет» и скопируйте ссылку виджета (вида https://ваш-домен/w/<токен>) — целиком или только токен. 4. Вставьте её в поле «Ссылка виджета (или токен)», выберите сцену и нажмите «Проверить / обновить источник». 5. Проверка: кнопка «Тестовый донат» в кабинете. --------------------------------------------------------------------------- ОСОБЕННОСТИ / ОТЛИЧИЯ ОТ LUA-ВЕРСИИ --------------------------------------------------------------------------- * OBS Python-API — это набор функций уровня модуля (script_update, script_properties, ...), а не класс Script: именно их вызывает OBS. * В Python-API этой сборки OBS нет obs_frontend_get_scenes()/get_scene_names(), поэтому: - текущая (программная) сцена определяется через obs_get_output_source(0); - список сцен — редактируемое поле: имя сцены можно вписать руками (если OBS отдаёт список сцен — он подставляется автоматически). * Повторное нажатие кнопки не создаёт дубликат: источник с таким же именем находится и обновляется. * Работает без интернета (страница просто покажет «нет соединение»). * Требуется OBS 28+ с включённым Python (проверено на Python 3.12). """ try: import obspython as obs # обычный путь в Windows/Linux except ImportError: # pragma: no cover — старые сборки/macOS import obs # type: ignore # --------------------------------------------------------------------------- # Константы и состояние # --------------------------------------------------------------------------- SCRIPT_VERSION = "1.0.0" DEFAULT_BASE_URL = "https://tipbit.local" DEFAULT_NAME = "TipBit Alert" # Значение пункта «Текущая сцена» в поле «Сцена» SCENE_CURRENT = "__current__" # Стандартный CSS прозрачного источника «Браузер» (совпадает с дефолтом OBS: # прозрачный фон, без полей и полос прокрутки). Именно это убирает чёрный # прямоугольник вокруг алерта. TRANSPARENT_CSS = ("body { background-color: rgba(0, 0, 0, 0); " "margin: 0px auto; overflow: hidden; }") LOG_INFO = getattr(obs, "LOG_INFO", 200) LOG_WARNING = getattr(obs, "LOG_WARNING", 300) LOG_ERROR = getattr(obs, "LOG_ERROR", 400) # Копия настроек скрипта (obs_data_t нельзя держать между вызовами — им владеет OBS) st = { "widget": "", "base_url": DEFAULT_BASE_URL, "source_name": DEFAULT_NAME, "width": 1920, "height": 1080, "scene": SCENE_CURRENT, "append_to_end": False, "restart_when_active": True, # Звук алертов играет сам виджет через WebAudio, поэтому по умолчанию # громкость источника НЕ трогаем: звук донатов должен идти в эфир. # Галочка нужна только тем, у кого звук алертов идёт другим путём. "mute_audio": False, } # --------------------------------------------------------------------------- # Помощники # --------------------------------------------------------------------------- def _log_info(msg): obs.script_log(LOG_INFO, "[TipBit] " + str(msg)) def _log_warn(msg): obs.script_log(LOG_WARNING, "[TipBit] " + str(msg)) def _log_err(msg): obs.script_log(LOG_ERROR, "[TipBit] " + str(msg)) def _trim(value): """Аккуратно приводит значение из obs_data к строке без пробелов по краям.""" if value is None: return "" return str(value).strip() def _is_browser_source(source): """Источник «Браузер»? Его id — ровно 'browser_source'.""" try: source_id = obs.obs_source_get_id(source) except Exception: return False if not source_id: return False return str(source_id).startswith("browser_source") def _find_source_by_name(name): """ Источник по имени (среди всех input-источников). Возвращает obs_source_t* с +1 ссылкой — вызывающий обязан release. """ found = None sources = obs.obs_enum_sources() if sources: for source in sources: try: if obs.obs_source_get_name(source) == name: found = source break except Exception: continue # obs.source_list_release освобождает всю таблицу источников, # поэтому нужный источник надо предварительно «удержать» ref-ом. if found is not None: try: obs.obs_source_get_ref(found) except Exception: pass try: obs.source_list_release(sources) except Exception: pass return found def _current_scene_name(): """ Имя текущей (программной) сцены OBS. В Python-API нет obs_frontend_get_current_scene(), зато канал 0 основного вывода всегда указывает на активную сцену программы (obs_get_output_source увеличивает счётчик ссылок — освобождаем). """ try: scene_source = obs.obs_get_output_source(0) except Exception: scene_source = None if scene_source is None: return None name = None try: if obs.obs_source_is_scene(scene_source): name = obs.obs_source_get_name(scene_source) except Exception: name = None try: obs.obs_source_release(scene_source) except Exception: pass return name or None def _find_scene_source(scene_name): """ Исходник сцены по имени. SCENE_CURRENT — текущая программная сцена. Возвращает obs_source_t* с +1 ссылкой либо None. """ if not scene_name or scene_name == SCENE_CURRENT: current = _current_scene_name() if not current: _log_warn("не удалось определить текущую сцену OBS. Впишите имя сцены " "в поле «Сцена» вручную.") return None scene_name = current source = obs.obs_get_source_by_name(scene_name) # +1 ссылка if source is None: return None try: if not obs.obs_source_is_scene(source): _log_err("«" + scene_name + "» — не сцена, а другой источник. " "Укажите имя сцены (или пункт «Текущая сцена»).") obs.obs_source_release(source) return None except Exception: pass return source def _restart_source(source): """Перезагружает страницу виджета (media_restart умеет browser_source).""" restart = getattr(obs, "obs_source_media_restart", None) if restart is None: _log_warn("OBS API не предоставил obs_source_media_restart: страница " "перезагрузится сама при следующей активации сцены.") return False restart(source) return True # --------------------------------------------------------------------------- # Нормализация и проверка ссылки виджета # --------------------------------------------------------------------------- def _resolve_widget_url(): """ Приводит введённое значение к полному URL виджета. Возвращает (url, None) либо (None, текст_ошибки). """ raw = _trim(st["widget"]) if raw == "": return None, ("не указана ссылка виджета: вставьте ссылку из кабинета TipBit " "(раздел «Алерты и виджет») или только токен.") lowered = raw.lower() if lowered.startswith("http://") or lowered.startswith("https://"): # Вставлена полная ссылка: убираем пробелы/переводы строк. url = "".join(raw.split()) # Могли скопировать https://host/widget.html?token=abc — приводим к /w/<токен>. token = "" for marker in ("?token=", "&token="): if marker in url: token = url.split(marker, 1)[1].split("&", 1)[0].strip() break if token and "/w/" not in url: origin = url.split("//", 1) if len(origin) == 2: origin = origin[0] + "//" + origin[1].split("/", 1)[0] _log_info("ссылка нормализована в " + origin + "/w/" + token) return origin + "/w/" + token, None if "/w/" not in url: _log_warn("в ссылке нет «/w/». Виджет алертов живёт по адресу " "<сервер>/w/<токен>; если фон источника чёрный — возьмите в " "кабинете ссылку вида https://домен/w/<токен>.") return url, None # Вставлен только токен (возможно с лишними «/», «w/», пробелами) token = raw.lstrip("/") if token.startswith("w/"): token = token[2:] token = token.strip() if token == "": return None, "после нормализации ссылка/токен пусты." for ch in token: if not (ch.isalnum() or ch in "-_."): return None, ("токен содержит недопустимые символы: «" + token + "». Ожидается токен из кабинета TipBit " "(латиница, цифры, «-», «_»).") base = "".join(_trim(st["base_url"]).split()) if base == "": base = DEFAULT_BASE_URL if not (base.lower().startswith("http://") or base.lower().startswith("https://")): base = "https://" + base _log_info("базовый URL дополнен схемой: " + base) base = base.rstrip("/") return base + "/w/" + token, None # --------------------------------------------------------------------------- # Настройки источника «Браузер» # --------------------------------------------------------------------------- def _build_source_settings(url, css): """ css обязателен: прозрачный фон. fps_custom намеренно НЕ задаём — остаётся стандартное значение OBS (30 fps). """ settings = obs.obs_data_create() obs.obs_data_set_string(settings, "url", url) obs.obs_data_set_int(settings, "width", int(st["width"])) obs.obs_data_set_int(settings, "height", int(st["height"])) obs.obs_data_set_bool(settings, "shutdown", False) obs.obs_data_set_bool(settings, "restart_when_active", bool(st["restart_when_active"])) obs.obs_data_set_string(settings, "css", css or TRANSPARENT_CSS) return settings # --------------------------------------------------------------------------- # Добавление источника в сцену # --------------------------------------------------------------------------- def _ensure_in_scene(source, name): """Возвращает True, если источник оказался в сцене (или уже был там).""" scene_source = _find_scene_source(st["scene"]) if scene_source is None: if st["scene"] == SCENE_CURRENT: _log_warn("не удалось определить текущую сцену. Источник создан, но в сцену " "не добавлен — впишите имя сцены в поле «Сцена» и повторите.") else: _log_warn("сцена «" + str(st["scene"]) + "» не найдена. Проверьте имя сцены " "и повторите.") return False scene = obs.obs_scene_from_source(scene_source) if scene is None: _log_warn("obs_scene_from_source вернул None — источник «" + name + "» не добавлен в сцену.") obs.obs_source_release(scene_source) return False scene_name = obs.obs_source_get_name(scene_source) # Уже лежит в этой сцене? Тогда дубликат не создаём. item = obs.obs_scene_find_source(scene, name) if item is not None: try: obs.obs_sceneitem_set_pos(item, {"x": 0, "y": 0}) except Exception: pass _log_info("источник «" + name + "» уже есть в сцене «" + str(scene_name) + "» — обновлены только настройки.") obs.obs_source_release(scene_source) return True item = obs.obs_scene_add(scene, source) if item is None: _log_warn("obs_scene_add вернул None — не удалось добавить источник «" + name + "» в сцену «" + str(scene_name) + "».") obs.obs_source_release(scene_source) return False # «В конец сцены» = ниже остальных источников списка. if st["append_to_end"]: set_order = getattr(obs, "obs_sceneitem_set_order", None) move_bottom = getattr(obs, "OBS_ORDER_MOVE_BOTTOM", None) if set_order is not None and move_bottom is not None: try: set_order(item, move_bottom) except Exception as exc: _log_warn("не удалось изменить порядок источника: " + str(exc)) else: _log_warn("OBS API не предоставил obs_sceneitem_set_order — источник " "добавлен как обычно. Порядок можно поправить в OBS вручную.") # Позиционируем в (0,0) относительно канвы сцены. try: obs.obs_sceneitem_set_pos(item, {"x": 0, "y": 0}) except Exception as exc: _log_warn("не удалось задать позицию (0,0): " + str(exc)) _log_info("источник «" + name + "» добавлен в сцену «" + str(scene_name) + "» в позицию (0,0).") obs.obs_source_release(scene_source) return True # --------------------------------------------------------------------------- # Основная работа: создать/обновить источник # --------------------------------------------------------------------------- def _sync_source(restart): """ Создаёт или обновляет источник, кладёт его в сцену. restart=True — дополнительно перезагрузить страницу. """ url, err = _resolve_widget_url() if url is None: _log_warn(err) return False name = _trim(st["source_name"]) or DEFAULT_NAME # 1) Ищем существующий источник с таким именем, чтобы не плодить дубликаты. source = _find_source_by_name(name) created = False if source is not None: if not _is_browser_source(source): _log_err("источник «" + name + "» уже существует, но это не «Браузер» " "(id = «" + str(obs.obs_source_get_id(source)) + "»). Переименуйте его " "или задайте другое «Имя источника» — чужой источник скрипт не трогает.") obs.obs_source_release(source) return False else: settings = _build_source_settings(url, None) source = obs.obs_source_create("browser_source", name, settings, None) obs.obs_data_release(settings) if source is None: _log_err("OBS не смог создать источник browser_source. Проверьте, что " "плагин «Browser Source» есть в сборке OBS (он входит в стандартную).") return False created = True # 2) Обновляем настройки, сохраняя пользовательский CSS, если он был задан. css = TRANSPARENT_CSS existing = obs.obs_source_get_settings(source) if existing is not None: try: current_css = obs.obs_data_get_string(existing, "css") if current_css: css = current_css except Exception: pass obs.obs_data_release(existing) settings = _build_source_settings(url, css) obs.obs_source_update(source, settings) obs.obs_data_release(settings) # 3) Звук: звук алертов играет сам виджет через WebAudio, поэтому по умолчанию # громкость источника НЕ трогаем — звук донатов должен идти в эфир. # Галочка «Отключить звук источника в микшере» нужна только тем, у кого звук # алертов идёт другим путём: при ней ставим volume = 0.0. if st["mute_audio"]: obs.obs_source_set_volume(source, 0.0) # 4) Кладём в сцену (если его там ещё нет) in_scene = _ensure_in_scene(source, name) # 5) При необходимости перезагружаем страницу if restart and in_scene: _restart_source(source) _log_info(("создан" if created else "обновлён") + " источник «" + name + "»: url = " + url + ", " + str(st["width"]) + "x" + str(st["height"]) + ", restart_when_active = " + str(st["restart_when_active"]) + ", звук в микшере = " + ("отключён галочкой" if st["mute_audio"] else "не менялся")) if st["mute_audio"]: _log_warn("включена галочка «Отключить звук источника в микшере»: громкость " "источника выставлена в 0.0, поэтому звук донатов НЕ попадёт в " "трансляцию. Выключите галочку, если алерты должны звучать в эфире.") obs.obs_source_release(source) return True def _check_connection(): """ Отдельная функция «Проверить подключение»: обновляет URL у источника и перезагружает страницу. Если источника нет — создаёт его. """ if not _sync_source(False): _log_warn("проверка подключения не выполнена: не удалось создать/обновить источник.") return False name = _trim(st["source_name"]) or DEFAULT_NAME source = _find_source_by_name(name) if source is None: _log_warn("источник «" + name + "» не найден — нечего перезагружать.") return False _restart_source(source) obs.obs_source_release(source) _log_info("проверка подключения: источник «" + name + "» перезагружен. Отправьте " "«Тестовый донат» в кабинете TipBit — алерт должен появиться в OBS.") return True # --------------------------------------------------------------------------- # Поле «Сцена» в свойствах скрипта # --------------------------------------------------------------------------- def _scene_names(): """ Список сцен. В Python-API нет obs_frontend_get_scenes(); если сборка OBS всё-таки отдаёт имена сцен — используем их, иначе поле остаётся редактируемым (имя сцены можно вписать руками). """ getter = getattr(obs, "obs_frontend_get_scene_names", None) if getter is not None: try: names = getter() if names: return [str(n) for n in names if n] except Exception: pass return [] def _fill_scene_list(props): prop = obs.obs_properties_get(props, "scene") if prop is None: return obs.obs_property_list_clear(prop) obs.obs_property_list_add_string(prop, "Текущая сцена (программная)", SCENE_CURRENT) names = _scene_names() for name in names: obs.obs_property_list_add_string(prop, name, name) # Сохранённая сцена не нашлась среди сцен — оставляем пунктом, чтобы не # потерять выбор пользователя молча. if st["scene"] != SCENE_CURRENT and st["scene"] not in names and _trim(st["scene"]): obs.obs_property_list_add_string(prop, st["scene"] + " (не найдена)", st["scene"]) # --------------------------------------------------------------------------- # Обработчики кнопок # --------------------------------------------------------------------------- def _on_apply_clicked(props, prop): _fill_scene_list(props) if _sync_source(True): _log_info("готово. Проверьте алерт кнопкой «Тестовый донат» в кабинете TipBit.") else: _log_warn("источник не создан/не обновлён — смотрите сообщения выше.") return False def _on_check_clicked(props, prop): _check_connection() return False def _on_refresh_scenes_clicked(props, prop): _fill_scene_list(props) _log_info("список сцен обновлён.") return False # --------------------------------------------------------------------------- # Функции, которые вызывает OBS # --------------------------------------------------------------------------- def script_description(): return ("TipBit " + SCRIPT_VERSION + " — добавляет в сцену источник «Браузер» " "(Browser Source) с виджетом алертов TipBit.\n\n" "1) В кабинете TipBit откройте «Алерты и виджет» и скопируйте ссылку виджета " "(https://домен/w/<токен>) — целиком или только токен.\n" "2) Вставьте её в поле «Ссылка виджета (или токен)».\n" "3) Проверьте размеры (1920x1080 либо 800x450), укажите сцену и нажмите " "«Проверить / обновить источник».\n" "4) Проверка алерта — кнопка «Тестовый донат» в кабинете.\n\n" "Звук алертов играет сам виджет (WebAudio): по умолчанию громкость источника " "в микшере не меняется, поэтому донаты слышно в эфире. Галочку «Отключить звук " "источника в микшере» включайте только если звук алертов у вас идёт другим путём.\n\n" "Повторное нажатие кнопки обновляет существующий источник, дубликатов не создаёт.") def script_defaults(settings): obs.obs_data_set_default_string(settings, "widget", "") obs.obs_data_set_default_string(settings, "base_url", DEFAULT_BASE_URL) obs.obs_data_set_default_string(settings, "source_name", DEFAULT_NAME) obs.obs_data_set_default_int(settings, "width", 1920) obs.obs_data_set_default_int(settings, "height", 1080) obs.obs_data_set_default_string(settings, "scene", SCENE_CURRENT) obs.obs_data_set_default_bool(settings, "append_to_end", False) obs.obs_data_set_default_bool(settings, "restart_when_active", True) obs.obs_data_set_default_bool(settings, "mute_audio", False) obs.obs_data_set_default_int(settings, "auto_refresh_minutes", 0) def script_properties(): props = obs.obs_properties_create() obs.obs_properties_add_text(props, "widget", "Ссылка виджета (или токен)", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(props, "base_url", "Базовый URL сервера (нужен, если вставлен только токен)", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(props, "source_name", "Имя источника", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_int(props, "width", "Ширина (1920 или 800)", 16, 7680, 1) obs.obs_properties_add_int(props, "height", "Высота (1080 или 450)", 16, 4320, 1) # Редактируемый список: пункт «Текущая сцена» + имена сцен, если их удалось получить. obs.obs_properties_add_list(props, "scene", "Сцена", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) obs.obs_properties_add_bool(props, "append_to_end", "Добавить в конец сцены (ниже остальных источников)") obs.obs_properties_add_bool(props, "restart_when_active", "Перезапускать при активации сцены") obs.obs_properties_add_bool(props, "mute_audio", "Отключить звук источника в микшере " "(звук донатов не попадёт в эфир)") obs.obs_properties_add_int(props, "auto_refresh_minutes", "Профилактическая перезагрузка виджета, минут (0 — выключено)", 0, 720, 5) obs.obs_properties_add_button(props, "apply", "Проверить / обновить источник", _on_apply_clicked) obs.obs_properties_add_button(props, "check", "Проверить подключение (перезагрузить виджет)", _on_check_clicked) obs.obs_properties_add_button(props, "refresh_scenes", "Обновить список сцен", _on_refresh_scenes_clicked) _fill_scene_list(props) return props def script_update(settings): st["widget"] = _trim(obs.obs_data_get_string(settings, "widget")) st["base_url"] = _trim(obs.obs_data_get_string(settings, "base_url")) or DEFAULT_BASE_URL st["source_name"] = _trim(obs.obs_data_get_string(settings, "source_name")) or DEFAULT_NAME st["scene"] = _trim(obs.obs_data_get_string(settings, "scene")) or SCENE_CURRENT try: width = int(obs.obs_data_get_int(settings, "width")) height = int(obs.obs_data_get_int(settings, "height")) except Exception: width, height = 1920, 1080 # защита от мусора в настройках: мусор → значение по умолчанию, # слишком большое → обрезаем по максимуму if not width or width < 16: width = 1920 if not height or height < 16: height = 1080 st["width"] = min(width, 7680) st["height"] = min(height, 4320) st["append_to_end"] = bool(obs.obs_data_get_bool(settings, "append_to_end")) st["restart_when_active"] = bool(obs.obs_data_get_bool(settings, "restart_when_active")) st["mute_audio"] = bool(obs.obs_data_get_bool(settings, "mute_audio")) try: minutes = int(obs.obs_data_get_int(settings, "auto_refresh_minutes")) except Exception: minutes = 0 st["auto_refresh_minutes"] = max(0, min(720, minutes)) def script_load(settings): script_update(settings) _log_info("загружен (v" + SCRIPT_VERSION + "). Вставьте ссылку виджета из кабинета " "TipBit и нажмите «Проверить / обновить источник».") # OBS должен догрузить сцены и источники — проверяем с небольшой задержкой obs.timer_add(_auto_sync_on_load, AUTO_SYNC_DELAY_MS) _apply_watchdog() def script_unload(): global watchdog_timer if watchdog_timer is not None: try: obs.timer_remove(watchdog_timer) except Exception: pass watchdog_timer = None _log_info("выгружен.") # --------------------------------------------------------------------------- # Надёжность: автопроверка при запуске OBS и профилактическая перезагрузка # --------------------------------------------------------------------------- AUTO_SYNC_DELAY_MS = 3000 watchdog_timer = None def _auto_sync_on_load(): """Приводит источник в актуальное состояние после запуска OBS.""" try: obs.timer_remove(_auto_sync_on_load) except Exception: pass if not _trim(st.get("widget") or ""): _log_info("ссылка виджета ещё не указана — автопроверка источника пропущена") return if _sync_source(False): _log_info("автопроверка при загрузке: источник актуален") def _watchdog_tick(): """Профилактическая перезагрузка страницы виджета (для долгих стримов).""" global watchdog_timer name = _trim(st.get("source_name") or "") or DEFAULT_NAME if not _sync_source(False): return source = _find_source_by_name(name) if source is None: return _restart_source(source) obs.obs_source_release(source) _log_info("профилактическая перезагрузка виджета выполнена") def _apply_watchdog(): global watchdog_timer if watchdog_timer is not None: try: obs.timer_remove(watchdog_timer) except Exception: pass watchdog_timer = None try: minutes = int(st.get("auto_refresh_minutes") or 0) except Exception: minutes = 0 if minutes > 0: watchdog_timer = _watchdog_tick obs.timer_add(watchdog_timer, minutes * 60000) _log_info("профилактическая перезагрузка включена: каждые " + str(minutes) + " мин")