+
+#include "help-dialog.hpp"
+#include "obfuscation.h"
+
+static QDialog *g_help_dlg = nullptr;
+static QTextBrowser *g_browser = nullptr;
+
+struct HelpStrings {
+ const char *title;
+ const char *your_network;
+ const char *local_ip_label;
+ const char *external_ip_label;
+ const char *port_fwd;
+ const char *port_fwd_intro;
+ const char *step1;
+ const char *step2;
+ const char *step3;
+ const char *step4;
+ const char *same_wifi_note;
+ const char *duckdns_title;
+ const char *duckdns_intro;
+ const char *duck_step1;
+ const char *duck_step2;
+ const char *duck_step3;
+ const char *duck_step4;
+ const char *duck_step5;
+ const char *duck_example;
+ const char *faq_title;
+ const char *faq_q1;
+ const char *faq_a1;
+ const char *faq_q2;
+ const char *faq_a2;
+ const char *faq_q3;
+ const char *faq_a3;
+ const char *faq_q4;
+ const char *faq_a4;
+ const char *faq_q5;
+ const char *faq_a5;
+ const char *faq_q6;
+ const char *faq_a6;
+ const char *srtla_title;
+ const char *srtla_intro;
+ const char *srtla_step1;
+ const char *srtla_step2;
+ const char *srtla_step3;
+ const char *srtla_step4;
+ const char *faq_q7;
+ const char *faq_a7;
+};
+
+static const HelpStrings LANG_DE = {
+ "Easy IRL Stream",
+ "Deine Netzwerk-Informationen",
+ "Lokale IP (im gleichen WLAN)",
+ "Externe IP (für Mobilfunk / unterwegs)",
+ "Port-Weiterleitung einrichten",
+ "Damit dein Handy von unterwegs (Mobilfunk) streamen kann, "
+ "muss der Port im Router weitergeleitet werden:",
+ "Router-Konfiguration öffnen
"
+ "Fritz!Box: http://fritz.box
"
+ "Telekom: http://192.168.2.1
"
+ "Andere: http://192.168.1.1",
+ "Port-Weiterleitung einrichten
"
+ "Externer Port: Dein Plugin-Port (Standard: 1935 / 9000)
"
+ "Interner Port: Der gleiche Port
"
+ "Protokoll: TCP (RTMP) oder UDP (SRT)
"
+ "Ziel-IP: %1 (dieser PC)",
+ "Windows-Firewall prüfen
"
+ "Beim ersten Start fragt Windows nach. Falls nicht:
"
+ "Windows-Suche → Windows Defender Firewall → "
+ "Erweiterte Einstellungen → Eingehende Regeln → "
+ "Neue Regel → Port → TCP/UDP → Port eingeben → Zulassen",
+ "Am Handy verbinden
"
+ "Als Server-IP die externe IP verwenden: %1",
+ "Im gleichen WLAN? Keine Port-Weiterleitung nötig! "
+ "Einfach die lokale IP verwenden: %1",
+ "DuckDNS (Dynamisches DNS)",
+ "Deine externe IP ändert sich regelmäßig. "
+ "Mit DuckDNS bekommst du eine feste Adresse:",
+ "Gehe zu duckdns.org und erstelle ein Konto",
+ "Erstelle eine Subdomain (z.B. meinstream)",
+ "Kopiere deinen Token",
+ "Trage Subdomain + Token auf stools.cc unter DuckDNS ein",
+ "Das Plugin aktualisiert deine IP automatisch!",
+ "Dein Handy verbindet sich dann z.B. mit:",
+ "Häufige Fragen",
+ "Mein Handy kann sich nicht verbinden – was tun?",
+ "1. Plugin in OBS aktiv? (Quelle muss in einer Szene sein)
"
+ "2. Im gleichen WLAN? → Lokale IP verwenden
"
+ "3. Über Mobilfunk? → Port-Weiterleitung einrichten
"
+ "4. Windows-Firewall → Port freigeben
"
+ "5. Port + Protokoll korrekt? RTMP = TCP:1935, SRT = UDP:9000",
+ "Was ist besser – RTMP oder SRT?",
+ "SRT ist besser für Mobilfunk (eingebaute Fehlerkorrektur, konfigurierbare Latenz).
"
+ "RTMP ist einfacher und wird von mehr Streaming-Apps unterstützt.
"
+ "Empfehlung: SRT für IRL-Streaming, RTMP als Fallback.
"
+ "Hinweis: Die SRT-Passphrase muss 10–79 Zeichen lang sein (SRT-Protokoll-Vorgabe).",
+ "Wie funktionieren Overlays?",
+ "Erstelle eine Quelle (Bild/Text) in deiner Szene → Blende sie mit dem "
+ "Auge-Symbol aus → Wähle sie im Plugin als Overlay-Quelle aus → "
+ "Das Plugin blendet sie automatisch ein/aus.",
+ "Was bedeutet „Schwellenwert (kbps)“?",
+ "Die minimale Bitrate, ab der die Verbindung als „schlecht“ gilt. "
+ "Standard: 500 kbps. Liegt die Bitrate darunter, werden die "
+ "konfigurierten Qualitäts-Aktionen ausgelöst (Overlay, Szenenwechsel…).",
+ "Unterschied Disconnect vs. schlechte Qualität?",
+ "Disconnect: Verbindung komplett weg – kein Stream kommt an.
"
+ "Schlechte Qualität: Stream kommt noch an, aber Bitrate ist zu niedrig.
"
+ "Für beide können unterschiedliche Aktionen und Overlays konfiguriert werden.",
+ "Meine externe IP ändert sich ständig?",
+ "Nutze DuckDNS (siehe oben). Dann hast du eine feste Adresse wie "
+ "meinstream.duckdns.org.",
+ "SRTLA (Link Aggregation)",
+ "SRTLA ermöglicht Apps wie Moblin, WLAN und Mobilfunk gleichzeitig "
+ "zu nutzen. Die Verbindung wird dadurch deutlich stabiler – fällt ein Netzwerk aus, "
+ "läuft der Stream über das andere weiter.",
+ "Auf stools.cc: SRT als Protokoll wählen und SRTLA aktivieren",
+ "SRTLA-Port merken (Standard: 5000)",
+ "In Moblin: Protokoll auf SRT(LA) stellen",
+ "Als Server-Adresse <DEINE_IP>:5000 eingeben "
+ "(den SRTLA-Port, nicht den SRT-Port!)",
+ "Was ist SRTLA?",
+ "SRTLA (SRT Link Aggregation) bündelt mehrere Netzwerkverbindungen "
+ "(z.B. WLAN + Mobilfunk) zu einer einzigen. Das Plugin startet einen SRTLA-Proxy, "
+ "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)!",
+};
+
+static const HelpStrings LANG_EN = {
+ "Easy IRL Stream",
+ "Your Network Information",
+ "Local IP (same WiFi network)",
+ "External IP (for mobile / remote)",
+ "Port Forwarding Setup",
+ "For your phone to stream remotely (mobile data), "
+ "you need to set up port forwarding in your router:",
+ "Open router configuration
"
+ "Common addresses: http://192.168.1.1 or http://192.168.0.1",
+ "Set up port forwarding
"
+ "External port: Your plugin port (default: 1935 / 9000)
"
+ "Internal port: Same port
"
+ "Protocol: TCP (RTMP) or UDP (SRT)
"
+ "Target IP: %1 (this PC)",
+ "Check Windows Firewall
"
+ "Windows should ask on first launch. If not:
"
+ "Windows Search → Windows Defender Firewall → "
+ "Advanced Settings → Inbound Rules → "
+ "New Rule → Port → TCP/UDP → Enter port → Allow",
+ "Connect your phone
"
+ "Use the external IP as server address: %1",
+ "Same WiFi? No port forwarding needed! "
+ "Just use the local IP: %1",
+ "DuckDNS (Dynamic DNS)",
+ "Your external IP changes regularly. "
+ "With DuckDNS you get a fixed address:",
+ "Go to duckdns.org and create an account",
+ "Create a subdomain (e.g. mystream)",
+ "Copy your Token",
+ "Enter subdomain + token on stools.cc under DuckDNS",
+ "The plugin updates your IP automatically!",
+ "Your phone then connects to e.g.:",
+ "Frequently Asked Questions",
+ "My phone can't connect – what to do?",
+ "1. Plugin active in OBS? (source must be in a scene)
"
+ "2. Same WiFi? → Use local IP
"
+ "3. On mobile data? → Set up port forwarding
"
+ "4. Windows Firewall → Allow the port
"
+ "5. Port + protocol correct? RTMP = TCP:1935, SRT = UDP:9000",
+ "Which is better – RTMP or SRT?",
+ "SRT is better for mobile (built-in error correction, configurable latency).
"
+ "RTMP is simpler and supported by more streaming apps.
"
+ "Recommendation: SRT for IRL streaming, RTMP as fallback.
"
+ "Note: The SRT passphrase must be 10–79 characters long (SRT protocol requirement).",
+ "How do overlays work?",
+ "Create a source (image/text) in your scene → Hide it with the "
+ "eye icon → Select it as overlay source in the plugin → "
+ "The plugin shows/hides it automatically.",
+ "What does "threshold (kbps)" mean?",
+ "The minimum bitrate below which the connection is considered "bad". "
+ "Default: 500 kbps. If the bitrate drops below this, the "
+ "configured quality actions are triggered (overlay, scene switch…).",
+ "Difference between disconnect and bad quality?",
+ "Disconnect: Connection completely lost – no stream arriving.
"
+ "Bad quality: Stream still arriving, but bitrate is too low.
"
+ "Different actions and overlays can be configured for each.",
+ "My external IP keeps changing?",
+ "Use DuckDNS (see above). Then you have a fixed address like "
+ "mystream.duckdns.org.",
+ "SRTLA (Link Aggregation)",
+ "SRTLA allows apps like Moblin to use WiFi and mobile data simultaneously. "
+ "This makes the connection much more stable – if one network drops, "
+ "the stream continues over the other.",
+ "On stools.cc: Select SRT as protocol and enable SRTLA",
+ "Note the SRTLA port (default: 5000)",
+ "In Moblin: Set protocol to SRT(LA)",
+ "Enter <YOUR_IP>:5000 as server address "
+ "(the SRTLA port, not the SRT port!)",
+ "What is SRTLA?",
+ "SRTLA (SRT Link Aggregation) bonds multiple network connections "
+ "(e.g. WiFi + mobile data) into one. The plugin runs an SRTLA proxy that "
+ "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)!",
+};
+
+static QString build_html(const char *local_ip, const char *external_ip,
+ const char *version, const HelpStrings &L)
+{
+ QString lip = local_ip && local_ip[0] ? local_ip : "?.?.?.?";
+ QString eip = external_ip && external_ip[0]
+ ? external_ip
+ : "...";
+
+ QWidget *w = QApplication::activeWindow();
+ QPalette pal = w ? w->palette() : QApplication::palette();
+
+ QString bg = pal.color(QPalette::Base).name();
+ QString fg = pal.color(QPalette::Text).name();
+ QString bg2 = pal.color(QPalette::AlternateBase).name();
+ QString accent = pal.color(QPalette::Highlight).name();
+ QString dimmed = pal.color(QPalette::PlaceholderText).name();
+ QString link = pal.color(QPalette::Link).name();
+
+ return QString(
+ ""
+ "")
+ .arg(bg, fg, dimmed, bg2, accent, link)
+
+ + QString("%1
Version %2
").arg(L.title).arg(version)
+
+ + QString("%1
").arg(L.your_network)
+ + QString("")
+ .arg(L.local_ip_label)
+ .arg(lip)
+ + QString("")
+ .arg(L.external_ip_label)
+ .arg(eip)
+
+ + QString("%1
%2
").arg(L.port_fwd).arg(L.port_fwd_intro)
+ + QString(""
+ "- %1
"
+ "- %2
"
+ "- %3
"
+ "- %4
"
+ "
")
+ .arg(L.step1)
+ .arg(QString(L.step2).arg(lip))
+ .arg(L.step3)
+ .arg(QString(L.step4).arg(eip))
+ + QString("%1
").arg(QString(L.same_wifi_note).arg(lip))
+
+ + QString("%1
%2
").arg(L.duckdns_title).arg(L.duckdns_intro)
+ + QString("- %1
- %2
- %3
- %4
- %5
")
+ .arg(L.duck_step1)
+ .arg(L.duck_step2)
+ .arg(L.duck_step3)
+ .arg(L.duck_step4)
+ .arg(L.duck_step5)
+ + QString("%1
rtmp://meinstream.duckdns.org:1935/live
").arg(L.duck_example)
+
+ + QString("%1
%2
").arg(L.srtla_title).arg(L.srtla_intro)
+ + QString("- %1
- %2
- %3
- %4
")
+ .arg(L.srtla_step1)
+ .arg(L.srtla_step2)
+ .arg(L.srtla_step3)
+ .arg(L.srtla_step4)
+
+ + QString("%1
").arg(L.faq_title)
+ + QString("%1
%2
").arg(L.faq_q1).arg(L.faq_a1)
+ + QString("%1
%2
").arg(L.faq_q2).arg(L.faq_a2)
+ + QString("%1
%2
").arg(L.faq_q3).arg(L.faq_a3)
+ + QString("%1
%2
").arg(L.faq_q4).arg(L.faq_a4)
+ + 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)
+
+ + "";
+}
+
+extern "C" void help_dialog_show(const char *local_ip,
+ const char *external_ip,
+ const char *version,
+ const char *locale)
+{
+ bool is_de = locale && (strncmp(locale, "de", 2) == 0);
+ const HelpStrings &L = is_de ? LANG_DE : LANG_EN;
+
+ if (g_help_dlg) {
+ g_browser->setHtml(
+ build_html(local_ip, external_ip, version, L));
+ g_help_dlg->show();
+ g_help_dlg->raise();
+ g_help_dlg->activateWindow();
+ return;
+ }
+
+ QWidget *parent = (QWidget *)obs_frontend_get_main_window();
+
+ g_help_dlg = new QDialog(parent);
+ g_help_dlg->setWindowTitle(
+ QString("Easy IRL Stream %1 Help & FAQ")
+ .arg(QChar(0x2014)));
+ g_help_dlg->resize(580, 700);
+ g_help_dlg->setAttribute(Qt::WA_DeleteOnClose);
+ QObject::connect(g_help_dlg, &QDialog::destroyed, []() {
+ g_help_dlg = nullptr;
+ g_browser = nullptr;
+ });
+
+ g_browser = new QTextBrowser(g_help_dlg);
+ g_browser->setOpenExternalLinks(true);
+ g_browser->setHtml(build_html(local_ip, external_ip, version, L));
+
+ QVBoxLayout *layout = new QVBoxLayout(g_help_dlg);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->addWidget(g_browser);
+
+ g_help_dlg->show();
+}
+
+static void open_url(const char *url)
+{
+ QDesktopServices::openUrl(QUrl(QString::fromUtf8(url)));
+}
+
+extern "C" void update_dialog_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 ? QString::fromUtf8("Update verf\xc3\xbc""gbar")
+ : "Update Available";
+
+ QString text = is_de
+ ? QString::fromUtf8("Eine neue Version (%1) von Easy IRL Stream "
+ "ist verf\xc3\xbc""gbar!\n\n"
+ "M\xc3\xb6""chtest du die Download-Seite "
+ "\xc3\xb6""ffnen?")
+ .arg(new_version)
+ : QString("A new version (%1) of Easy IRL Stream is available!"
+ "\n\nWould you like to open the download page?")
+ .arg(new_version);
+
+ QMessageBox::StandardButton reply = QMessageBox::information(
+ parent, title, text,
+ QMessageBox::Ok | QMessageBox::Cancel);
+
+ if (reply == 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
new file mode 100644
index 0000000..939961a
--- /dev/null
+++ b/src/help-dialog.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+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);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/ingest-thread.c b/src/ingest-thread.c
new file mode 100644
index 0000000..f8fded0
--- /dev/null
+++ b/src/ingest-thread.c
@@ -0,0 +1,223 @@
+#include "ingest-thread.h"
+#include "media-decoder.h"
+#include "event-handler.h"
+
+extern void duckdns_update(const char *domain, const char *token);
+
+#ifdef _WIN32
+#include
+#define atomic_add_long(ptr, val) _InterlockedExchangeAdd((ptr), (val))
+#else
+#define atomic_add_long(ptr, val) __sync_fetch_and_add((ptr), (val))
+#endif
+
+static int interrupt_cb(void *opaque)
+{
+ struct irl_source_data *data = opaque;
+ return !data->active ? 1 : 0;
+}
+
+static void build_url(struct irl_source_data *data, char *buf, size_t sz)
+{
+ pthread_mutex_lock(&data->mutex);
+
+ if (data->protocol == PROTOCOL_RTMP) {
+ const char *key =
+ (data->stream_key && data->stream_key[0])
+ ? data->stream_key
+ : "stream";
+ snprintf(buf, sz, "rtmp://0.0.0.0:%d/live/%s", data->port,
+ key);
+ } else {
+ struct dstr url;
+ dstr_init(&url);
+ dstr_printf(&url, "srt://0.0.0.0:%d?mode=listener&latency=%d",
+ data->port, data->srt_latency_ms * 1000);
+
+ if (data->srt_passphrase && data->srt_passphrase[0]) {
+ size_t plen = strlen(data->srt_passphrase);
+ if (plen >= 10 && plen <= 79) {
+ dstr_catf(&url, "&passphrase=%s",
+ data->srt_passphrase);
+ } else {
+ blog(LOG_WARNING,
+ "[%s] SRT passphrase ignored: "
+ "must be 10-79 characters (got %zu)",
+ PLUGIN_NAME, plen);
+ }
+ }
+
+ snprintf(buf, sz, "%s", url.array);
+ dstr_free(&url);
+ }
+
+ pthread_mutex_unlock(&data->mutex);
+}
+
+static void *ingest_thread_func(void *arg)
+{
+ struct irl_source_data *data = arg;
+
+ os_set_thread_name("easy-irl-ingest");
+
+ /* Update DuckDNS on startup */
+ pthread_mutex_lock(&data->mutex);
+ if (data->duckdns_domain && data->duckdns_domain[0] &&
+ data->duckdns_token && data->duckdns_token[0]) {
+ char *dd = bstrdup(data->duckdns_domain);
+ char *dt = bstrdup(data->duckdns_token);
+ pthread_mutex_unlock(&data->mutex);
+ duckdns_update(dd, dt);
+ bfree(dd);
+ bfree(dt);
+ } else {
+ pthread_mutex_unlock(&data->mutex);
+ }
+
+ /* Start SRTLA proxy if enabled and SRT selected */
+ pthread_mutex_lock(&data->mutex);
+ bool start_srtla = data->srtla_enabled &&
+ data->protocol == PROTOCOL_SRT;
+ int srtla_port = data->srtla_port;
+ int srt_port_val = data->port;
+ pthread_mutex_unlock(&data->mutex);
+
+ if (start_srtla)
+ srtla_server_start(&data->srtla, srtla_port, srt_port_val);
+
+ while (data->active) {
+ char url[1024];
+ build_url(data, url, sizeof(url));
+
+ os_atomic_set_long(&data->connection_state,
+ CONN_STATE_LISTENING);
+ blog(LOG_DEBUG, "[%s] Listening: %s", PLUGIN_NAME, url);
+
+ AVFormatContext *fmt_ctx = avformat_alloc_context();
+ if (!fmt_ctx) {
+ os_sleep_ms(2000);
+ continue;
+ }
+
+ fmt_ctx->interrupt_callback.callback = interrupt_cb;
+ fmt_ctx->interrupt_callback.opaque = data;
+
+ AVDictionary *opts = NULL;
+ if (data->protocol == PROTOCOL_RTMP)
+ av_dict_set(&opts, "listen", "1", 0);
+ av_dict_set(&opts, "rw_timeout", "5000000", 0);
+
+ int ret = avformat_open_input(&fmt_ctx, url, NULL, &opts);
+ av_dict_free(&opts);
+
+ if (ret < 0) {
+ avformat_free_context(fmt_ctx);
+ if (!data->active)
+ break;
+ char errbuf[256];
+ av_strerror(ret, errbuf, sizeof(errbuf));
+ blog(LOG_WARNING,
+ "[%s] avformat_open_input failed: %s",
+ PLUGIN_NAME, errbuf);
+ os_sleep_ms(2000);
+ continue;
+ }
+
+ data->fmt_ctx = fmt_ctx;
+
+ ret = avformat_find_stream_info(fmt_ctx, NULL);
+ if (ret < 0) {
+ blog(LOG_WARNING, "[%s] Could not find stream info",
+ PLUGIN_NAME);
+ avformat_close_input(&data->fmt_ctx);
+ data->fmt_ctx = NULL;
+ if (!data->active)
+ break;
+ os_sleep_ms(2000);
+ continue;
+ }
+
+ if (!decoder_open(data)) {
+ avformat_close_input(&data->fmt_ctx);
+ data->fmt_ctx = NULL;
+ if (!data->active)
+ break;
+ os_sleep_ms(2000);
+ continue;
+ }
+
+ os_atomic_set_long(&data->connection_state,
+ CONN_STATE_CONNECTED);
+ data->last_frame_time_ns = os_gettime_ns();
+ data->stats_connect_time_ns = os_gettime_ns();
+ data->stats_total_frames = 0;
+ data->stats_total_bytes = 0;
+ event_handler_on_connect(data);
+
+ AVPacket *pkt = av_packet_alloc();
+
+ while (data->active) {
+ ret = av_read_frame(fmt_ctx, pkt);
+ if (ret < 0)
+ break;
+
+ atomic_add_long(&data->bytes_window,
+ (long)pkt->size);
+ data->stats_total_bytes += (uint64_t)pkt->size;
+
+ decoder_decode_packet(data, pkt);
+ av_packet_unref(pkt);
+ }
+
+ av_packet_free(&pkt);
+ decoder_close(data);
+ avformat_close_input(&data->fmt_ctx);
+ data->fmt_ctx = NULL;
+
+ if (data->active) {
+ os_atomic_set_long(&data->connection_state,
+ CONN_STATE_DISCONNECTED);
+ data->stats_connect_time_ns = 0;
+ event_handler_on_disconnect(data);
+ os_sleep_ms(500);
+ }
+ }
+
+ srtla_server_stop(&data->srtla);
+
+ os_atomic_set_long(&data->connection_state, CONN_STATE_IDLE);
+ blog(LOG_DEBUG, "[%s] Ingest thread exited", PLUGIN_NAME);
+ return NULL;
+}
+
+void ingest_thread_start(struct irl_source_data *data)
+{
+ if (data->thread_created)
+ ingest_thread_stop(data);
+
+ data->active = true;
+ data->disconnect_actions_fired = false;
+
+ if (pthread_create(&data->ingest_thread, NULL, ingest_thread_func,
+ data) == 0) {
+ data->thread_created = true;
+ } else {
+ blog(LOG_ERROR, "[%s] Failed to create ingest thread",
+ PLUGIN_NAME);
+ data->active = false;
+ }
+}
+
+void ingest_thread_stop(struct irl_source_data *data)
+{
+ if (!data->thread_created)
+ return;
+
+ data->active = false;
+
+ pthread_join(data->ingest_thread, NULL);
+ data->thread_created = false;
+
+ os_atomic_set_long(&data->connection_state, CONN_STATE_IDLE);
+ blog(LOG_DEBUG, "[%s] Ingest thread stopped", PLUGIN_NAME);
+}
diff --git a/src/ingest-thread.h b/src/ingest-thread.h
new file mode 100644
index 0000000..57a501a
--- /dev/null
+++ b/src/ingest-thread.h
@@ -0,0 +1,6 @@
+#pragma once
+
+#include "irl-source.h"
+
+void ingest_thread_start(struct irl_source_data *data);
+void ingest_thread_stop(struct irl_source_data *data);
diff --git a/src/irl-source.c b/src/irl-source.c
new file mode 100644
index 0000000..38cbedf
--- /dev/null
+++ b/src/irl-source.c
@@ -0,0 +1,266 @@
+#include "irl-source.h"
+#include "ingest-thread.h"
+#include "event-handler.h"
+#include "remote-settings.h"
+#include "obfuscation.h"
+#include "translations.h"
+
+#ifdef _WIN32
+#include
+#include
+#endif
+
+struct irl_source_data *g_irl_sources[MAX_IRL_SOURCES] = {0};
+int g_irl_source_count = 0;
+
+/* ---- helpers ---- */
+
+static inline void safe_bfree(char **ptr)
+{
+ bfree(*ptr);
+ *ptr = NULL;
+}
+
+static void load_settings(struct irl_source_data *data, obs_data_t *settings)
+{
+ pthread_mutex_lock(&data->mutex);
+
+ data->protocol = (int)obs_data_get_int(settings, "protocol");
+ data->port = (int)obs_data_get_int(settings, "port");
+
+ safe_bfree(&data->stream_key);
+ data->stream_key =
+ bstrdup(obs_data_get_string(settings, "stream_key"));
+
+ safe_bfree(&data->srt_passphrase);
+ data->srt_passphrase =
+ bstrdup(obs_data_get_string(settings, "srt_passphrase"));
+
+ safe_bfree(&data->srt_streamid);
+ data->srt_streamid =
+ bstrdup(obs_data_get_string(settings, "srt_streamid"));
+
+ data->srt_latency_ms =
+ (int)obs_data_get_int(settings, "srt_latency");
+
+ data->disconnect_timeout_sec =
+ (int)obs_data_get_int(settings, "disconnect_timeout");
+
+ safe_bfree(&data->disconnect_scene_name);
+ data->disconnect_scene_name =
+ bstrdup(obs_data_get_string(settings, "disconnect_scene"));
+
+ safe_bfree(&data->reconnect_scene_name);
+ data->reconnect_scene_name =
+ bstrdup(obs_data_get_string(settings, "reconnect_scene"));
+
+ safe_bfree(&data->overlay_source_name);
+ data->overlay_source_name =
+ bstrdup(obs_data_get_string(settings, "overlay_source"));
+
+ data->disconnect_recording_action =
+ (int)obs_data_get_int(settings, "recording_action");
+
+ data->low_quality_enabled =
+ obs_data_get_bool(settings, "low_quality_enabled");
+ data->low_quality_bitrate_kbps =
+ (int)obs_data_get_int(settings, "low_quality_bitrate");
+ data->low_quality_timeout_sec =
+ (int)obs_data_get_int(settings, "low_quality_timeout");
+
+ safe_bfree(&data->low_quality_scene_name);
+ data->low_quality_scene_name =
+ bstrdup(obs_data_get_string(settings, "low_quality_scene"));
+
+ safe_bfree(&data->low_quality_overlay_name);
+ data->low_quality_overlay_name =
+ bstrdup(obs_data_get_string(settings, "low_quality_overlay"));
+
+ data->srtla_enabled =
+ obs_data_get_bool(settings, "srtla_enabled");
+ data->srtla_port =
+ (int)obs_data_get_int(settings, "srtla_port");
+
+ safe_bfree(&data->duckdns_domain);
+ data->duckdns_domain =
+ bstrdup(obs_data_get_string(settings, "duckdns_domain"));
+
+ safe_bfree(&data->duckdns_token);
+ data->duckdns_token =
+ bstrdup(obs_data_get_string(settings, "duckdns_token"));
+
+ safe_bfree(&data->webhook_url);
+ data->webhook_url =
+ bstrdup(obs_data_get_string(settings, "webhook_url"));
+
+ safe_bfree(&data->custom_command);
+ data->custom_command =
+ bstrdup(obs_data_get_string(settings, "custom_command"));
+
+ pthread_mutex_unlock(&data->mutex);
+}
+
+/* ---- source callbacks ---- */
+
+static const char *irl_source_get_name(void *unused)
+{
+ UNUSED_PARAMETER(unused);
+ return tr_source_name();
+}
+
+static void *irl_source_create(obs_data_t *settings, obs_source_t *source)
+{
+ struct irl_source_data *data = bzalloc(sizeof(*data));
+ data->source = source;
+ data->video_stream_idx = -1;
+ data->audio_stream_idx = -1;
+ data->active = true;
+
+ pthread_mutex_init(&data->mutex, NULL);
+
+ load_settings(data, settings);
+ ingest_thread_start(data);
+
+ if (g_irl_source_count < MAX_IRL_SOURCES)
+ g_irl_sources[g_irl_source_count++] = data;
+
+ remote_settings_start(data);
+
+ return data;
+}
+
+static void irl_source_destroy(void *vdata)
+{
+ struct irl_source_data *data = vdata;
+
+ remote_settings_stop(data);
+
+ for (int i = 0; i < g_irl_source_count; i++) {
+ if (g_irl_sources[i] == data) {
+ g_irl_sources[i] =
+ g_irl_sources[--g_irl_source_count];
+ g_irl_sources[g_irl_source_count] = NULL;
+ break;
+ }
+ }
+
+ data->active = false;
+ ingest_thread_stop(data);
+
+ obs_source_output_video(data->source, NULL);
+
+ safe_bfree(&data->stream_key);
+ safe_bfree(&data->srt_passphrase);
+ safe_bfree(&data->srt_streamid);
+ safe_bfree(&data->disconnect_scene_name);
+ safe_bfree(&data->reconnect_scene_name);
+ safe_bfree(&data->overlay_source_name);
+ safe_bfree(&data->low_quality_scene_name);
+ safe_bfree(&data->low_quality_overlay_name);
+ safe_bfree(&data->duckdns_domain);
+ safe_bfree(&data->duckdns_token);
+ safe_bfree(&data->webhook_url);
+ safe_bfree(&data->custom_command);
+
+ pthread_mutex_destroy(&data->mutex);
+ bfree(data);
+}
+
+static void irl_source_update(void *vdata, obs_data_t *settings)
+{
+ struct irl_source_data *data = vdata;
+ UNUSED_PARAMETER(settings);
+ UNUSED_PARAMETER(data);
+}
+
+static void irl_source_get_defaults(obs_data_t *settings)
+{
+ obs_data_set_default_string(settings, "api_token", "");
+
+ obs_data_set_default_int(settings, "protocol", PROTOCOL_RTMP);
+ obs_data_set_default_int(settings, "port", 1935);
+ obs_data_set_default_string(settings, "stream_key", "stream");
+ obs_data_set_default_string(settings, "srt_streamid", "stream");
+ obs_data_set_default_int(settings, "srt_latency", 200);
+ obs_data_set_default_int(settings, "disconnect_timeout", 5);
+ obs_data_set_default_int(settings, "recording_action",
+ RECORDING_ACTION_NONE);
+ obs_data_set_default_bool(settings, "low_quality_enabled", false);
+ obs_data_set_default_int(settings, "low_quality_bitrate", 500);
+ obs_data_set_default_int(settings, "low_quality_timeout", 3);
+ obs_data_set_default_bool(settings, "srtla_enabled", false);
+ obs_data_set_default_int(settings, "srtla_port", 5000);
+}
+
+
+static bool login_button_clicked(obs_properties_t *props, obs_property_t *prop,
+ void *data)
+{
+ UNUSED_PARAMETER(props);
+ UNUSED_PARAMETER(prop);
+ UNUSED_PARAMETER(data);
+
+ char url[256];
+ snprintf(url, sizeof(url), "%s%s%s",
+ obf_https_prefix(), obf_stools_host(),
+ obf_dash_tools_path());
+
+#ifdef _WIN32
+ ShellExecuteA(NULL, "open", url, NULL, NULL, SW_SHOWNORMAL);
+#elif __APPLE__
+ char cmd[512];
+ snprintf(cmd, sizeof(cmd), "open \"%s\"", url);
+ system(cmd);
+#else
+ char cmd[512];
+ snprintf(cmd, sizeof(cmd), "xdg-open \"%s\"", url);
+ system(cmd);
+#endif
+
+ return false;
+}
+
+static obs_properties_t *irl_source_get_properties(void *vdata)
+{
+ UNUSED_PARAMETER(vdata);
+
+ obs_properties_t *props = obs_properties_create();
+
+ obs_properties_add_text(props, "api_token",
+ tr_api_token(),
+ OBS_TEXT_PASSWORD);
+
+ obs_properties_add_button(props, "login_button",
+ tr_login_button(),
+ login_button_clicked);
+
+ obs_properties_add_text(props, "api_info",
+ tr_api_info(),
+ OBS_TEXT_INFO);
+
+ return props;
+}
+
+static void irl_source_video_tick(void *vdata, float seconds)
+{
+ UNUSED_PARAMETER(seconds);
+ struct irl_source_data *data = vdata;
+ event_handler_tick(data);
+}
+
+/* ---- source info ---- */
+
+struct obs_source_info irl_source_info = {
+ .id = SOURCE_ID,
+ .type = OBS_SOURCE_TYPE_INPUT,
+ .output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO |
+ OBS_SOURCE_DO_NOT_DUPLICATE,
+ .get_name = irl_source_get_name,
+ .create = irl_source_create,
+ .destroy = irl_source_destroy,
+ .update = irl_source_update,
+ .get_defaults = irl_source_get_defaults,
+ .get_properties = irl_source_get_properties,
+ .video_tick = irl_source_video_tick,
+ .icon_type = OBS_ICON_TYPE_CAMERA,
+};
diff --git a/src/irl-source.h b/src/irl-source.h
new file mode 100644
index 0000000..181d3c6
--- /dev/null
+++ b/src/irl-source.h
@@ -0,0 +1,136 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "srtla-server.h"
+
+#define PLUGIN_NAME "Easy IRL Stream"
+#define SOURCE_ID "easy_irl_stream_source"
+
+/* IP detection globals (filled by plugin-main.c on startup) */
+extern char g_local_ip[64];
+extern char g_external_ip[64];
+
+#define PROTOCOL_RTMP 0
+#define PROTOCOL_SRT 1
+
+#define RECORDING_ACTION_NONE 0
+#define RECORDING_ACTION_START 1
+#define RECORDING_ACTION_STOP 2
+
+enum connection_state {
+ CONN_STATE_IDLE,
+ CONN_STATE_LISTENING,
+ CONN_STATE_CONNECTED,
+ CONN_STATE_DISCONNECTED,
+};
+
+struct irl_source_data {
+ obs_source_t *source;
+
+ /* Settings */
+ int protocol;
+ int port;
+ char *stream_key;
+ char *srt_passphrase;
+ char *srt_streamid;
+ int srt_latency_ms;
+
+ /* Ingest thread */
+ pthread_t ingest_thread;
+ volatile bool active;
+ bool thread_created;
+
+ /* Connection state */
+ volatile long connection_state;
+ uint64_t last_frame_time_ns;
+
+ /* FFmpeg decoder context (owned by ingest thread) */
+ AVFormatContext *fmt_ctx;
+ AVCodecContext *video_dec_ctx;
+ AVCodecContext *audio_dec_ctx;
+ int video_stream_idx;
+ int audio_stream_idx;
+
+ /* Pixel-format conversion */
+ struct SwsContext *sws_ctx;
+ int sws_width;
+ int sws_height;
+ enum AVPixelFormat sws_src_fmt;
+ uint8_t *video_dst_data[4];
+ int video_dst_linesize[4];
+
+ /* Audio resampler */
+ struct SwrContext *swr_ctx;
+ int swr_sample_rate;
+ int swr_channels;
+
+ /* Bitrate tracking (written by ingest thread, read by tick) */
+ volatile long bytes_window;
+ uint64_t last_bitrate_check_ns;
+ int64_t current_bitrate_kbps;
+
+ /* Event handler settings: disconnect */
+ int disconnect_timeout_sec;
+ char *disconnect_scene_name;
+ char *reconnect_scene_name;
+ char *overlay_source_name;
+ int disconnect_recording_action;
+ bool disconnect_actions_fired;
+ uint64_t disconnect_time_ns;
+
+ /* Event handler settings: low quality */
+ bool low_quality_enabled;
+ int low_quality_bitrate_kbps;
+ int low_quality_timeout_sec;
+ char *low_quality_scene_name;
+ char *low_quality_overlay_name;
+ bool low_quality_active;
+ bool low_quality_actions_fired;
+ uint64_t low_quality_start_ns;
+
+ /* SRTLA */
+ bool srtla_enabled;
+ int srtla_port;
+ struct srtla_state srtla;
+
+ /* DuckDNS */
+ char *duckdns_domain;
+ char *duckdns_token;
+
+ /* Webhook / custom command */
+ char *webhook_url;
+ char *custom_command;
+
+ /* Stats (written by ingest thread, read by UI) */
+ char stats_video_codec[32];
+ char stats_audio_codec[32];
+ char stats_video_pixfmt[32];
+ int stats_video_width;
+ int stats_video_height;
+ int stats_audio_sample_rate;
+ uint64_t stats_connect_time_ns;
+ int64_t stats_total_frames;
+ uint64_t stats_total_bytes;
+
+ pthread_mutex_t mutex;
+};
+
+#define MAX_IRL_SOURCES 8
+extern struct irl_source_data *g_irl_sources[MAX_IRL_SOURCES];
+extern int g_irl_source_count;
+
+extern struct obs_source_info irl_source_info;
diff --git a/src/media-decoder.c b/src/media-decoder.c
new file mode 100644
index 0000000..eff5e51
--- /dev/null
+++ b/src/media-decoder.c
@@ -0,0 +1,364 @@
+#include "media-decoder.h"
+
+bool decoder_open(struct irl_source_data *data)
+{
+ data->video_stream_idx = -1;
+ data->audio_stream_idx = -1;
+
+ for (unsigned i = 0; i < data->fmt_ctx->nb_streams; i++) {
+ AVCodecParameters *par = data->fmt_ctx->streams[i]->codecpar;
+
+ if (par->codec_type == AVMEDIA_TYPE_VIDEO &&
+ data->video_stream_idx < 0) {
+ const AVCodec *codec =
+ avcodec_find_decoder(par->codec_id);
+ if (!codec)
+ continue;
+
+ AVCodecContext *ctx = avcodec_alloc_context3(codec);
+ if (!ctx)
+ continue;
+
+ avcodec_parameters_to_context(ctx, par);
+ ctx->thread_count = 2;
+
+ if (avcodec_open2(ctx, codec, NULL) < 0) {
+ avcodec_free_context(&ctx);
+ continue;
+ }
+
+ data->video_dec_ctx = ctx;
+ data->video_stream_idx = (int)i;
+ snprintf(data->stats_video_codec,
+ sizeof(data->stats_video_codec), "%s",
+ codec->name);
+ data->stats_video_width = par->width;
+ data->stats_video_height = par->height;
+ data->stats_video_pixfmt[0] = '\0';
+ blog(LOG_DEBUG,
+ "[%s] Video stream #%u: %s %dx%d",
+ PLUGIN_NAME, i, codec->name, par->width,
+ par->height);
+ } else if (par->codec_type == AVMEDIA_TYPE_AUDIO &&
+ data->audio_stream_idx < 0) {
+ const AVCodec *codec =
+ avcodec_find_decoder(par->codec_id);
+ if (!codec)
+ continue;
+
+ AVCodecContext *ctx = avcodec_alloc_context3(codec);
+ if (!ctx)
+ continue;
+
+ avcodec_parameters_to_context(ctx, par);
+
+ if (avcodec_open2(ctx, codec, NULL) < 0) {
+ avcodec_free_context(&ctx);
+ continue;
+ }
+
+ data->audio_dec_ctx = ctx;
+ data->audio_stream_idx = (int)i;
+ snprintf(data->stats_audio_codec,
+ sizeof(data->stats_audio_codec), "%s",
+ codec->name);
+ data->stats_audio_sample_rate = par->sample_rate;
+ blog(LOG_DEBUG,
+ "[%s] Audio stream #%u: %s %dHz",
+ PLUGIN_NAME, i, codec->name,
+ par->sample_rate);
+ }
+ }
+
+ if (data->video_stream_idx < 0) {
+ blog(LOG_WARNING, "[%s] No video stream found", PLUGIN_NAME);
+ return false;
+ }
+
+ return true;
+}
+
+void decoder_close(struct irl_source_data *data)
+{
+ if (data->video_dec_ctx) {
+ avcodec_free_context(&data->video_dec_ctx);
+ data->video_dec_ctx = NULL;
+ }
+ if (data->audio_dec_ctx) {
+ avcodec_free_context(&data->audio_dec_ctx);
+ data->audio_dec_ctx = NULL;
+ }
+ if (data->sws_ctx) {
+ sws_freeContext(data->sws_ctx);
+ data->sws_ctx = NULL;
+ }
+ if (data->swr_ctx) {
+ swr_free(&data->swr_ctx);
+ data->swr_ctx = NULL;
+ }
+ if (data->video_dst_data[0]) {
+ av_freep(&data->video_dst_data[0]);
+ memset(data->video_dst_data, 0, sizeof(data->video_dst_data));
+ memset(data->video_dst_linesize, 0,
+ sizeof(data->video_dst_linesize));
+ }
+
+ data->video_stream_idx = -1;
+ data->audio_stream_idx = -1;
+ data->sws_width = 0;
+ data->sws_height = 0;
+ data->swr_sample_rate = 0;
+ data->swr_channels = 0;
+}
+
+static enum video_format ffmpeg_to_obs_format(enum AVPixelFormat fmt,
+ bool *full_range)
+{
+ *full_range = false;
+ switch (fmt) {
+ case AV_PIX_FMT_YUV420P:
+ return VIDEO_FORMAT_I420;
+ case AV_PIX_FMT_YUVJ420P:
+ *full_range = true;
+ return VIDEO_FORMAT_I420;
+ case AV_PIX_FMT_NV12:
+ return VIDEO_FORMAT_NV12;
+ case AV_PIX_FMT_YUV422P:
+ return VIDEO_FORMAT_I422;
+ case AV_PIX_FMT_YUVJ422P:
+ *full_range = true;
+ return VIDEO_FORMAT_I422;
+ case AV_PIX_FMT_YUV444P:
+ return VIDEO_FORMAT_I444;
+ case AV_PIX_FMT_YUVJ444P:
+ *full_range = true;
+ return VIDEO_FORMAT_I444;
+ case AV_PIX_FMT_UYVY422:
+ return VIDEO_FORMAT_UYVY;
+ case AV_PIX_FMT_YUYV422:
+ return VIDEO_FORMAT_YUY2;
+ case AV_PIX_FMT_RGBA:
+ return VIDEO_FORMAT_RGBA;
+ case AV_PIX_FMT_BGRA:
+ return VIDEO_FORMAT_BGRA;
+ default:
+ return VIDEO_FORMAT_NONE;
+ }
+}
+
+static void output_video_frame(struct irl_source_data *data, AVFrame *frame)
+{
+ int w = frame->width;
+ int h = frame->height;
+ enum AVPixelFormat src_fmt = (enum AVPixelFormat)frame->format;
+ bool full_range = false;
+
+ enum video_format obs_fmt = ffmpeg_to_obs_format(src_fmt, &full_range);
+
+ if (!data->stats_video_pixfmt[0])
+ snprintf(data->stats_video_pixfmt,
+ sizeof(data->stats_video_pixfmt), "%s",
+ av_get_pix_fmt_name(src_fmt));
+
+ if (obs_fmt != VIDEO_FORMAT_NONE) {
+ if (w != data->sws_width || h != data->sws_height ||
+ src_fmt != data->sws_src_fmt) {
+ data->sws_width = w;
+ data->sws_height = h;
+ data->sws_src_fmt = src_fmt;
+ blog(LOG_DEBUG,
+ "[%s] Video: %s %dx%d -> direct output (fmt=%d, full_range=%d)",
+ PLUGIN_NAME,
+ av_get_pix_fmt_name(src_fmt), w, h,
+ obs_fmt, full_range);
+ }
+
+ enum video_colorspace cs = (h >= 720) ? VIDEO_CS_709
+ : VIDEO_CS_601;
+
+ struct obs_source_frame obs_frame = {0};
+ for (int i = 0; i < MAX_AV_PLANES && frame->data[i]; i++) {
+ obs_frame.data[i] = frame->data[i];
+ obs_frame.linesize[i] =
+ (uint32_t)frame->linesize[i];
+ }
+ obs_frame.width = (uint32_t)w;
+ obs_frame.height = (uint32_t)h;
+ obs_frame.format = obs_fmt;
+ obs_frame.full_range = full_range;
+ obs_frame.timestamp = os_gettime_ns();
+
+ video_format_get_parameters_for_format(
+ cs, obs_fmt, full_range, obs_frame.color_matrix,
+ obs_frame.color_range_min,
+ obs_frame.color_range_max);
+
+ obs_source_output_video(data->source, &obs_frame);
+ data->last_frame_time_ns = obs_frame.timestamp;
+ return;
+ }
+
+ if (w != data->sws_width || h != data->sws_height ||
+ src_fmt != data->sws_src_fmt) {
+ sws_freeContext(data->sws_ctx);
+ data->sws_ctx = sws_getContext(w, h, src_fmt, w, h,
+ AV_PIX_FMT_NV12,
+ SWS_BILINEAR, NULL, NULL,
+ NULL);
+
+ av_freep(&data->video_dst_data[0]);
+ memset(data->video_dst_data, 0, sizeof(data->video_dst_data));
+ av_image_alloc(data->video_dst_data, data->video_dst_linesize,
+ w, h, AV_PIX_FMT_NV12, 32);
+
+ data->sws_width = w;
+ data->sws_height = h;
+ data->sws_src_fmt = src_fmt;
+
+ blog(LOG_DEBUG,
+ "[%s] Video: %s %dx%d -> NV12 sws conversion",
+ PLUGIN_NAME,
+ av_get_pix_fmt_name(src_fmt), w, h);
+ }
+
+ if (!data->sws_ctx || !data->video_dst_data[0])
+ return;
+
+ sws_scale(data->sws_ctx, (const uint8_t *const *)frame->data,
+ frame->linesize, 0, h, data->video_dst_data,
+ data->video_dst_linesize);
+
+ enum video_colorspace cs = (h >= 720) ? VIDEO_CS_709 : VIDEO_CS_601;
+
+ struct obs_source_frame obs_frame = {0};
+ obs_frame.data[0] = data->video_dst_data[0];
+ obs_frame.data[1] = data->video_dst_data[1];
+ obs_frame.linesize[0] = (uint32_t)data->video_dst_linesize[0];
+ obs_frame.linesize[1] = (uint32_t)data->video_dst_linesize[1];
+ obs_frame.width = (uint32_t)w;
+ obs_frame.height = (uint32_t)h;
+ obs_frame.format = VIDEO_FORMAT_NV12;
+ obs_frame.timestamp = os_gettime_ns();
+
+ video_format_get_parameters_for_format(
+ cs, VIDEO_FORMAT_NV12, false, obs_frame.color_matrix,
+ obs_frame.color_range_min, obs_frame.color_range_max);
+
+ obs_source_output_video(data->source, &obs_frame);
+ data->last_frame_time_ns = obs_frame.timestamp;
+}
+
+static void output_audio_frame(struct irl_source_data *data, AVFrame *frame)
+{
+ int in_rate = frame->sample_rate;
+ int in_ch = frame->ch_layout.nb_channels;
+
+ if (!data->swr_ctx || in_rate != data->swr_sample_rate ||
+ in_ch != data->swr_channels) {
+ swr_free(&data->swr_ctx);
+
+ AVChannelLayout out_layout = AV_CHANNEL_LAYOUT_STEREO;
+
+ int ret = swr_alloc_set_opts2(
+ &data->swr_ctx, &out_layout, AV_SAMPLE_FMT_FLTP,
+ 48000, &frame->ch_layout,
+ (enum AVSampleFormat)frame->format, in_rate, 0, NULL);
+ if (ret < 0 || swr_init(data->swr_ctx) < 0) {
+ swr_free(&data->swr_ctx);
+ return;
+ }
+
+ data->swr_sample_rate = in_rate;
+ data->swr_channels = in_ch;
+ }
+
+ int out_samples =
+ swr_get_out_samples(data->swr_ctx, frame->nb_samples);
+ if (out_samples <= 0)
+ return;
+
+ uint8_t *out_buf[2] = {NULL, NULL};
+ av_samples_alloc(out_buf, NULL, 2, out_samples, AV_SAMPLE_FMT_FLTP, 0);
+
+ out_samples = swr_convert(data->swr_ctx, out_buf, out_samples,
+ (const uint8_t **)frame->extended_data,
+ frame->nb_samples);
+ if (out_samples <= 0) {
+ av_freep(&out_buf[0]);
+ return;
+ }
+
+ struct obs_source_audio obs_audio = {0};
+ obs_audio.data[0] = out_buf[0];
+ obs_audio.data[1] = out_buf[1];
+ obs_audio.frames = (uint32_t)out_samples;
+ obs_audio.speakers = SPEAKERS_STEREO;
+ obs_audio.format = AUDIO_FORMAT_FLOAT_PLANAR;
+ obs_audio.samples_per_sec = 48000;
+ obs_audio.timestamp = os_gettime_ns();
+
+ obs_source_output_audio(data->source, &obs_audio);
+
+ av_freep(&out_buf[0]);
+}
+
+bool decoder_decode_packet(struct irl_source_data *data, AVPacket *pkt)
+{
+ static int vid_pkt_count = 0;
+ static int vid_frame_count = 0;
+
+ if (pkt->stream_index == data->video_stream_idx &&
+ data->video_dec_ctx) {
+ int send_ret =
+ avcodec_send_packet(data->video_dec_ctx, pkt);
+ vid_pkt_count++;
+
+ if (send_ret < 0) {
+ if (vid_pkt_count <= 5)
+ blog(LOG_WARNING,
+ "[%s] avcodec_send_packet failed: %d (pkt #%d, size=%d)",
+ PLUGIN_NAME, send_ret,
+ vid_pkt_count, pkt->size);
+ return true;
+ }
+
+ AVFrame *frame = av_frame_alloc();
+ while (avcodec_receive_frame(data->video_dec_ctx, frame) == 0) {
+ vid_frame_count++;
+ if (vid_frame_count <= 3 ||
+ (vid_frame_count % 300 == 0))
+ blog(LOG_DEBUG,
+ "[%s] Video frame #%d decoded (fmt=%d %dx%d)",
+ PLUGIN_NAME, vid_frame_count,
+ frame->format,
+ frame->width, frame->height);
+ output_video_frame(data, frame);
+ data->stats_total_frames++;
+ av_frame_unref(frame);
+ }
+ av_frame_free(&frame);
+
+ if (vid_pkt_count == 30 && vid_frame_count == 0)
+ blog(LOG_WARNING,
+ "[%s] 30 video packets sent but 0 frames decoded",
+ PLUGIN_NAME);
+
+ return true;
+
+ } else if (pkt->stream_index == data->audio_stream_idx &&
+ data->audio_dec_ctx) {
+ if (avcodec_send_packet(data->audio_dec_ctx, pkt) < 0)
+ return true;
+
+ AVFrame *frame = av_frame_alloc();
+ while (avcodec_receive_frame(data->audio_dec_ctx, frame) ==
+ 0) {
+ output_audio_frame(data, frame);
+ av_frame_unref(frame);
+ }
+ av_frame_free(&frame);
+ return true;
+ }
+
+ return false;
+}
diff --git a/src/media-decoder.h b/src/media-decoder.h
new file mode 100644
index 0000000..9071282
--- /dev/null
+++ b/src/media-decoder.h
@@ -0,0 +1,7 @@
+#pragma once
+
+#include "irl-source.h"
+
+bool decoder_open(struct irl_source_data *data);
+void decoder_close(struct irl_source_data *data);
+bool decoder_decode_packet(struct irl_source_data *data, AVPacket *pkt);
diff --git a/src/obfuscation.cpp b/src/obfuscation.cpp
new file mode 100644
index 0000000..9cf343f
--- /dev/null
+++ b/src/obfuscation.cpp
@@ -0,0 +1,46 @@
+#include "obfuscation.h"
+
+static constexpr char K = 0x5A;
+
+template
+struct XorStr {
+ char data[N];
+ constexpr XorStr(const char (&s)[N]) : data{}
+ {
+ for (unsigned i = 0; i < N; i++)
+ data[i] = s[i] ^ K;
+ }
+};
+
+template
+static void xor_dec(char *out, const XorStr &x)
+{
+ for (unsigned i = 0; i < N; i++)
+ out[i] = x.data[i] ^ K;
+}
+
+#define OBF_FUNC(fn, literal) \
+ static constexpr XorStr _enc_##fn(literal); \
+ extern "C" const char *fn(void) \
+ { \
+ static char buf[sizeof(literal)]; \
+ static int ready; \
+ if (!ready) { \
+ xor_dec(buf, _enc_##fn); \
+ ready = 1; \
+ } \
+ return buf; \
+ }
+
+OBF_FUNC(obf_stools_host, "stools.cc")
+OBF_FUNC(obf_api_settings_path, "/api/plugin/settings")
+OBF_FUNC(obf_api_obs_info_path, "/api/plugin/obs-info")
+OBF_FUNC(obf_api_version_path, "/api/plugin/version")
+OBF_FUNC(obf_dash_tools_path, "/dashboard/tools")
+OBF_FUNC(obf_dash_downloads_path, "/dashboard/downloads")
+OBF_FUNC(obf_ipify_host, "api.ipify.org")
+OBF_FUNC(obf_duckdns_host, "www.duckdns.org")
+OBF_FUNC(obf_ua_prefix, "easy-irl-stream/")
+OBF_FUNC(obf_https_prefix, "https://")
+OBF_FUNC(obf_duckdns_update_fmt, "/update?domains=%s&token=%s&verbose=true")
+OBF_FUNC(obf_auth_bearer_fmt, "Authorization: Bearer %s")
diff --git a/src/obfuscation.h b/src/obfuscation.h
new file mode 100644
index 0000000..ce64a20
--- /dev/null
+++ b/src/obfuscation.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include
+
+/* Simple XOR encode/decode — symmetric operation */
+static inline void xor_crypt(char *buf, const char *src, size_t len)
+{
+ for (size_t i = 0; i < len; i++)
+ buf[i] = src[i] ^ 0x5A;
+ buf[len] = '\0';
+}
+
+/* Obfuscated string accessors (implemented in obfuscation.cpp) */
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+const char *obf_stools_host(void);
+const char *obf_api_settings_path(void);
+const char *obf_api_obs_info_path(void);
+const char *obf_api_version_path(void);
+const char *obf_dash_tools_path(void);
+const char *obf_dash_downloads_path(void);
+const char *obf_ipify_host(void);
+const char *obf_duckdns_host(void);
+const char *obf_ua_prefix(void);
+const char *obf_https_prefix(void);
+const char *obf_duckdns_update_fmt(void);
+const char *obf_auth_bearer_fmt(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/plugin-main.c b/src/plugin-main.c
new file mode 100644
index 0000000..72c57bb
--- /dev/null
+++ b/src/plugin-main.c
@@ -0,0 +1,423 @@
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#include
+#include
+#include
+#else
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#define closesocket close
+#define SOCKET int
+#define INVALID_SOCKET (-1)
+#endif
+
+#include "irl-source.h"
+#include "obfuscation.h"
+#include "translations.h"
+
+OBS_DECLARE_MODULE()
+
+/* ---- IP detection (global) ---- */
+
+char g_local_ip[64] = "";
+char g_external_ip[64] = "";
+
+static void detect_local_ip(void)
+{
+#ifdef _WIN32
+ WSADATA wsa;
+ WSAStartup(MAKEWORD(2, 2), &wsa);
+
+ ULONG buf_size = 15000;
+ PIP_ADAPTER_ADDRESSES addrs = (PIP_ADAPTER_ADDRESSES)malloc(buf_size);
+ if (!addrs) {
+ snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?");
+ return;
+ }
+
+ ULONG flags = GAA_FLAG_INCLUDE_GATEWAYS;
+ ULONG ret = GetAdaptersAddresses(AF_INET, flags, NULL, addrs,
+ &buf_size);
+ if (ret == ERROR_BUFFER_OVERFLOW) {
+ free(addrs);
+ addrs = (PIP_ADAPTER_ADDRESSES)malloc(buf_size);
+ if (!addrs) {
+ snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?");
+ return;
+ }
+ ret = GetAdaptersAddresses(AF_INET, flags, NULL, addrs,
+ &buf_size);
+ }
+ if (ret != NO_ERROR) {
+ free(addrs);
+ snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?");
+ return;
+ }
+
+ for (PIP_ADAPTER_ADDRESSES a = addrs; a; a = a->Next) {
+ if (a->OperStatus != IfOperStatusUp)
+ continue;
+ if (!a->FirstGatewayAddress)
+ continue;
+ if (a->IfType != IF_TYPE_ETHERNET_CSMACD &&
+ a->IfType != IF_TYPE_IEEE80211)
+ continue;
+
+ PIP_ADAPTER_UNICAST_ADDRESS ua = a->FirstUnicastAddress;
+ for (; ua; ua = ua->Next) {
+ struct sockaddr_in *sa =
+ (struct sockaddr_in *)ua->Address.lpSockaddr;
+ if (sa->sin_family == AF_INET) {
+ inet_ntop(AF_INET, &sa->sin_addr, g_local_ip,
+ sizeof(g_local_ip));
+ free(addrs);
+ return;
+ }
+ }
+ }
+ free(addrs);
+ snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?");
+
+#else
+ struct ifaddrs *ifas, *ifa;
+ if (getifaddrs(&ifas) == -1) {
+ snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?");
+ return;
+ }
+ for (ifa = ifas; ifa; ifa = ifa->ifa_next) {
+ if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET)
+ continue;
+ if (ifa->ifa_flags & IFF_LOOPBACK)
+ continue;
+ if (!(ifa->ifa_flags & IFF_UP))
+ continue;
+ struct sockaddr_in *sa =
+ (struct sockaddr_in *)ifa->ifa_addr;
+ inet_ntop(AF_INET, &sa->sin_addr, g_local_ip,
+ sizeof(g_local_ip));
+ break;
+ }
+ freeifaddrs(ifas);
+ if (!g_local_ip[0])
+ snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?");
+#endif
+}
+
+static void http_get_body(const char *host, const char *path, char *out,
+ size_t out_sz)
+{
+ struct addrinfo hints = {0}, *res = NULL;
+ hints.ai_family = AF_INET;
+ hints.ai_socktype = SOCK_STREAM;
+
+ if (getaddrinfo(host, "80", &hints, &res) != 0) {
+ snprintf(out, out_sz, "?");
+ return;
+ }
+
+ SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
+ if (s == INVALID_SOCKET) {
+ freeaddrinfo(res);
+ snprintf(out, out_sz, "?");
+ return;
+ }
+
+ if (connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) {
+ freeaddrinfo(res);
+ closesocket(s);
+ snprintf(out, out_sz, "?");
+ return;
+ }
+ freeaddrinfo(res);
+
+ char req[512];
+ snprintf(req, sizeof(req),
+ "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n",
+ path, host);
+ send(s, req, (int)strlen(req), 0);
+
+ char response[2048] = {0};
+ int total = 0, n;
+ while ((n = recv(s, response + total,
+ (int)(sizeof(response) - total - 1), 0)) > 0)
+ total += n;
+ response[total] = '\0';
+ closesocket(s);
+
+ char *body = strstr(response, "\r\n\r\n");
+ if (body) {
+ body += 4;
+ while (*body == ' ' || *body == '\r' || *body == '\n')
+ body++;
+ char *end = body;
+ while (*end && *end != '\r' && *end != '\n' && *end != ' ')
+ end++;
+ *end = '\0';
+ snprintf(out, out_sz, "%s", body);
+ } else {
+ snprintf(out, out_sz, "?");
+ }
+}
+
+/* ---- DuckDNS update ---- */
+
+void duckdns_update(const char *domain, const char *token)
+{
+ if (!domain || !domain[0] || !token || !token[0])
+ return;
+
+ char path[512];
+ snprintf(path, sizeof(path), obf_duckdns_update_fmt(), domain, token);
+
+ char result[256] = {0};
+ http_get_body(obf_duckdns_host(), path, result, sizeof(result));
+
+ if (strncmp(result, "OK", 2) == 0 || strncmp(result, "KO", 2) == 0) {
+ blog(LOG_DEBUG, "[%s] DuckDNS update for %s.duckdns.org: %s",
+ PLUGIN_NAME, domain, result);
+ } else {
+ blog(LOG_WARNING, "[%s] DuckDNS update failed: %s",
+ PLUGIN_NAME, result);
+ }
+}
+
+/* ---- IP monitoring ---- */
+
+static volatile bool g_ip_thread_active = false;
+static pthread_t g_ip_thread;
+
+static void trigger_duckdns_update(void)
+{
+ for (int i = 0; i < g_irl_source_count && i < MAX_IRL_SOURCES; i++) {
+ struct irl_source_data *d = g_irl_sources[i];
+ if (!d) continue;
+
+ pthread_mutex_lock(&d->mutex);
+ bool has_dns = d->duckdns_domain && d->duckdns_domain[0] &&
+ d->duckdns_token && d->duckdns_token[0];
+ char *dd = has_dns ? bstrdup(d->duckdns_domain) : NULL;
+ char *dt = has_dns ? bstrdup(d->duckdns_token) : NULL;
+ pthread_mutex_unlock(&d->mutex);
+
+ if (dd && dt) {
+ duckdns_update(dd, dt);
+ bfree(dd);
+ bfree(dt);
+ break;
+ }
+ bfree(dd);
+ bfree(dt);
+ }
+}
+
+#define IP_CHECK_INTERVAL_SEC 60
+
+static void *ip_detect_thread(void *arg)
+{
+ UNUSED_PARAMETER(arg);
+
+ detect_local_ip();
+ http_get_body(obf_ipify_host(), "/", g_external_ip,
+ sizeof(g_external_ip));
+ blog(LOG_DEBUG, "[%s] Local IP: %s, External IP: %s", PLUGIN_NAME,
+ g_local_ip, g_external_ip);
+
+ while (g_ip_thread_active) {
+ for (int i = 0; i < IP_CHECK_INTERVAL_SEC * 10 &&
+ g_ip_thread_active; i++)
+ os_sleep_ms(100);
+
+ if (!g_ip_thread_active)
+ break;
+
+ char new_ip[64] = {0};
+ http_get_body(obf_ipify_host(), "/", new_ip, sizeof(new_ip));
+
+ if (new_ip[0] && strcmp(new_ip, "?") != 0 &&
+ strcmp(new_ip, g_external_ip) != 0) {
+ blog(LOG_DEBUG,
+ "[%s] External IP changed: %s -> %s",
+ PLUGIN_NAME, g_external_ip, new_ip);
+ snprintf(g_external_ip, sizeof(g_external_ip),
+ "%s", new_ip);
+ trigger_duckdns_update();
+ }
+ }
+
+ return NULL;
+}
+
+/* ---- Update check ---- */
+
+struct update_mem_buf {
+ char *data;
+ size_t size;
+};
+
+static size_t update_write_cb(void *contents, size_t size, size_t nmemb,
+ void *userp)
+{
+ size_t total = size * nmemb;
+ struct update_mem_buf *buf = (struct update_mem_buf *)userp;
+ char *tmp = realloc(buf->data, buf->size + total + 1);
+ if (!tmp) return 0;
+ buf->data = tmp;
+ memcpy(buf->data + buf->size, contents, total);
+ buf->size += total;
+ buf->data[buf->size] = '\0';
+ return total;
+}
+
+static int compare_versions(const char *a, const char *b)
+{
+ int a1 = 0, a2 = 0, a3 = 0, b1 = 0, b2 = 0, b3 = 0;
+ sscanf(a, "%d.%d.%d", &a1, &a2, &a3);
+ sscanf(b, "%d.%d.%d", &b1, &b2, &b3);
+ if (a1 != b1) return a1 - b1;
+ if (a2 != b2) return a2 - b2;
+ return a3 - b3;
+}
+
+struct update_ctx {
+ char version[64];
+};
+
+#include "help-dialog.hpp"
+#include "stats-dialog.hpp"
+
+static void task_show_update_dialog(void *param)
+{
+ 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(),
+ obf_api_version_path());
+
+ char ua[128];
+ snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION);
+
+ CURL *curl = curl_easy_init();
+ if (!curl) return NULL;
+
+ 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_USERAGENT, ua);
+
+ CURLcode res = curl_easy_perform(curl);
+ long http_code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+ curl_easy_cleanup(curl);
+
+ if (res != CURLE_OK || http_code != 200 || !buf.data) {
+ free(buf.data);
+ return NULL;
+ }
+
+ /* Parse {"version":"x.y.z"} */
+ const char *vkey = strstr(buf.data, "\"version\"");
+ if (!vkey) { free(buf.data); return NULL; }
+ const char *vstart = strchr(vkey + 9, '"');
+ if (!vstart) { free(buf.data); return NULL; }
+ vstart++;
+ const char *vend = strchr(vstart, '"');
+ if (!vend || vend - vstart > 60) { free(buf.data); return NULL; }
+
+ char remote_ver[64];
+ size_t vlen = (size_t)(vend - vstart);
+ memcpy(remote_ver, vstart, vlen);
+ remote_ver[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);
+
+ 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;
+}
+
+/* ---- Tools menu ---- */
+
+static void tools_menu_cb(void *private_data)
+{
+ UNUSED_PARAMETER(private_data);
+ help_dialog_show(g_local_ip, g_external_ip, PLUGIN_VERSION,
+ obs_get_locale());
+}
+
+static void tools_stats_cb(void *private_data)
+{
+ UNUSED_PARAMETER(private_data);
+ stats_dialog_show(obs_get_locale());
+}
+
+/* ---- module lifecycle ---- */
+
+bool obs_module_load(void)
+{
+ curl_global_init(CURL_GLOBAL_DEFAULT);
+
+ 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)
+{
+ obs_frontend_add_tools_menu_item(tr_tools_menu_help(),
+ tools_menu_cb, NULL);
+ obs_frontend_add_tools_menu_item(tr_tools_menu_stats(),
+ tools_stats_cb, NULL);
+ blog(LOG_DEBUG, "[%s] Tools menu registered", PLUGIN_NAME);
+}
+
+void obs_module_unload(void)
+{
+ if (g_ip_thread_active) {
+ g_ip_thread_active = false;
+ pthread_join(g_ip_thread, NULL);
+ }
+
+ curl_global_cleanup();
+ blog(LOG_INFO, "[%s] Plugin unloaded", PLUGIN_NAME);
+}
diff --git a/src/remote-settings.c b/src/remote-settings.c
new file mode 100644
index 0000000..6cde2fe
--- /dev/null
+++ b/src/remote-settings.c
@@ -0,0 +1,390 @@
+#include "remote-settings.h"
+#include "ingest-thread.h"
+#include "obfuscation.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+
+/* ---- cURL helpers ---- */
+
+struct mem_buf {
+ char *data;
+ size_t size;
+};
+
+static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp)
+{
+ size_t total = size * nmemb;
+ struct mem_buf *buf = (struct mem_buf *)userp;
+ char *tmp = realloc(buf->data, buf->size + total + 1);
+ if (!tmp) return 0;
+ buf->data = tmp;
+ memcpy(buf->data + buf->size, contents, total);
+ buf->size += total;
+ buf->data[buf->size] = '\0';
+ return total;
+}
+
+static char *api_get(const char *path, const char *token)
+{
+ CURL *curl = curl_easy_init();
+ if (!curl) return NULL;
+
+ char url[512];
+ snprintf(url, sizeof(url), "%s%s%s",
+ obf_https_prefix(), obf_stools_host(), path);
+
+ struct curl_slist *headers = NULL;
+ char auth_header[512];
+ snprintf(auth_header, sizeof(auth_header),
+ obf_auth_bearer_fmt(), token);
+ headers = curl_slist_append(headers, auth_header);
+
+ char ua[128];
+ snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION);
+
+ struct mem_buf buf = {NULL, 0};
+
+ curl_easy_setopt(curl, CURLOPT_URL, url);
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, ua);
+
+ CURLcode res = curl_easy_perform(curl);
+ long http_code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+ curl_slist_free_all(headers);
+ curl_easy_cleanup(curl);
+
+ if (res != CURLE_OK) {
+ blog(LOG_WARNING, "[%s] API GET %s failed: %s",
+ PLUGIN_NAME, path, curl_easy_strerror(res));
+ free(buf.data);
+ return NULL;
+ }
+ if (http_code != 200) {
+ blog(LOG_WARNING, "[%s] API GET %s returned HTTP %ld",
+ PLUGIN_NAME, path, http_code);
+ free(buf.data);
+ return NULL;
+ }
+
+ return buf.data;
+}
+
+static bool api_post(const char *path, const char *token, const char *json_body)
+{
+ CURL *curl = curl_easy_init();
+ if (!curl) return false;
+
+ char url[512];
+ snprintf(url, sizeof(url), "%s%s%s",
+ obf_https_prefix(), obf_stools_host(), path);
+
+ struct curl_slist *headers = NULL;
+ char auth_header[512];
+ snprintf(auth_header, sizeof(auth_header),
+ obf_auth_bearer_fmt(), token);
+ headers = curl_slist_append(headers, auth_header);
+ headers = curl_slist_append(headers, "Content-Type: application/json");
+
+ char ua[128];
+ snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION);
+
+ curl_easy_setopt(curl, CURLOPT_URL, url);
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+ curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, ua);
+
+ CURLcode res = curl_easy_perform(curl);
+ long http_code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+ curl_slist_free_all(headers);
+ curl_easy_cleanup(curl);
+
+ if (res != CURLE_OK) {
+ blog(LOG_WARNING, "[%s] API POST %s failed: %s",
+ PLUGIN_NAME, path, curl_easy_strerror(res));
+ return false;
+ }
+ if (http_code != 200) {
+ blog(LOG_WARNING, "[%s] API POST %s returned HTTP %ld",
+ PLUGIN_NAME, path, http_code);
+ return false;
+ }
+
+ return true;
+}
+
+/* ---- Minimal JSON parser (extract string/int/bool by key) ---- */
+
+static const char *json_find_key(const char *json, const char *key)
+{
+ char search[256];
+ snprintf(search, sizeof(search), "\"%s\"", key);
+ const char *pos = strstr(json, search);
+ if (!pos) return NULL;
+ pos += strlen(search);
+ while (*pos == ' ' || *pos == ':') pos++;
+ return pos;
+}
+
+static int json_get_int(const char *json, const char *key, int def)
+{
+ const char *v = json_find_key(json, key);
+ if (!v) return def;
+ return atoi(v);
+}
+
+static bool json_get_bool(const char *json, const char *key, bool def)
+{
+ const char *v = json_find_key(json, key);
+ if (!v) return def;
+ if (strncmp(v, "true", 4) == 0) return true;
+ if (strncmp(v, "false", 5) == 0) return false;
+ return def;
+}
+
+static char *json_get_string(const char *json, const char *key)
+{
+ const char *v = json_find_key(json, key);
+ if (!v || *v != '"') return bstrdup("");
+ v++;
+ const char *end = strchr(v, '"');
+ if (!end) return bstrdup("");
+ size_t len = (size_t)(end - v);
+ char *result = bmalloc(len + 1);
+ memcpy(result, v, len);
+ result[len] = '\0';
+ return result;
+}
+
+/* ---- Apply remote settings to source ---- */
+
+static void apply_remote_settings(struct irl_source_data *data, const char *json)
+{
+ int old_proto = data->protocol;
+ int old_port = data->port;
+
+ pthread_mutex_lock(&data->mutex);
+ char *old_key = data->stream_key ? bstrdup(data->stream_key) : NULL;
+ char *old_pass = data->srt_passphrase ? bstrdup(data->srt_passphrase) : NULL;
+ int old_lat = data->srt_latency_ms;
+
+ data->protocol = json_get_int(json, "protocol", data->protocol);
+ data->port = json_get_int(json, "port", data->port);
+
+ bfree(data->stream_key);
+ data->stream_key = json_get_string(json, "streamKey");
+
+ bfree(data->srt_passphrase);
+ data->srt_passphrase = json_get_string(json, "srtPassphrase");
+
+ bfree(data->srt_streamid);
+ data->srt_streamid = json_get_string(json, "srtStreamid");
+
+ data->srt_latency_ms = json_get_int(json, "srtLatency", data->srt_latency_ms);
+ data->disconnect_timeout_sec = json_get_int(json, "disconnectTimeout", data->disconnect_timeout_sec);
+
+ bfree(data->disconnect_scene_name);
+ data->disconnect_scene_name = json_get_string(json, "disconnectScene");
+
+ bfree(data->reconnect_scene_name);
+ data->reconnect_scene_name = json_get_string(json, "reconnectScene");
+
+ bfree(data->overlay_source_name);
+ data->overlay_source_name = json_get_string(json, "overlaySource");
+
+ data->disconnect_recording_action = json_get_int(json, "recordingAction", data->disconnect_recording_action);
+ data->low_quality_enabled = json_get_bool(json, "lowQualityEnabled", data->low_quality_enabled);
+ data->low_quality_bitrate_kbps = json_get_int(json, "lowQualityBitrate", data->low_quality_bitrate_kbps);
+ data->low_quality_timeout_sec = json_get_int(json, "lowQualityTimeout", data->low_quality_timeout_sec);
+
+ bfree(data->low_quality_scene_name);
+ data->low_quality_scene_name = json_get_string(json, "lowQualityScene");
+
+ bfree(data->low_quality_overlay_name);
+ data->low_quality_overlay_name = json_get_string(json, "lowQualityOverlay");
+
+ data->srtla_enabled = json_get_bool(json, "srtlaEnabled", data->srtla_enabled);
+ data->srtla_port = json_get_int(json, "srtlaPort", data->srtla_port);
+
+ bfree(data->duckdns_domain);
+ data->duckdns_domain = json_get_string(json, "duckdnsDomain");
+
+ bfree(data->duckdns_token);
+ data->duckdns_token = json_get_string(json, "duckdnsToken");
+
+ bfree(data->webhook_url);
+ data->webhook_url = json_get_string(json, "webhookUrl");
+
+ bfree(data->custom_command);
+ data->custom_command = json_get_string(json, "customCommand");
+
+ /* Check if ingest needs restart */
+ bool need_restart = (data->protocol != old_proto) || (data->port != old_port);
+ if (data->stream_key && old_key && strcmp(data->stream_key, old_key) != 0)
+ need_restart = true;
+ if (data->srt_passphrase && old_pass && strcmp(data->srt_passphrase, old_pass) != 0)
+ need_restart = true;
+ if (data->srt_latency_ms != old_lat)
+ need_restart = true;
+
+ pthread_mutex_unlock(&data->mutex);
+
+ bfree(old_key);
+ bfree(old_pass);
+
+ if (need_restart)
+ ingest_thread_start(data);
+}
+
+/* ---- Background poll thread ---- */
+
+static pthread_t g_settings_thread;
+static volatile bool g_settings_thread_active = false;
+
+static void *settings_poll_thread(void *arg)
+{
+ struct irl_source_data *data = (struct irl_source_data *)arg;
+
+ os_set_thread_name("irl-remote-settings");
+
+ os_sleep_ms(3000);
+
+ while (g_settings_thread_active) {
+ obs_data_t *settings = obs_source_get_settings(data->source);
+ const char *api_token = obs_data_get_string(settings, "api_token");
+ char *token_copy = (api_token && api_token[0]) ? bstrdup(api_token) : NULL;
+ obs_data_release(settings);
+
+ if (token_copy) {
+ char *json = api_get(obf_api_settings_path(), token_copy);
+ bool force_sync = false;
+ if (json) {
+ apply_remote_settings(data, json);
+ force_sync = json_get_bool(json, "requestSync", false);
+ free(json);
+ }
+
+ remote_report_obs_info(token_copy);
+
+ if (force_sync) {
+ os_sleep_ms(2000);
+ remote_report_obs_info(token_copy);
+ }
+
+ bfree(token_copy);
+ }
+
+ for (int i = 0; i < SETTINGS_POLL_INTERVAL_SEC * 10 && g_settings_thread_active; i++)
+ os_sleep_ms(100);
+ }
+
+ return NULL;
+}
+
+/* ---- Public API ---- */
+
+void remote_settings_start(struct irl_source_data *data)
+{
+ if (g_settings_thread_active)
+ return;
+
+ g_settings_thread_active = true;
+ pthread_create(&g_settings_thread, NULL, settings_poll_thread, data);
+}
+
+void remote_settings_stop(struct irl_source_data *data)
+{
+ UNUSED_PARAMETER(data);
+ if (!g_settings_thread_active)
+ return;
+
+ g_settings_thread_active = false;
+ pthread_join(g_settings_thread, NULL);
+}
+
+/* ---- Report OBS scenes/sources ---- */
+
+static void escape_json_string(struct dstr *out, const char *str)
+{
+ dstr_cat(out, "\"");
+ for (const char *p = str; *p; p++) {
+ if (*p == '"') dstr_cat(out, "\\\"");
+ else if (*p == '\\') dstr_cat(out, "\\\\");
+ else if (*p == '\n') dstr_cat(out, "\\n");
+ else { char c[2] = {*p, 0}; dstr_cat(out, c); }
+ }
+ dstr_cat(out, "\"");
+}
+
+struct src_enum_ctx {
+ struct dstr *json;
+ int count;
+};
+
+static bool enum_video_sources_cb(void *param, obs_source_t *source)
+{
+ struct src_enum_ctx *ctx = (struct src_enum_ctx *)param;
+ uint32_t flags = obs_source_get_output_flags(source);
+ if (flags & OBS_SOURCE_VIDEO) {
+ if (ctx->count > 0) dstr_cat(ctx->json, ",");
+ escape_json_string(ctx->json, obs_source_get_name(source));
+ ctx->count++;
+ }
+ return true;
+}
+
+extern char g_local_ip[64];
+extern char g_external_ip[64];
+
+void remote_report_obs_info(const char *api_token)
+{
+ if (!api_token || !api_token[0])
+ return;
+
+ struct dstr json;
+ dstr_init(&json);
+ dstr_cat(&json, "{\"scenes\":[");
+
+ struct obs_frontend_source_list scenes = {0};
+ obs_frontend_get_scenes(&scenes);
+ for (size_t i = 0; i < scenes.sources.num; i++) {
+ if (i > 0) dstr_cat(&json, ",");
+ escape_json_string(&json, obs_source_get_name(scenes.sources.array[i]));
+ }
+ obs_frontend_source_list_free(&scenes);
+
+ dstr_cat(&json, "],\"sources\":[");
+
+ struct src_enum_ctx src_ctx = { &json, 0 };
+
+ obs_enum_sources(enum_video_sources_cb, &src_ctx);
+
+ dstr_cat(&json, "],");
+
+ dstr_cat(&json, "\"localIp\":");
+ escape_json_string(&json, g_local_ip[0] ? g_local_ip : "");
+ dstr_cat(&json, ",\"externalIp\":");
+ escape_json_string(&json, g_external_ip[0] ? g_external_ip : "");
+ dstr_cat(&json, ",\"pluginVersion\":");
+ escape_json_string(&json, PLUGIN_VERSION);
+ dstr_cat(&json, "}");
+
+ api_post(obf_api_obs_info_path(), api_token, json.array);
+
+ dstr_free(&json);
+}
diff --git a/src/remote-settings.h b/src/remote-settings.h
new file mode 100644
index 0000000..66a53b7
--- /dev/null
+++ b/src/remote-settings.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#include "irl-source.h"
+
+/* Polling interval in seconds */
+#define SETTINGS_POLL_INTERVAL_SEC 30
+
+/* Start/stop the background sync thread for a source */
+void remote_settings_start(struct irl_source_data *data);
+void remote_settings_stop(struct irl_source_data *data);
+
+/* Report available OBS scenes and sources to the API */
+void remote_report_obs_info(const char *api_token);
diff --git a/src/srtla-server.c b/src/srtla-server.c
new file mode 100644
index 0000000..378e541
--- /dev/null
+++ b/src/srtla-server.c
@@ -0,0 +1,648 @@
+#include "srtla-server.h"
+#include
+#include
+#include
+#include
+
+#define PLUGIN_NAME "Easy IRL Stream"
+
+#define SRTLA_REG_PKT_SIZE (2 + SRTLA_GROUP_ID_LEN)
+
+static uint16_t srtla_get_type(const uint8_t *buf, int len)
+{
+ if (len < 4)
+ return 0;
+ uint16_t v = ((uint16_t)buf[0] << 8) | buf[1];
+ if (!(v & SRT_CONTROL_BIT))
+ return 0;
+ uint16_t type = v & 0x7FFF;
+ return (type >= 0x1000) ? type : 0;
+}
+
+static bool is_srt_data_packet(const uint8_t *buf, int len)
+{
+ if (len < 4)
+ return false;
+ return (buf[0] & 0x80) == 0;
+}
+
+static uint32_t get_srt_sequence_number(const uint8_t *buf)
+{
+ return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
+ ((uint32_t)buf[2] << 8) | buf[3];
+}
+
+static void srtla_build_header(uint8_t *buf, uint16_t type)
+{
+ uint16_t v = SRT_CONTROL_BIT | type;
+ buf[0] = (uint8_t)(v >> 8);
+ buf[1] = (uint8_t)(v & 0xFF);
+}
+
+static bool sockaddr_equal(const struct sockaddr_storage *a, socklen_t alen,
+ const struct sockaddr_storage *b, socklen_t blen)
+{
+ (void)alen;
+ (void)blen;
+ const struct sockaddr_in *sa = (const struct sockaddr_in *)a;
+ const struct sockaddr_in *sb = (const struct sockaddr_in *)b;
+ if (sa->sin_family != sb->sin_family)
+ return false;
+ if (sa->sin_family == AF_INET)
+ return sa->sin_port == sb->sin_port &&
+ sa->sin_addr.s_addr == sb->sin_addr.s_addr;
+ return false;
+}
+
+static bool sockaddr_same_ip(const struct sockaddr_storage *a,
+ const struct sockaddr_storage *b)
+{
+ const struct sockaddr_in *sa = (const struct sockaddr_in *)a;
+ const struct sockaddr_in *sb = (const struct sockaddr_in *)b;
+ if (sa->sin_family != sb->sin_family)
+ return false;
+ if (sa->sin_family == AF_INET)
+ return sa->sin_addr.s_addr == sb->sin_addr.s_addr;
+ return false;
+}
+
+static struct srtla_group *find_group(struct srtla_state *state,
+ const uint8_t *group_id)
+{
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (state->groups[i].active &&
+ memcmp(state->groups[i].group_id, group_id,
+ SRTLA_GROUP_ID_LEN) == 0)
+ return &state->groups[i];
+ }
+ return NULL;
+}
+
+static struct srtla_group *alloc_group(struct srtla_state *state)
+{
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (!state->groups[i].active)
+ return &state->groups[i];
+ }
+ return NULL;
+}
+
+static SRTLA_SOCKET create_srt_forward_sock(int srt_port)
+{
+ SRTLA_SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+ if (s == SRTLA_INVALID_SOCKET)
+ return SRTLA_INVALID_SOCKET;
+
+ struct sockaddr_in dst = {0};
+ dst.sin_family = AF_INET;
+ dst.sin_port = htons((uint16_t)srt_port);
+ dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+
+ if (connect(s, (struct sockaddr *)&dst, sizeof(dst)) != 0) {
+ closesocket(s);
+ return SRTLA_INVALID_SOCKET;
+ }
+
+#ifdef _WIN32
+ u_long nonblock = 1;
+ ioctlsocket(s, FIONBIO, &nonblock);
+#else
+ {
+ int flags = fcntl(s, F_GETFL, 0);
+ fcntl(s, F_SETFL, flags | O_NONBLOCK);
+ }
+#endif
+
+ return s;
+}
+
+static void group_cleanup(struct srtla_group *g)
+{
+ if (g->srt_sock != SRTLA_INVALID_SOCKET) {
+ closesocket(g->srt_sock);
+ g->srt_sock = SRTLA_INVALID_SOCKET;
+ }
+ memset(g, 0, sizeof(*g));
+ g->srt_sock = SRTLA_INVALID_SOCKET;
+}
+
+static void add_connection_to_group(struct srtla_group *g,
+ const struct sockaddr_storage *from,
+ socklen_t from_len)
+{
+ for (int i = 0; i < g->num_conns; i++) {
+ if (sockaddr_equal(&g->conns[i].addr, g->conns[i].addr_len,
+ from, from_len)) {
+ g->conns[i].last_activity_ns = os_gettime_ns();
+ return;
+ }
+ }
+ if (g->num_conns < SRTLA_MAX_CONNS) {
+ struct srtla_conn *c = &g->conns[g->num_conns++];
+ memcpy(&c->addr, from, from_len);
+ c->addr_len = from_len;
+ c->last_activity_ns = os_gettime_ns();
+ c->active = true;
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: connection %d added to group",
+ PLUGIN_NAME, g->num_conns);
+ }
+}
+
+static void send_srtla_ack(struct srtla_state *state, struct srtla_group *g)
+{
+ if (g->ack_sn_count == 0)
+ return;
+
+ int pkt_len = 4 + g->ack_sn_count * 4;
+ uint8_t pkt[SRTLA_ACK_PKT_LEN];
+ memset(pkt, 0, sizeof(pkt));
+ srtla_build_header(pkt, SRTLA_TYPE_ACK);
+ pkt[2] = 0;
+ pkt[3] = 0;
+
+ for (int i = 0; i < g->ack_sn_count; i++) {
+ int off = 4 + i * 4;
+ pkt[off + 0] = (uint8_t)(g->ack_sns[i] >> 24);
+ pkt[off + 1] = (uint8_t)(g->ack_sns[i] >> 16);
+ pkt[off + 2] = (uint8_t)(g->ack_sns[i] >> 8);
+ pkt[off + 3] = (uint8_t)(g->ack_sns[i]);
+ }
+
+ for (int j = 0; j < g->num_conns; j++) {
+ sendto(state->listen_sock, (const char *)pkt, pkt_len, 0,
+ (const struct sockaddr *)&g->conns[j].addr,
+ g->conns[j].addr_len);
+ }
+
+ g->ack_sn_count = 0;
+}
+
+/*
+ * REG1: Client wants to create a new SRTLA group.
+ * Packet: 2 bytes type + 256 bytes client random.
+ * Response: REG2 with first 128 bytes from client + 128 bytes server random.
+ */
+static void handle_reg1(struct srtla_state *state, const uint8_t *buf, int len,
+ const struct sockaddr_storage *from, socklen_t from_len)
+{
+ if (len < SRTLA_REG_PKT_SIZE) {
+ blog(LOG_WARNING,
+ "[%s] SRTLA: REG1 too short (%d, need %d)",
+ PLUGIN_NAME, len, SRTLA_REG_PKT_SIZE);
+ return;
+ }
+
+ blog(LOG_DEBUG, "[%s] SRTLA: Got REG1 (create group)", PLUGIN_NAME);
+
+ uint8_t group_id[SRTLA_GROUP_ID_LEN];
+ memcpy(group_id, buf + 2, 128);
+
+ for (int i = 0; i < 128; i++)
+ group_id[128 + i] = (uint8_t)(rand() & 0xFF);
+
+ if (find_group(state, group_id)) {
+ blog(LOG_WARNING,
+ "[%s] SRTLA: group ID collision, ignoring",
+ PLUGIN_NAME);
+ return;
+ }
+
+ struct srtla_group *g = alloc_group(state);
+ if (!g) {
+ blog(LOG_WARNING,
+ "[%s] SRTLA: max groups reached",
+ PLUGIN_NAME);
+ return;
+ }
+
+ memset(g, 0, sizeof(*g));
+ g->srt_sock = SRTLA_INVALID_SOCKET;
+ memcpy(g->group_id, group_id, SRTLA_GROUP_ID_LEN);
+ g->srt_sock = create_srt_forward_sock(state->srt_port);
+ if (g->srt_sock == SRTLA_INVALID_SOCKET) {
+ blog(LOG_WARNING,
+ "[%s] SRTLA: failed to create SRT forward socket",
+ PLUGIN_NAME);
+ return;
+ }
+ g->active = true;
+ g->last_activity_ns = os_gettime_ns();
+
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: group created, sending REG2 response",
+ PLUGIN_NAME);
+
+ uint8_t resp[SRTLA_REG_PKT_SIZE];
+ srtla_build_header(resp, SRTLA_TYPE_REG2);
+ memcpy(resp + 2, group_id, SRTLA_GROUP_ID_LEN);
+ sendto(state->listen_sock, (const char *)resp, SRTLA_REG_PKT_SIZE, 0,
+ (const struct sockaddr *)from, from_len);
+}
+
+/*
+ * REG2: Client wants to register a connection to an existing group.
+ * Packet: 2 bytes type + 256 bytes group ID.
+ * Response: REG3 if group found, REG_NGP if not.
+ */
+static void handle_reg2(struct srtla_state *state, const uint8_t *buf, int len,
+ const struct sockaddr_storage *from, socklen_t from_len)
+{
+ if (len < SRTLA_REG_PKT_SIZE) {
+ blog(LOG_WARNING,
+ "[%s] SRTLA: REG2 too short (%d, need %d)",
+ PLUGIN_NAME, len, SRTLA_REG_PKT_SIZE);
+ return;
+ }
+
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: Got REG2 (register connection)",
+ PLUGIN_NAME);
+
+ const uint8_t *gid = buf + 2;
+ struct srtla_group *g = find_group(state, gid);
+
+ if (!g) {
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: unknown group, sending REG_NGP",
+ PLUGIN_NAME);
+ uint8_t ngp[2];
+ srtla_build_header(ngp, SRTLA_TYPE_REG_NGP);
+ sendto(state->listen_sock, (const char *)ngp, 2, 0,
+ (const struct sockaddr *)from, from_len);
+ return;
+ }
+
+ add_connection_to_group(g, from, from_len);
+ g->last_activity_ns = os_gettime_ns();
+
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: connection registered, sending REG3 (%d conns)",
+ PLUGIN_NAME, g->num_conns);
+
+ uint8_t reg3[2];
+ srtla_build_header(reg3, SRTLA_TYPE_REG3);
+ sendto(state->listen_sock, (const char *)reg3, 2, 0,
+ (const struct sockaddr *)from, from_len);
+}
+
+static void handle_keepalive(struct srtla_state *state, const uint8_t *buf,
+ int len,
+ const struct sockaddr_storage *from,
+ socklen_t from_len)
+{
+ int echo_len = (len > SRTLA_MAX_PKT) ? SRTLA_MAX_PKT : len;
+ sendto(state->listen_sock, (const char *)buf, echo_len, 0,
+ (const struct sockaddr *)from, from_len);
+
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (!state->groups[i].active)
+ continue;
+ for (int j = 0; j < state->groups[i].num_conns; j++) {
+ if (sockaddr_equal(&state->groups[i].conns[j].addr,
+ state->groups[i].conns[j].addr_len,
+ from, from_len)) {
+ state->groups[i].conns[j].last_activity_ns =
+ os_gettime_ns();
+ state->groups[i].last_activity_ns =
+ os_gettime_ns();
+ return;
+ }
+ }
+ }
+}
+
+static struct srtla_group *find_group_by_addr(struct srtla_state *state,
+ const struct sockaddr_storage *from,
+ socklen_t from_len)
+{
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (!state->groups[i].active)
+ continue;
+
+ for (int j = 0; j < state->groups[i].num_conns; j++) {
+ if (sockaddr_equal(&state->groups[i].conns[j].addr,
+ state->groups[i].conns[j].addr_len,
+ from, from_len)) {
+ state->groups[i].conns[j].last_activity_ns =
+ os_gettime_ns();
+ state->groups[i].last_activity_ns =
+ os_gettime_ns();
+ return &state->groups[i];
+ }
+ }
+
+ if (state->groups[i].num_conns > 0 &&
+ sockaddr_same_ip(&state->groups[i].conns[0].addr, from)) {
+ add_connection_to_group(&state->groups[i], from,
+ from_len);
+ state->groups[i].last_activity_ns = os_gettime_ns();
+ return &state->groups[i];
+ }
+ }
+ return NULL;
+}
+
+static void cleanup_stale_groups(struct srtla_state *state)
+{
+ uint64_t now = os_gettime_ns();
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (!state->groups[i].active)
+ continue;
+ uint64_t age_ms =
+ (now - state->groups[i].last_activity_ns) / 1000000;
+ if (age_ms > 30000) {
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: group %d timed out (age=%llu ms, last_ns=%llu, now_ns=%llu)",
+ PLUGIN_NAME, i,
+ (unsigned long long)age_ms,
+ (unsigned long long)state->groups[i].last_activity_ns,
+ (unsigned long long)now);
+ group_cleanup(&state->groups[i]);
+ }
+ }
+}
+
+static void *srtla_thread_func(void *arg)
+{
+ struct srtla_state *state = arg;
+
+ os_set_thread_name("easy-irl-srtla");
+
+ state->listen_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+ if (state->listen_sock == SRTLA_INVALID_SOCKET) {
+ blog(LOG_ERROR, "[%s] SRTLA: failed to create socket",
+ PLUGIN_NAME);
+ return NULL;
+ }
+
+ int reuse = 1;
+ setsockopt(state->listen_sock, SOL_SOCKET, SO_REUSEADDR,
+ (const char *)&reuse, sizeof(reuse));
+
+ struct sockaddr_in bind_addr = {0};
+ bind_addr.sin_family = AF_INET;
+ bind_addr.sin_port = htons((uint16_t)state->listen_port);
+ bind_addr.sin_addr.s_addr = htonl(INADDR_ANY);
+
+ if (bind(state->listen_sock, (struct sockaddr *)&bind_addr,
+ sizeof(bind_addr)) != 0) {
+ blog(LOG_ERROR,
+ "[%s] SRTLA: failed to bind port %d",
+ PLUGIN_NAME, state->listen_port);
+ closesocket(state->listen_sock);
+ state->listen_sock = SRTLA_INVALID_SOCKET;
+ return NULL;
+ }
+
+ /* Set listen socket to non-blocking for draining all packets */
+#ifdef _WIN32
+ u_long nonblock = 1;
+ ioctlsocket(state->listen_sock, FIONBIO, &nonblock);
+#else
+ {
+ int flags = fcntl(state->listen_sock, F_GETFL, 0);
+ fcntl(state->listen_sock, F_SETFL, flags | O_NONBLOCK);
+ }
+#endif
+
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: listening on UDP port %d, forwarding to SRT port %d",
+ PLUGIN_NAME, state->listen_port, state->srt_port);
+
+ uint64_t last_cleanup = os_gettime_ns();
+
+ while (state->running) {
+ fd_set readfds;
+ FD_ZERO(&readfds);
+ FD_SET(state->listen_sock, &readfds);
+
+ SRTLA_SOCKET maxfd = state->listen_sock;
+
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (state->groups[i].active &&
+ state->groups[i].srt_sock != SRTLA_INVALID_SOCKET) {
+ FD_SET(state->groups[i].srt_sock, &readfds);
+ if (state->groups[i].srt_sock > maxfd)
+ maxfd = state->groups[i].srt_sock;
+ }
+ }
+
+ struct timeval tv = {0, 50000};
+ int ret = select((int)(maxfd + 1), &readfds, NULL, NULL, &tv);
+ if (ret <= 0)
+ goto cleanup_check;
+
+ if (FD_ISSET(state->listen_sock, &readfds)) {
+ for (int pkt_iter = 0; pkt_iter < 256; pkt_iter++) {
+ uint8_t buf[SRTLA_MAX_PKT];
+ struct sockaddr_storage from;
+ socklen_t from_len = sizeof(from);
+
+ int n = recvfrom(state->listen_sock, (char *)buf,
+ sizeof(buf), 0,
+ (struct sockaddr *)&from, &from_len);
+ if (n <= 0)
+ break;
+
+ uint16_t type = srtla_get_type(buf, n);
+
+ switch (type) {
+ case SRTLA_TYPE_REG1:
+ handle_reg1(state, buf, n, &from,
+ from_len);
+ break;
+ case SRTLA_TYPE_REG2:
+ handle_reg2(state, buf, n, &from,
+ from_len);
+ break;
+ case SRTLA_TYPE_KEEPALIVE:
+ handle_keepalive(state, buf, n, &from,
+ from_len);
+ break;
+ default: {
+ struct srtla_group *g =
+ find_group_by_addr(state, &from,
+ from_len);
+
+ if (!g) {
+ g = alloc_group(state);
+ if (g) {
+ memset(g, 0, sizeof(*g));
+ g->srt_sock =
+ SRTLA_INVALID_SOCKET;
+ memset(g->group_id, 0xFF,
+ SRTLA_GROUP_ID_LEN);
+ g->srt_sock =
+ create_srt_forward_sock(
+ state->srt_port);
+ if (g->srt_sock !=
+ SRTLA_INVALID_SOCKET) {
+ g->active = true;
+ add_connection_to_group(
+ g, &from,
+ from_len);
+ g->last_activity_ns =
+ os_gettime_ns();
+ blog(LOG_DEBUG,
+ "[%s] SRTLA: auto-registered client (%d bytes)",
+ PLUGIN_NAME, n);
+ } else {
+ memset(g, 0,
+ sizeof(*g));
+ g->srt_sock =
+ SRTLA_INVALID_SOCKET;
+ g = NULL;
+ }
+ }
+ }
+
+ if (g && g->srt_sock !=
+ SRTLA_INVALID_SOCKET) {
+ int sent = send(g->srt_sock,
+ (const char *)buf,
+ n, 0);
+ g->last_activity_ns = os_gettime_ns();
+ if (sent < 0) {
+ blog(LOG_WARNING,
+ "[%s] SRTLA: forward failed (err=%d)",
+ PLUGIN_NAME,
+#ifdef _WIN32
+ WSAGetLastError()
+#else
+ errno
+#endif
+ );
+ }
+
+ if (is_srt_data_packet(buf, n) &&
+ n >= 4) {
+ uint32_t sn =
+ get_srt_sequence_number(
+ buf);
+ g->ack_sns
+ [g->ack_sn_count++] =
+ sn;
+ if (g->ack_sn_count >=
+ SRTLA_ACK_MAX_SNS) {
+ send_srtla_ack(
+ state, g);
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /* Responses from SRT server back to clients */
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ struct srtla_group *g = &state->groups[i];
+ if (!g->active ||
+ g->srt_sock == SRTLA_INVALID_SOCKET ||
+ !FD_ISSET(g->srt_sock, &readfds))
+ continue;
+
+ for (int resp_iter = 0; resp_iter < 256; resp_iter++) {
+ uint8_t buf[SRTLA_MAX_PKT];
+ int n = recv(g->srt_sock, (char *)buf, sizeof(buf), 0);
+ if (n <= 0)
+ break;
+
+ g->last_activity_ns = os_gettime_ns();
+
+ if (g->num_conns > 0) {
+ bool is_data = is_srt_data_packet(buf, n);
+ uint16_t srt_type = 0;
+ if (!is_data && n >= 4) {
+ srt_type =
+ (((uint16_t)buf[0] << 8) |
+ buf[1]) &
+ 0x7FFF;
+ }
+
+ bool is_ack = (!is_data && srt_type == 0x0002);
+ bool is_nak = (!is_data && srt_type == 0x0003);
+
+ if (is_ack || is_nak) {
+ for (int j = 0; j < g->num_conns;
+ j++) {
+ sendto(state->listen_sock,
+ (const char *)buf, n, 0,
+ (const struct sockaddr
+ *)&g->conns[j]
+ .addr,
+ g->conns[j].addr_len);
+ }
+ } else {
+ struct srtla_conn *c = &g->conns[0];
+ for (int j = 1; j < g->num_conns;
+ j++) {
+ if (g->conns[j]
+ .last_activity_ns >
+ c->last_activity_ns)
+ c = &g->conns[j];
+ }
+ sendto(state->listen_sock,
+ (const char *)buf, n, 0,
+ (const struct sockaddr *)&c->addr,
+ c->addr_len);
+ }
+ }
+ }
+ }
+
+ cleanup_check:;
+ uint64_t now = os_gettime_ns();
+ if ((now - last_cleanup) / 1000000 > 5000) {
+ cleanup_stale_groups(state);
+ last_cleanup = now;
+ }
+ }
+
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++) {
+ if (state->groups[i].active)
+ group_cleanup(&state->groups[i]);
+ }
+ closesocket(state->listen_sock);
+ state->listen_sock = SRTLA_INVALID_SOCKET;
+
+ blog(LOG_DEBUG, "[%s] SRTLA: server stopped", PLUGIN_NAME);
+ return NULL;
+}
+
+void srtla_server_start(struct srtla_state *state, int listen_port,
+ int srt_port)
+{
+ if (state->thread_created)
+ srtla_server_stop(state);
+
+ memset(state, 0, sizeof(*state));
+ state->listen_sock = SRTLA_INVALID_SOCKET;
+ for (int i = 0; i < SRTLA_MAX_GROUPS; i++)
+ state->groups[i].srt_sock = SRTLA_INVALID_SOCKET;
+
+ state->listen_port = listen_port;
+ state->srt_port = srt_port;
+ state->running = true;
+
+ if (pthread_create(&state->thread, NULL, srtla_thread_func, state) ==
+ 0) {
+ state->thread_created = true;
+ } else {
+ blog(LOG_ERROR, "[%s] SRTLA: failed to create thread",
+ PLUGIN_NAME);
+ state->running = false;
+ }
+}
+
+void srtla_server_stop(struct srtla_state *state)
+{
+ if (!state->thread_created)
+ return;
+
+ state->running = false;
+ pthread_join(state->thread, NULL);
+ state->thread_created = false;
+}
diff --git a/src/srtla-server.h b/src/srtla-server.h
new file mode 100644
index 0000000..073c6fd
--- /dev/null
+++ b/src/srtla-server.h
@@ -0,0 +1,76 @@
+#pragma once
+
+#include
+#include
+
+#ifdef _WIN32
+#include
+#include
+#define SRTLA_SOCKET SOCKET
+#define SRTLA_INVALID_SOCKET INVALID_SOCKET
+#else
+#include
+#include
+#include
+#include
+#include
+#define SRTLA_SOCKET int
+#define SRTLA_INVALID_SOCKET (-1)
+#define closesocket close
+#endif
+
+#define SRTLA_MAX_GROUPS 8
+#define SRTLA_MAX_CONNS 8
+#define SRTLA_MAX_PKT 1500
+#define SRTLA_GROUP_ID_LEN 256
+
+/* SRTLA control types (without 0x8000 bit, that's added separately) */
+#define SRTLA_TYPE_KEEPALIVE 0x1000
+#define SRTLA_TYPE_ACK 0x1100
+#define SRTLA_TYPE_REG1 0x1200
+#define SRTLA_TYPE_REG2 0x1201
+#define SRTLA_TYPE_REG3 0x1202
+#define SRTLA_TYPE_REG_ERR 0x1210
+#define SRTLA_TYPE_REG_NGP 0x1211
+#define SRTLA_TYPE_REG_NAK 0x1212
+
+/* SRT control type bit (bit 15) */
+#define SRT_CONTROL_BIT 0x8000
+
+/* SRTLA ACK: 2 bytes header + 2 bytes padding + up to 10 sequence numbers */
+#define SRTLA_ACK_MAX_SNS 10
+#define SRTLA_ACK_PKT_LEN (4 + SRTLA_ACK_MAX_SNS * 4)
+
+struct srtla_conn {
+ struct sockaddr_storage addr;
+ socklen_t addr_len;
+ uint64_t last_activity_ns;
+ uint64_t total_bytes;
+ bool active;
+};
+
+struct srtla_group {
+ uint8_t group_id[SRTLA_GROUP_ID_LEN];
+ struct srtla_conn conns[SRTLA_MAX_CONNS];
+ int num_conns;
+ SRTLA_SOCKET srt_sock;
+ uint64_t last_activity_ns;
+ bool active;
+ /* SRTLA ACK tracking */
+ uint32_t ack_sns[SRTLA_ACK_MAX_SNS];
+ int ack_sn_count;
+};
+
+struct srtla_state {
+ SRTLA_SOCKET listen_sock;
+ struct srtla_group groups[SRTLA_MAX_GROUPS];
+ int srt_port;
+ int listen_port;
+ volatile bool running;
+ pthread_t thread;
+ bool thread_created;
+};
+
+void srtla_server_start(struct srtla_state *state, int listen_port,
+ int srt_port);
+void srtla_server_stop(struct srtla_state *state);
diff --git a/src/stats-dialog.cpp b/src/stats-dialog.cpp
new file mode 100644
index 0000000..a01bebd
--- /dev/null
+++ b/src/stats-dialog.cpp
@@ -0,0 +1,347 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "stats-dialog.hpp"
+
+extern "C" {
+#include "irl-source.h"
+}
+
+/* ---- helpers ---- */
+
+static QString fmt_bytes(uint64_t b)
+{
+ if (b < 1024)
+ return QString("%1 B").arg(b);
+ if (b < 1024ULL * 1024)
+ return QString("%1 KB").arg(b / 1024.0, 0, 'f', 1);
+ if (b < 1024ULL * 1024 * 1024)
+ return QString("%1 MB").arg(b / (1024.0 * 1024.0), 0, 'f', 1);
+ return QString("%1 GB").arg(b / (1024.0 * 1024.0 * 1024.0), 0, 'f',
+ 2);
+}
+
+static QString fmt_bitrate(int64_t kbps)
+{
+ if (kbps <= 0)
+ return "-";
+ if (kbps < 1000)
+ return QString("%1 kbps").arg(kbps);
+ return QString("%1 Mbps").arg(kbps / 1000.0, 0, 'f', 1);
+}
+
+static QString fmt_uptime(uint64_t start_ns)
+{
+ if (!start_ns)
+ return "-";
+ uint64_t now = os_gettime_ns();
+ uint64_t sec = (now > start_ns) ? (now - start_ns) / 1000000000ULL : 0;
+ int h = (int)(sec / 3600);
+ int m = (int)((sec % 3600) / 60);
+ int s = (int)(sec % 60);
+ return QString("%1:%2:%3")
+ .arg(h, 2, 10, QChar('0'))
+ .arg(m, 2, 10, QChar('0'))
+ .arg(s, 2, 10, QChar('0'));
+}
+
+/* ---- widget ---- */
+
+static const char *status_colors[] = {"#888888", "#e0a020", "#20c040",
+ "#e04040"};
+
+class StreamStatsWidget : public QWidget {
+public:
+ StreamStatsWidget(bool is_de, QWidget *parent = nullptr)
+ : QWidget(parent),
+ m_de(is_de)
+ {
+ setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
+ buildUI();
+
+ m_timer = new QTimer(this);
+ QObject::connect(m_timer, &QTimer::timeout,
+ [this]() { refresh(); });
+ m_timer->start(500);
+ refresh();
+ }
+
+private:
+ bool m_de;
+ QLabel *m_dot, *m_status;
+ QLabel *m_lbls[4], *m_vals[4];
+ QLabel *m_videoLine, *m_audioLine, *m_serverLine;
+ QTimer *m_timer;
+ int64_t m_prevFrames = 0;
+ uint64_t m_prevTime = 0;
+ double m_fps = 0.0;
+
+ QLabel *makeLabel(const QString &text, int ptDelta, bool bold,
+ const QString &color)
+ {
+ auto *l = new QLabel(text, this);
+ QFont f = font();
+ f.setPointSize(f.pointSize() + ptDelta);
+ f.setBold(bold);
+ l->setFont(f);
+ if (!color.isEmpty())
+ l->setStyleSheet(
+ QString("QLabel{color:%1}").arg(color));
+ l->setTextInteractionFlags(Qt::NoTextInteraction);
+ return l;
+ }
+
+ void buildUI()
+ {
+ QString dim = palette()
+ .color(QPalette::PlaceholderText)
+ .name();
+ QString acc = palette().color(QPalette::Highlight).name();
+
+ auto *root = new QVBoxLayout(this);
+ root->setContentsMargins(12, 10, 12, 10);
+ root->setSpacing(8);
+
+ /* Row 1 — status */
+ auto *row1 = new QHBoxLayout();
+ row1->setSpacing(8);
+
+ m_dot = new QLabel(this);
+ m_dot->setFixedSize(10, 10);
+ m_dot->setStyleSheet(
+ "QLabel{background:#888;border-radius:5px;"
+ "min-width:10px;min-height:10px}");
+ row1->addWidget(m_dot, 0, Qt::AlignVCenter);
+
+ m_status = makeLabel(m_de ? "Inaktiv" : "Idle", 1, true, "");
+ row1->addWidget(m_status, 0, Qt::AlignVCenter);
+ row1->addStretch();
+ root->addLayout(row1);
+
+ /* Row 2 — stats grid */
+ auto *grid = new QGridLayout();
+ grid->setHorizontalSpacing(12);
+ grid->setVerticalSpacing(2);
+ for (int c = 0; c < 4; c++)
+ grid->setColumnStretch(c, 1);
+
+ QFont lblFont = font();
+ lblFont.setPointSize(lblFont.pointSize() - 2);
+ lblFont.setBold(true);
+
+ QFont valFont("Consolas", font().pointSize() + 2);
+ valFont.setBold(true);
+
+ QString headers[4] = {"BITRATE", "FPS",
+ m_de ? "UPTIME" : "UPTIME",
+ m_de ? "DATEN" : "DATA"};
+
+ for (int c = 0; c < 4; c++) {
+ m_lbls[c] = new QLabel(headers[c], this);
+ m_lbls[c]->setFont(lblFont);
+ m_lbls[c]->setStyleSheet(
+ QString("QLabel{color:%1}").arg(dim));
+ m_lbls[c]->setTextInteractionFlags(
+ Qt::NoTextInteraction);
+ m_lbls[c]->setAlignment(Qt::AlignLeft |
+ Qt::AlignBottom);
+ grid->addWidget(m_lbls[c], 0, c);
+
+ m_vals[c] = new QLabel("-", this);
+ m_vals[c]->setFont(valFont);
+ m_vals[c]->setStyleSheet(
+ QString("QLabel{color:%1}").arg(acc));
+ m_vals[c]->setTextInteractionFlags(
+ Qt::NoTextInteraction);
+ m_vals[c]->setAlignment(Qt::AlignLeft |
+ Qt::AlignTop);
+ grid->addWidget(m_vals[c], 1, c);
+ }
+ root->addLayout(grid);
+
+ /* Separator */
+ auto *sep = new QFrame(this);
+ sep->setFrameShape(QFrame::HLine);
+ sep->setFrameShadow(QFrame::Sunken);
+ root->addWidget(sep);
+
+ /* Info lines */
+ m_videoLine = makeLabel("-", -1, false, "");
+ m_audioLine = makeLabel("-", -1, false, "");
+ m_serverLine = makeLabel("-", -1, false, dim);
+ root->addWidget(m_videoLine);
+ root->addWidget(m_audioLine);
+ root->addWidget(m_serverLine);
+
+ root->addStretch();
+ }
+
+ void refresh()
+ {
+ struct irl_source_data *d = nullptr;
+ for (int i = 0; i < g_irl_source_count && i < MAX_IRL_SOURCES;
+ i++) {
+ if (g_irl_sources[i]) {
+ d = g_irl_sources[i];
+ break;
+ }
+ }
+
+ if (!d) {
+ setNoSource();
+ return;
+ }
+
+ long state = os_atomic_load_long(&d->connection_state);
+ if (state < 0 || state > 3)
+ state = 0;
+ bool conn = (state == CONN_STATE_CONNECTED);
+
+ static const char *de[] = {"Inaktiv", "Wartet\xe2\x80\xa6",
+ "Verbunden", "Getrennt"};
+ static const char *en[] = {"Idle", "Listening\xe2\x80\xa6",
+ "Connected", "Disconnected"};
+
+ m_dot->setStyleSheet(
+ QString("QLabel{background:%1;border-radius:5px;"
+ "min-width:10px;min-height:10px}")
+ .arg(status_colors[state]));
+ m_status->setText(m_de ? de[state] : en[state]);
+
+ /* Bitrate */
+ m_vals[0]->setText(
+ conn ? fmt_bitrate(d->current_bitrate_kbps) : "-");
+
+ /* FPS */
+ uint64_t now = os_gettime_ns();
+ int64_t frames = d->stats_total_frames;
+ if (m_prevTime > 0 && now > m_prevTime) {
+ double dt = (double)(now - m_prevTime) / 1e9;
+ int64_t df = frames - m_prevFrames;
+ if (dt > 0.05 && df >= 0)
+ m_fps = df / dt;
+ }
+ m_prevFrames = frames;
+ m_prevTime = now;
+ m_vals[1]->setText(conn && m_fps > 0.1
+ ? QString::number(m_fps, 'f', 1)
+ : "-");
+
+ /* Uptime */
+ m_vals[2]->setText(
+ conn ? fmt_uptime(d->stats_connect_time_ns) : "-");
+
+ /* Data */
+ uint64_t tb = d->stats_total_bytes;
+ m_vals[3]->setText(tb > 0 ? fmt_bytes(tb) : "-");
+
+ /* Video */
+ if (d->stats_video_codec[0]) {
+ QString v = QString("Video: %1 %2\u00d7%3")
+ .arg(QString(d->stats_video_codec)
+ .toUpper())
+ .arg(d->stats_video_width)
+ .arg(d->stats_video_height);
+ if (d->stats_video_pixfmt[0])
+ v += QString(" %1").arg(d->stats_video_pixfmt);
+ v += QString(" \u00b7 Frames: %L1").arg(frames);
+ m_videoLine->setText(v);
+ } else {
+ m_videoLine->setText("-");
+ }
+
+ /* Audio */
+ if (d->stats_audio_codec[0])
+ m_audioLine->setText(
+ QString("Audio: %1 %2 Hz")
+ .arg(QString(d->stats_audio_codec)
+ .toUpper())
+ .arg(d->stats_audio_sample_rate));
+ else
+ m_audioLine->setText("-");
+
+ /* Server */
+ pthread_mutex_lock(&d->mutex);
+ int proto = d->protocol;
+ int port = d->port;
+ bool srtla = d->srtla_enabled;
+ int srtla_p = d->srtla_port;
+ pthread_mutex_unlock(&d->mutex);
+
+ QString s = QString("%1 \u00b7 Port %2")
+ .arg(proto == PROTOCOL_RTMP ? "RTMP"
+ : "SRT")
+ .arg(port);
+ if (srtla)
+ s += QString(" \u00b7 SRTLA \u2713 (:%1)")
+ .arg(srtla_p);
+ m_serverLine->setText(s);
+ }
+
+ void setNoSource()
+ {
+ m_dot->setStyleSheet(
+ "QLabel{background:#888;border-radius:5px;"
+ "min-width:10px;min-height:10px}");
+ m_status->setText(m_de ? "Keine Quelle" : "No source");
+ for (int c = 0; c < 4; c++)
+ m_vals[c]->setText("-");
+ m_videoLine->setText("-");
+ m_audioLine->setText("-");
+ m_serverLine->setText("-");
+ m_fps = 0;
+ m_prevFrames = 0;
+ m_prevTime = 0;
+ }
+};
+
+/* ---- dock creation ---- */
+
+static QDockWidget *g_dock = nullptr;
+
+extern "C" void stats_dialog_show(const char *locale)
+{
+ if (g_dock) {
+ g_dock->setVisible(!g_dock->isVisible());
+ if (g_dock->isVisible()) {
+ g_dock->raise();
+ g_dock->activateWindow();
+ }
+ return;
+ }
+
+ bool is_de = locale && (strncmp(locale, "de", 2) == 0);
+
+ QMainWindow *main = (QMainWindow *)obs_frontend_get_main_window();
+ if (!main)
+ return;
+
+ g_dock = new QDockWidget(
+ QString("Easy IRL Stream \u2014 Monitor"), main);
+ g_dock->setObjectName("EasyIRLStreamMonitorDock");
+ g_dock->setWidget(new StreamStatsWidget(is_de, g_dock));
+ g_dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+ g_dock->setFeatures(QDockWidget::DockWidgetMovable |
+ QDockWidget::DockWidgetFloatable |
+ QDockWidget::DockWidgetClosable);
+
+ QObject::connect(g_dock, &QDockWidget::destroyed,
+ []() { g_dock = nullptr; });
+
+ main->addDockWidget(Qt::BottomDockWidgetArea, g_dock);
+ g_dock->setFloating(true);
+ g_dock->resize(480, 230);
+ g_dock->show();
+}
diff --git a/src/stats-dialog.hpp b/src/stats-dialog.hpp
new file mode 100644
index 0000000..434cdf8
--- /dev/null
+++ b/src/stats-dialog.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void stats_dialog_show(const char *locale);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/translations.h b/src/translations.h
new file mode 100644
index 0000000..ddd33a7
--- /dev/null
+++ b/src/translations.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include
+
+static inline int tr_is_de(void)
+{
+ const char *loc = obs_get_locale();
+ return loc && loc[0] == 'd' && loc[1] == 'e';
+}
+
+static inline const char *tr_source_name(void)
+{
+ return "Easy IRL Stream";
+}
+
+static inline const char *tr_api_token(void)
+{
+ return tr_is_de() ? "stools.cc API-Token" : "stools.cc API Token";
+}
+
+static inline const char *tr_login_button(void)
+{
+ return tr_is_de() ? "Bei stools.cc anmelden"
+ : "Sign in with stools.cc";
+}
+
+static inline const char *tr_api_info(void)
+{
+ return tr_is_de()
+ ? "Alle Einstellungen werden auf stools.cc/dashboard/plugin verwaltet.\n"
+ "1. Klicke oben auf den Button um stools.cc zu \xc3\xb6""ffnen\n"
+ "2. Erstelle einen Token und kopiere ihn\n"
+ "3. F\xc3\xbc""ge ihn im API-Token-Feld ein"
+ : "All settings are managed at stools.cc/dashboard/plugin\n"
+ "1. Click the button above to open stools.cc\n"
+ "2. Create a token and copy it\n"
+ "3. Paste it into the API Token field";
+}
+
+static inline const char *tr_tools_menu_help(void)
+{
+ return tr_is_de() ? "Easy IRL Stream - Hilfe"
+ : "Easy IRL Stream - Help";
+}
+
+static inline const char *tr_tools_menu_stats(void)
+{
+ return "Easy IRL Stream - Stream Monitor";
+}
diff --git a/src/webhook.c b/src/webhook.c
new file mode 100644
index 0000000..2e5dc21
--- /dev/null
+++ b/src/webhook.c
@@ -0,0 +1,216 @@
+#include "webhook.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#include
+#include
+typedef SOCKET sock_t;
+#define SOCK_INVALID INVALID_SOCKET
+#define sock_close closesocket
+#else
+#include
+#include
+#include
+#include
+typedef int sock_t;
+#define SOCK_INVALID (-1)
+#define sock_close close
+#endif
+
+struct webhook_args {
+ char *url;
+ char *event_name;
+ char *source_name;
+};
+
+struct cmd_args {
+ char *command;
+};
+
+static bool parse_url(const char *url, char *host, size_t host_sz,
+ char *port, size_t port_sz, char *path, size_t path_sz)
+{
+ const char *p = url;
+
+ if (strncmp(p, "http://", 7) == 0) {
+ p += 7;
+ snprintf(port, port_sz, "80");
+ } else if (strncmp(p, "https://", 8) == 0) {
+ p += 8;
+ snprintf(port, port_sz, "443");
+ } else {
+ return false;
+ }
+
+ const char *slash = strchr(p, '/');
+ const char *colon = strchr(p, ':');
+
+ if (colon && (!slash || colon < slash)) {
+ size_t hlen = (size_t)(colon - p);
+ if (hlen >= host_sz)
+ hlen = host_sz - 1;
+ memcpy(host, p, hlen);
+ host[hlen] = '\0';
+
+ colon++;
+ const char *pend = slash ? slash : colon + strlen(colon);
+ size_t plen = (size_t)(pend - colon);
+ if (plen >= port_sz)
+ plen = port_sz - 1;
+ memcpy(port, colon, plen);
+ port[plen] = '\0';
+ } else {
+ size_t hlen = slash ? (size_t)(slash - p)
+ : strlen(p);
+ if (hlen >= host_sz)
+ hlen = host_sz - 1;
+ memcpy(host, p, hlen);
+ host[hlen] = '\0';
+ }
+
+ if (slash)
+ snprintf(path, path_sz, "%s", slash);
+ else
+ snprintf(path, path_sz, "/");
+
+ return true;
+}
+
+static void webhook_do_send(const char *url, const char *event_name,
+ const char *source_name)
+{
+ char host[256] = {0};
+ char port_str[16] = {0};
+ char path[512] = {0};
+
+ if (!parse_url(url, host, sizeof(host), port_str, sizeof(port_str),
+ path, sizeof(path))) {
+ blog(LOG_WARNING, "[%s] Webhook: invalid URL '%s'",
+ "Easy IRL Stream", url);
+ return;
+ }
+
+#ifdef _WIN32
+ WSADATA wsa;
+ WSAStartup(MAKEWORD(2, 2), &wsa);
+#endif
+
+ struct addrinfo hints = {0};
+ struct addrinfo *res = NULL;
+ hints.ai_family = AF_INET;
+ hints.ai_socktype = SOCK_STREAM;
+
+ if (getaddrinfo(host, port_str, &hints, &res) != 0) {
+ blog(LOG_WARNING, "[%s] Webhook: DNS lookup failed for '%s'",
+ "Easy IRL Stream", host);
+ return;
+ }
+
+ sock_t sock = socket(res->ai_family, res->ai_socktype,
+ res->ai_protocol);
+ if (sock == SOCK_INVALID) {
+ freeaddrinfo(res);
+ return;
+ }
+
+ if (connect(sock, res->ai_addr, (int)res->ai_addrlen) != 0) {
+ freeaddrinfo(res);
+ sock_close(sock);
+ return;
+ }
+ freeaddrinfo(res);
+
+ char body[1024];
+ snprintf(body, sizeof(body),
+ "{\"event\":\"%s\",\"source\":\"%s\",\"timestamp\":%lld}",
+ event_name, source_name, (long long)time(NULL));
+
+ char request[2048];
+ snprintf(request, sizeof(request),
+ "POST %s HTTP/1.1\r\n"
+ "Host: %s\r\n"
+ "Content-Type: application/json\r\n"
+ "Content-Length: %d\r\n"
+ "Connection: close\r\n"
+ "\r\n"
+ "%s",
+ path, host, (int)strlen(body), body);
+
+ send(sock, request, (int)strlen(request), 0);
+
+ char buf[512];
+ while (recv(sock, buf, sizeof(buf), 0) > 0) {
+ }
+
+ sock_close(sock);
+
+ blog(LOG_DEBUG, "[%s] Webhook sent: %s -> %s", "Easy IRL Stream",
+ event_name, url);
+}
+
+static void *webhook_thread_func(void *arg)
+{
+ struct webhook_args *wa = arg;
+ webhook_do_send(wa->url, wa->event_name, wa->source_name);
+ bfree(wa->url);
+ bfree(wa->event_name);
+ bfree(wa->source_name);
+ bfree(wa);
+ return NULL;
+}
+
+void webhook_send_async(const char *url, const char *event_name,
+ const char *source_name)
+{
+ if (!url || !url[0])
+ return;
+
+ struct webhook_args *wa = bzalloc(sizeof(*wa));
+ wa->url = bstrdup(url);
+ wa->event_name = bstrdup(event_name);
+ wa->source_name = bstrdup(source_name);
+
+ pthread_t thread;
+ if (pthread_create(&thread, NULL, webhook_thread_func, wa) == 0) {
+ pthread_detach(thread);
+ } else {
+ bfree(wa->url);
+ bfree(wa->event_name);
+ bfree(wa->source_name);
+ bfree(wa);
+ }
+}
+
+static void *cmd_thread_func(void *arg)
+{
+ struct cmd_args *ca = arg;
+ blog(LOG_DEBUG, "[%s] Executing command: %s", "Easy IRL Stream",
+ ca->command);
+ (void)system(ca->command);
+ bfree(ca->command);
+ bfree(ca);
+ return NULL;
+}
+
+void webhook_execute_command_async(const char *command)
+{
+ if (!command || !command[0])
+ return;
+
+ struct cmd_args *ca = bzalloc(sizeof(*ca));
+ ca->command = bstrdup(command);
+
+ pthread_t thread;
+ if (pthread_create(&thread, NULL, cmd_thread_func, ca) == 0) {
+ pthread_detach(thread);
+ } else {
+ bfree(ca->command);
+ bfree(ca);
+ }
+}
diff --git a/src/webhook.h b/src/webhook.h
new file mode 100644
index 0000000..453f419
--- /dev/null
+++ b/src/webhook.h
@@ -0,0 +1,6 @@
+#pragma once
+
+void webhook_send_async(const char *url, const char *event_name,
+ const char *source_name);
+
+void webhook_execute_command_async(const char *command);