Add CHANGELOG.md for version 1.1.0; implement watermark feature and update check

- Introduced a mandatory update check on startup, disabling the plugin until updated.
- Added a watermark for free users, displaying "Easy IRL Stream - stools.cc" in the video.
- Fixed SSL connection errors by utilizing the Windows certificate store in API calls.
- Updated help dialog to include information about the watermark feature.
This commit is contained in:
Nils
2026-04-06 18:29:42 +02:00
parent 9890848c6c
commit fb7edeae0b
12 changed files with 318 additions and 38 deletions
+23
View File
@@ -0,0 +1,23 @@
# Easy IRL Stream
## v1.1.0 — Update Check, Watermark & SSL Fix
### New Features
- **Mandatory update check** — The plugin now checks for updates on startup. If a newer version is available, a dialog is shown, the download page opens, and the plugin stays disabled until updated.
- **Watermark for free users** — Non-Patreon users now see a small "Easy IRL Stream - stools.cc" watermark in the bottom-right corner of the video. Patreon supporters get it removed automatically.
### Bug Fixes
- **Fixed SSL connect errors** — API calls to `stools.cc` failed with "SSL connect error" because libcurl (built with OpenSSL) couldn't find CA certificates on Windows. Added `CURLSSLOPT_NATIVE_CA` to all curl calls so OpenSSL uses the Windows certificate store.
---
## v1.0.1 — Bug Fixes & Improvements
### Bug Fixes
- **Fixed HTTPS webhooks** — Webhooks to `https://` URLs were silently failing because the plugin used raw TCP sockets without TLS. Replaced with libcurl, which properly handles HTTPS, and added connect/request timeouts to prevent hangs.
- **Fixed shared decoder counters** — Debug counters for video packets/frames were shared across all source instances (static variables). They are now per-source and reset on each new connection.
- **Fixed SRT streamid not applied** — The `streamid` setting was loaded from the remote config but never actually appended to the SRT listener URL. It is now included when set.
### Improvements
- **Enriched webhook payload** — Webhook POST body now includes `bitrate_kbps`, `uptime_sec`, `video_width`, `video_height`, and `video_codec` alongside the existing `event`, `source`, and `timestamp` fields.
- **IP addresses in Stream Monitor** — The monitor dock now shows your local (LAN) and external (WAN) IP at the bottom, so you don't have to open the Help dialog to check them.
+1
View File
@@ -22,6 +22,7 @@ add_library(easy-irl-stream MODULE
src/webhook.c
src/srtla-server.c
src/remote-settings.c
src/watermark.c
src/obfuscation.cpp
src/help-dialog.cpp
src/stats-dialog.cpp
+43
View File
@@ -56,6 +56,8 @@ struct HelpStrings {
const char *srtla_step4;
const char *faq_q7;
const char *faq_a7;
const char *faq_q8;
const char *faq_a8;
};
static const HelpStrings LANG_DE = {
@@ -135,6 +137,11 @@ static const HelpStrings LANG_DE = {
"der die Pakete entgegennimmt und an den internen SRT-Server weiterleitet.<br>"
"<b>Standard-Ports:</b> SRTLA = UDP <code>5000</code>, SRT = UDP <code>9000</code><br>"
"<b>Wichtig:</b> In Moblin den <b>SRTLA-Port</b> (5000) angeben, nicht den SRT-Port (9000)!",
"Was ist das Wasserzeichen im Video?",
"In der kostenlosen Version wird <i>Easy IRL Stream &ndash; stools.cc</i> "
"unten rechts im Bild eingeblendet. Als "
"<a href='https://www.patreon.com/checkout/stoolscc?rid=27957669'>Patreon-Unterst&uuml;tzer</a> "
"wird das Wasserzeichen automatisch entfernt.",
};
static const HelpStrings LANG_EN = {
@@ -212,6 +219,11 @@ static const HelpStrings LANG_EN = {
"receives the packets and forwards them to the internal SRT server.<br>"
"<b>Default ports:</b> SRTLA = UDP <code>5000</code>, SRT = UDP <code>9000</code><br>"
"<b>Important:</b> In Moblin, enter the <b>SRTLA port</b> (5000), not the SRT port (9000)!",
"What is the watermark in the video?",
"The free version shows <i>Easy IRL Stream &ndash; stools.cc</i> "
"in the bottom-right corner of the video. "
"<a href='https://www.patreon.com/checkout/stoolscc?rid=27957669'>Patreon supporters</a> "
"get the watermark removed automatically.",
};
static QString build_html(const char *local_ip, const char *external_ip,
@@ -307,6 +319,7 @@ static QString build_html(const char *local_ip, const char *external_ip,
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q5).arg(L.faq_a5)
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q6).arg(L.faq_a6)
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q7).arg(L.faq_a7)
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q8).arg(L.faq_a8)
+ "</body></html>";
}
@@ -388,3 +401,33 @@ extern "C" void update_dialog_show(const char *new_version, const char *locale)
open_url(url);
}
}
extern "C" void forced_update_show(const char *new_version, const char *locale)
{
bool is_de = locale && (strncmp(locale, "de", 2) == 0);
QWidget *parent = (QWidget *)obs_frontend_get_main_window();
QString title = is_de ? "Update erforderlich"
: "Update Required";
QString text = is_de
? QString::fromUtf8(
"Easy IRL Stream v%1 ist verf\xc3\xbc""gbar.\n\n"
"Bitte aktualisiere das Plugin, um es weiter "
"nutzen zu k\xc3\xb6""nnen.\n\n"
"Die Download-Seite wird jetzt ge\xc3\xb6""ffnet.")
.arg(new_version)
: QString("Easy IRL Stream v%1 is available.\n\n"
"Please update the plugin to continue using it.\n\n"
"The download page will now open.")
.arg(new_version);
QMessageBox::warning(parent, title, text, QMessageBox::Ok);
char url[256];
snprintf(url, sizeof(url), "%s%s%s",
obf_https_prefix(), obf_stools_host(),
obf_dash_downloads_path());
open_url(url);
}
+1
View File
@@ -8,6 +8,7 @@ void help_dialog_show(const char *local_ip, const char *external_ip,
const char *version, const char *locale);
void update_dialog_show(const char *new_version, const char *locale);
void forced_update_show(const char *new_version, const char *locale);
#ifdef __cplusplus
}
+1
View File
@@ -115,6 +115,7 @@ static void *irl_source_create(obs_data_t *settings, obs_source_t *source)
data->video_stream_idx = -1;
data->audio_stream_idx = -1;
data->active = true;
data->show_watermark = true;
pthread_mutex_init(&data->mutex, NULL);
+3
View File
@@ -115,6 +115,9 @@ struct irl_source_data {
char *webhook_url;
char *custom_command;
/* Watermark (non-patreon) */
bool show_watermark;
/* Decoder debug counters (per-source) */
int dec_vid_pkt_count;
int dec_vid_frame_count;
+17
View File
@@ -1,4 +1,5 @@
#include "media-decoder.h"
#include "watermark.h"
bool decoder_open(struct irl_source_data *data)
{
@@ -173,6 +174,10 @@ static void output_video_frame(struct irl_source_data *data, AVFrame *frame)
obs_fmt, full_range);
}
if (data->show_watermark &&
av_frame_make_writable(frame) >= 0)
watermark_draw(frame);
enum video_colorspace cs = (h >= 720) ? VIDEO_CS_709
: VIDEO_CS_601;
@@ -228,6 +233,18 @@ static void output_video_frame(struct irl_source_data *data, AVFrame *frame)
frame->linesize, 0, h, data->video_dst_data,
data->video_dst_linesize);
if (data->show_watermark) {
AVFrame tmp = {0};
tmp.data[0] = data->video_dst_data[0];
tmp.data[1] = data->video_dst_data[1];
tmp.linesize[0] = data->video_dst_linesize[0];
tmp.linesize[1] = data->video_dst_linesize[1];
tmp.width = w;
tmp.height = h;
tmp.format = AV_PIX_FMT_NV12;
watermark_draw(&tmp);
}
enum video_colorspace cs = (h >= 720) ? VIDEO_CS_709 : VIDEO_CS_601;
struct obs_source_frame obs_frame = {0};
+42 -38
View File
@@ -260,6 +260,9 @@ static void *ip_detect_thread(void *arg)
/* ---- Update check ---- */
static volatile bool g_update_required = false;
static char g_remote_version[64] = "";
struct update_mem_buf {
char *data;
size_t size;
@@ -296,18 +299,8 @@ struct update_ctx {
#include "help-dialog.hpp"
#include "stats-dialog.hpp"
static void task_show_update_dialog(void *param)
static bool check_update_blocking(void)
{
struct update_ctx *ctx = param;
update_dialog_show(ctx->version, obs_get_locale());
free(ctx);
}
static void *update_check_thread(void *arg)
{
UNUSED_PARAMETER(arg);
os_sleep_ms(5000);
char url[256];
snprintf(url, sizeof(url), "%s%s%s",
obf_https_prefix(), obf_stools_host(),
@@ -317,14 +310,18 @@ static void *update_check_thread(void *arg)
snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION);
CURL *curl = curl_easy_init();
if (!curl) return NULL;
if (!curl) return false;
struct update_mem_buf buf = {NULL, 0};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, update_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, ua);
#ifdef CURLSSLOPT_NATIVE_CA
curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, (long)CURLSSLOPT_NATIVE_CA);
#endif
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
@@ -333,38 +330,30 @@ static void *update_check_thread(void *arg)
if (res != CURLE_OK || http_code != 200 || !buf.data) {
free(buf.data);
return NULL;
return false;
}
/* Parse {"version":"x.y.z"} */
const char *vkey = strstr(buf.data, "\"version\"");
if (!vkey) { free(buf.data); return NULL; }
if (!vkey) { free(buf.data); return false; }
const char *vstart = strchr(vkey + 9, '"');
if (!vstart) { free(buf.data); return NULL; }
if (!vstart) { free(buf.data); return false; }
vstart++;
const char *vend = strchr(vstart, '"');
if (!vend || vend - vstart > 60) { free(buf.data); return NULL; }
if (!vend || vend - vstart > 60) { free(buf.data); return false; }
char remote_ver[64];
size_t vlen = (size_t)(vend - vstart);
memcpy(remote_ver, vstart, vlen);
remote_ver[vlen] = '\0';
memcpy(g_remote_version, vstart, vlen);
g_remote_version[vlen] = '\0';
free(buf.data);
if (compare_versions(remote_ver, PLUGIN_VERSION) > 0) {
blog(LOG_DEBUG, "[%s] New version available: %s (current: %s)",
PLUGIN_NAME, remote_ver, PLUGIN_VERSION);
return compare_versions(g_remote_version, PLUGIN_VERSION) > 0;
}
struct update_ctx *ctx = malloc(sizeof(*ctx));
if (ctx) {
snprintf(ctx->version, sizeof(ctx->version), "%s",
remote_ver);
obs_queue_task(OBS_TASK_UI, task_show_update_dialog,
ctx, false);
}
}
return NULL;
static void task_show_forced_update(void *param)
{
struct update_ctx *ctx = param;
forced_update_show(ctx->version, obs_get_locale());
free(ctx);
}
/* ---- Tools menu ---- */
@@ -388,22 +377,37 @@ bool obs_module_load(void)
{
curl_global_init(CURL_GLOBAL_DEFAULT);
if (check_update_blocking()) {
g_update_required = true;
blog(LOG_WARNING,
"[%s] Update required (v%s available), plugin disabled",
PLUGIN_NAME, g_remote_version);
return true;
}
obs_register_source(&irl_source_info);
g_ip_thread_active = true;
if (pthread_create(&g_ip_thread, NULL, ip_detect_thread, NULL) != 0)
g_ip_thread_active = false;
pthread_t update_thread;
if (pthread_create(&update_thread, NULL, update_check_thread, NULL) == 0)
pthread_detach(update_thread);
blog(LOG_INFO, "[%s] Plugin loaded (v%s)", PLUGIN_NAME, PLUGIN_VERSION);
return true;
}
void obs_module_post_load(void)
{
if (g_update_required) {
struct update_ctx *ctx = malloc(sizeof(*ctx));
if (ctx) {
snprintf(ctx->version, sizeof(ctx->version), "%s",
g_remote_version);
obs_queue_task(OBS_TASK_UI, task_show_forced_update,
ctx, false);
}
return;
}
obs_frontend_add_tools_menu_item(tr_tools_menu_help(),
tools_menu_cb, NULL);
obs_frontend_add_tools_menu_item(tr_tools_menu_stats(),
+8
View File
@@ -60,6 +60,9 @@ static char *api_get(const char *path, const char *token)
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, ua);
#ifdef CURLSSLOPT_NATIVE_CA
curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, (long)CURLSSLOPT_NATIVE_CA);
#endif
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
@@ -107,6 +110,9 @@ static bool api_post(const char *path, const char *token, const char *json_body)
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, ua);
#ifdef CURLSSLOPT_NATIVE_CA
curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, (long)CURLSSLOPT_NATIVE_CA);
#endif
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
@@ -221,6 +227,8 @@ static void apply_remote_settings(struct irl_source_data *data, const char *json
data->srtla_enabled = json_get_bool(json, "srtlaEnabled", data->srtla_enabled);
data->srtla_port = json_get_int(json, "srtlaPort", data->srtla_port);
data->show_watermark = !json_get_bool(json, "patreon", false);
bfree(data->duckdns_domain);
data->duckdns_domain = json_get_string(json, "duckdnsDomain");
+171
View File
@@ -0,0 +1,171 @@
#include "watermark.h"
#include <string.h>
#define WM_FONT_W 7
#define WM_FONT_H 12
#define WM_PAD_X 10
#define WM_PAD_Y 3
static const char wm_text[] = "Easy IRL Stream - stools.cc";
/*
* Embedded 7x12 bitmap font.
* Each row is one byte; bits 7-1 = 7 pixels left to right.
* Only characters needed for wm_text are defined.
*/
struct wm_glyph {
char ch;
uint8_t rows[12];
};
static const struct wm_glyph wm_font[] = {
{' ', {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}},
{'-', {0x00,0x00,0x00,0x00,0x00,0x78,0x00,0x00,0x00,0x00,0x00,0x00}},
{'.', {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x30,0x00,0x00}},
{'E', {0x00,0x00,0x7C,0x40,0x40,0x78,0x40,0x40,0x40,0x7C,0x00,0x00}},
{'I', {0x00,0x00,0x7C,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00}},
{'L', {0x00,0x00,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x7C,0x00,0x00}},
{'R', {0x00,0x00,0x78,0x44,0x44,0x78,0x50,0x48,0x44,0x44,0x00,0x00}},
{'S', {0x00,0x00,0x38,0x44,0x40,0x38,0x04,0x04,0x44,0x38,0x00,0x00}},
{'a', {0x00,0x00,0x00,0x00,0x38,0x04,0x3C,0x44,0x44,0x3C,0x00,0x00}},
{'c', {0x00,0x00,0x00,0x00,0x38,0x44,0x40,0x40,0x44,0x38,0x00,0x00}},
{'e', {0x00,0x00,0x00,0x00,0x38,0x44,0x7C,0x40,0x40,0x38,0x00,0x00}},
{'l', {0x00,0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x38,0x00,0x00}},
{'m', {0x00,0x00,0x00,0x00,0x6C,0x54,0x54,0x54,0x54,0x54,0x00,0x00}},
{'o', {0x00,0x00,0x00,0x00,0x38,0x44,0x44,0x44,0x44,0x38,0x00,0x00}},
{'r', {0x00,0x00,0x00,0x00,0x58,0x60,0x40,0x40,0x40,0x40,0x00,0x00}},
{'s', {0x00,0x00,0x00,0x00,0x38,0x40,0x38,0x04,0x44,0x38,0x00,0x00}},
{'t', {0x00,0x00,0x10,0x10,0x78,0x10,0x10,0x10,0x10,0x0C,0x00,0x00}},
{'y', {0x00,0x00,0x00,0x00,0x44,0x44,0x44,0x44,0x3C,0x04,0x78,0x00}},
};
#define WM_GLYPH_COUNT (sizeof(wm_font) / sizeof(wm_font[0]))
static const uint8_t *wm_get_glyph(char c)
{
for (int i = 0; i < (int)WM_GLYPH_COUNT; i++)
if (wm_font[i].ch == c)
return wm_font[i].rows;
return wm_font[0].rows;
}
static void stamp_y_plane(uint8_t *y, int stride, int w, int h)
{
int text_len = (int)strlen(wm_text);
int text_w = text_len * WM_FONT_W;
int bar_w = text_w + WM_PAD_X * 2;
int bar_h = WM_FONT_H + WM_PAD_Y * 2;
int bar_x = w - bar_w;
int bar_y = h - bar_h;
if (bar_x < 0 || bar_y < 0)
return;
for (int r = bar_y; r < bar_y + bar_h && r < h; r++)
for (int c = bar_x; c < bar_x + bar_w && c < w; c++)
y[r * stride + c] >>= 2;
int tx = bar_x + WM_PAD_X;
int ty = bar_y + WM_PAD_Y;
for (int i = 0; i < text_len; i++) {
const uint8_t *glyph = wm_get_glyph(wm_text[i]);
int cx = tx + i * WM_FONT_W;
for (int r = 0; r < WM_FONT_H; r++) {
int py = ty + r;
if (py >= h)
break;
uint8_t bits = glyph[r];
for (int c = 0; c < WM_FONT_W; c++) {
int px = cx + c;
if (px >= w)
break;
if (bits & (0x80 >> c))
y[py * stride + px] = 220;
}
}
}
}
static void neutralize_chroma_420(uint8_t *u, int u_stride,
uint8_t *v, int v_stride,
int bar_x, int bar_y,
int bar_w, int bar_h,
int w, int h)
{
int cx0 = bar_x / 2;
int cy0 = bar_y / 2;
int cx1 = (bar_x + bar_w + 1) / 2;
int cy1 = (bar_y + bar_h + 1) / 2;
int cw = w / 2;
int ch = h / 2;
for (int r = cy0; r < cy1 && r < ch; r++)
for (int c = cx0; c < cx1 && c < cw; c++) {
u[r * u_stride + c] = 128;
v[r * v_stride + c] = 128;
}
}
static void neutralize_chroma_nv12(uint8_t *uv, int uv_stride,
int bar_x, int bar_y,
int bar_w, int bar_h,
int w, int h)
{
int cx0 = bar_x / 2;
int cy0 = bar_y / 2;
int cx1 = (bar_x + bar_w + 1) / 2;
int cy1 = (bar_y + bar_h + 1) / 2;
int cw = w / 2;
int ch = h / 2;
for (int r = cy0; r < cy1 && r < ch; r++)
for (int c = cx0; c < cx1 && c < cw; c++) {
uv[r * uv_stride + c * 2] = 128;
uv[r * uv_stride + c * 2 + 1] = 128;
}
}
void watermark_draw(AVFrame *frame)
{
int w = frame->width;
int h = frame->height;
enum AVPixelFormat fmt = (enum AVPixelFormat)frame->format;
int text_len = (int)strlen(wm_text);
int text_w = text_len * WM_FONT_W;
int bar_w = text_w + WM_PAD_X * 2;
int bar_h = WM_FONT_H + WM_PAD_Y * 2;
int bar_x = w - bar_w;
int bar_y = h - bar_h;
if (bar_x < 0 || bar_y < 0)
return;
switch (fmt) {
case AV_PIX_FMT_YUV420P:
case AV_PIX_FMT_YUVJ420P:
stamp_y_plane(frame->data[0], frame->linesize[0], w, h);
neutralize_chroma_420(frame->data[1], frame->linesize[1],
frame->data[2], frame->linesize[2],
bar_x, bar_y, bar_w, bar_h, w, h);
break;
case AV_PIX_FMT_NV12:
stamp_y_plane(frame->data[0], frame->linesize[0], w, h);
neutralize_chroma_nv12(frame->data[1], frame->linesize[1],
bar_x, bar_y, bar_w, bar_h, w, h);
break;
case AV_PIX_FMT_YUV422P:
case AV_PIX_FMT_YUVJ422P:
case AV_PIX_FMT_YUV444P:
case AV_PIX_FMT_YUVJ444P:
stamp_y_plane(frame->data[0], frame->linesize[0], w, h);
break;
default:
break;
}
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <libavutil/frame.h>
void watermark_draw(AVFrame *frame);
+3
View File
@@ -44,6 +44,9 @@ static void webhook_do_send(const char *url, const char *json_body)
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, discard_response);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "easy-irl-stream-webhook/1.0");
#ifdef CURLSSLOPT_NATIVE_CA
curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, (long)CURLSSLOPT_NATIVE_CA);
#endif
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) {