diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb4ed54 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt index c9e7a38..584ffff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/help-dialog.cpp b/src/help-dialog.cpp index 1a22408..5134423 100644 --- a/src/help-dialog.cpp +++ b/src/help-dialog.cpp @@ -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.
" "Standard-Ports: SRTLA = UDP 5000, SRT = UDP 9000
" "Wichtig: In Moblin den SRTLA-Port (5000) angeben, nicht den SRT-Port (9000)!", + "Was ist das Wasserzeichen im Video?", + "In der kostenlosen Version wird Easy IRL Stream – stools.cc " + "unten rechts im Bild eingeblendet. Als " + "Patreon-Unterstützer " + "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.
" "Default ports: SRTLA = UDP 5000, SRT = UDP 9000
" "Important: In Moblin, enter the SRTLA port (5000), not the SRT port (9000)!", + "What is the watermark in the video?", + "The free version shows Easy IRL Stream – stools.cc " + "in the bottom-right corner of the video. " + "Patreon supporters " + "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("
%1
%2
").arg(L.faq_q5).arg(L.faq_a5) + QString("
%1
%2
").arg(L.faq_q6).arg(L.faq_a6) + QString("
%1
%2
").arg(L.faq_q7).arg(L.faq_a7) + + QString("
%1
%2
").arg(L.faq_q8).arg(L.faq_a8) + ""; } @@ -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); +} diff --git a/src/help-dialog.hpp b/src/help-dialog.hpp index 939961a..07e7311 100644 --- a/src/help-dialog.hpp +++ b/src/help-dialog.hpp @@ -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 } diff --git a/src/irl-source.c b/src/irl-source.c index 38cbedf..d65b7a8 100644 --- a/src/irl-source.c +++ b/src/irl-source.c @@ -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); diff --git a/src/irl-source.h b/src/irl-source.h index 672fc83..9d88ac4 100644 --- a/src/irl-source.h +++ b/src/irl-source.h @@ -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; diff --git a/src/media-decoder.c b/src/media-decoder.c index 083333e..38fb086 100644 --- a/src/media-decoder.c +++ b/src/media-decoder.c @@ -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}; diff --git a/src/plugin-main.c b/src/plugin-main.c index 72c57bb..97454e3 100644 --- a/src/plugin-main.c +++ b/src/plugin-main.c @@ -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(), diff --git a/src/remote-settings.c b/src/remote-settings.c index 6cde2fe..595a7e5 100644 --- a/src/remote-settings.c +++ b/src/remote-settings.c @@ -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"); diff --git a/src/watermark.c b/src/watermark.c new file mode 100644 index 0000000..a53dc5d --- /dev/null +++ b/src/watermark.c @@ -0,0 +1,171 @@ +#include "watermark.h" +#include + +#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; + } +} diff --git a/src/watermark.h b/src/watermark.h new file mode 100644 index 0000000..3825390 --- /dev/null +++ b/src/watermark.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void watermark_draw(AVFrame *frame); diff --git a/src/webhook.c b/src/webhook.c index 203ae96..b05c8c1 100644 --- a/src/webhook.c +++ b/src/webhook.c @@ -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) {