Initial commit
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
#include "event-handler.h"
|
||||
#include "webhook.h"
|
||||
|
||||
/* ---- queued tasks executed on the UI thread ---- */
|
||||
|
||||
struct scene_switch_ctx {
|
||||
char *scene_name;
|
||||
};
|
||||
|
||||
static void task_switch_scene(void *param)
|
||||
{
|
||||
struct scene_switch_ctx *ctx = param;
|
||||
obs_source_t *scene = obs_get_source_by_name(ctx->scene_name);
|
||||
if (scene) {
|
||||
obs_frontend_set_current_scene(scene);
|
||||
obs_source_release(scene);
|
||||
}
|
||||
bfree(ctx->scene_name);
|
||||
bfree(ctx);
|
||||
}
|
||||
|
||||
struct overlay_ctx {
|
||||
char *source_name;
|
||||
bool visible;
|
||||
};
|
||||
|
||||
static void task_set_overlay(void *param)
|
||||
{
|
||||
struct overlay_ctx *ctx = param;
|
||||
|
||||
obs_source_t *current = obs_frontend_get_current_scene();
|
||||
if (current) {
|
||||
obs_scene_t *scene = obs_scene_from_source(current);
|
||||
if (scene) {
|
||||
obs_sceneitem_t *item = obs_scene_find_source(
|
||||
scene, ctx->source_name);
|
||||
if (item)
|
||||
obs_sceneitem_set_visible(item, ctx->visible);
|
||||
}
|
||||
obs_source_release(current);
|
||||
}
|
||||
|
||||
bfree(ctx->source_name);
|
||||
bfree(ctx);
|
||||
}
|
||||
|
||||
struct recording_ctx {
|
||||
int action;
|
||||
};
|
||||
|
||||
static void task_recording(void *param)
|
||||
{
|
||||
struct recording_ctx *ctx = param;
|
||||
if (ctx->action == RECORDING_ACTION_START)
|
||||
obs_frontend_recording_start();
|
||||
else if (ctx->action == RECORDING_ACTION_STOP)
|
||||
obs_frontend_recording_stop();
|
||||
bfree(ctx);
|
||||
}
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
static void queue_scene_switch(const char *scene_name)
|
||||
{
|
||||
if (!scene_name || !scene_name[0])
|
||||
return;
|
||||
struct scene_switch_ctx *ctx = bzalloc(sizeof(*ctx));
|
||||
ctx->scene_name = bstrdup(scene_name);
|
||||
obs_queue_task(OBS_TASK_UI, task_switch_scene, ctx, false);
|
||||
}
|
||||
|
||||
static void queue_overlay(const char *source_name, bool visible)
|
||||
{
|
||||
if (!source_name || !source_name[0])
|
||||
return;
|
||||
struct overlay_ctx *ctx = bzalloc(sizeof(*ctx));
|
||||
ctx->source_name = bstrdup(source_name);
|
||||
ctx->visible = visible;
|
||||
obs_queue_task(OBS_TASK_UI, task_set_overlay, ctx, false);
|
||||
}
|
||||
|
||||
static void queue_recording(int action)
|
||||
{
|
||||
if (action == RECORDING_ACTION_NONE)
|
||||
return;
|
||||
struct recording_ctx *ctx = bzalloc(sizeof(*ctx));
|
||||
ctx->action = action;
|
||||
obs_queue_task(OBS_TASK_UI, task_recording, ctx, false);
|
||||
}
|
||||
|
||||
/* ---- fire low-quality / quality-recovered actions ---- */
|
||||
|
||||
static void fire_low_quality_actions(struct irl_source_data *data)
|
||||
{
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
if (data->low_quality_actions_fired) {
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
return;
|
||||
}
|
||||
data->low_quality_actions_fired = true;
|
||||
|
||||
char *scene = data->low_quality_scene_name
|
||||
? bstrdup(data->low_quality_scene_name)
|
||||
: NULL;
|
||||
char *overlay = data->low_quality_overlay_name
|
||||
? bstrdup(data->low_quality_overlay_name)
|
||||
: NULL;
|
||||
char *webhook = data->webhook_url ? bstrdup(data->webhook_url) : NULL;
|
||||
char *cmd = data->custom_command ? bstrdup(data->custom_command)
|
||||
: NULL;
|
||||
const char *src_name = obs_source_get_name(data->source);
|
||||
char *src_copy = bstrdup(src_name ? src_name : "Easy IRL Stream");
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
blog(LOG_DEBUG, "[%s] Low quality detected (%lld kbps)", PLUGIN_NAME,
|
||||
(long long)data->current_bitrate_kbps);
|
||||
|
||||
queue_scene_switch(scene);
|
||||
queue_overlay(overlay, true);
|
||||
|
||||
if (webhook && webhook[0])
|
||||
webhook_send_async(webhook, "low_quality", src_copy);
|
||||
if (cmd && cmd[0])
|
||||
webhook_execute_command_async(cmd);
|
||||
|
||||
bfree(scene);
|
||||
bfree(overlay);
|
||||
bfree(webhook);
|
||||
bfree(cmd);
|
||||
bfree(src_copy);
|
||||
}
|
||||
|
||||
static void fire_quality_recovered_actions(struct irl_source_data *data)
|
||||
{
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
data->low_quality_actions_fired = false;
|
||||
data->low_quality_active = false;
|
||||
|
||||
char *scene = data->reconnect_scene_name
|
||||
? bstrdup(data->reconnect_scene_name)
|
||||
: NULL;
|
||||
char *overlay = data->low_quality_overlay_name
|
||||
? bstrdup(data->low_quality_overlay_name)
|
||||
: NULL;
|
||||
char *webhook = data->webhook_url ? bstrdup(data->webhook_url) : NULL;
|
||||
const char *src_name = obs_source_get_name(data->source);
|
||||
char *src_copy = bstrdup(src_name ? src_name : "Easy IRL Stream");
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
blog(LOG_DEBUG, "[%s] Quality recovered (%lld kbps)", PLUGIN_NAME,
|
||||
(long long)data->current_bitrate_kbps);
|
||||
|
||||
queue_scene_switch(scene);
|
||||
queue_overlay(overlay, false);
|
||||
|
||||
if (webhook && webhook[0])
|
||||
webhook_send_async(webhook, "quality_recovered", src_copy);
|
||||
|
||||
bfree(scene);
|
||||
bfree(overlay);
|
||||
bfree(webhook);
|
||||
bfree(src_copy);
|
||||
}
|
||||
|
||||
/* ---- fire disconnect / reconnect actions ---- */
|
||||
|
||||
static void fire_disconnect_actions(struct irl_source_data *data)
|
||||
{
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
if (data->disconnect_actions_fired) {
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
return;
|
||||
}
|
||||
data->disconnect_actions_fired = true;
|
||||
|
||||
char *scene = data->disconnect_scene_name
|
||||
? bstrdup(data->disconnect_scene_name)
|
||||
: NULL;
|
||||
char *overlay = data->overlay_source_name
|
||||
? bstrdup(data->overlay_source_name)
|
||||
: NULL;
|
||||
int rec_action = data->disconnect_recording_action;
|
||||
char *webhook = data->webhook_url ? bstrdup(data->webhook_url) : NULL;
|
||||
char *cmd = data->custom_command ? bstrdup(data->custom_command)
|
||||
: NULL;
|
||||
const char *src_name = obs_source_get_name(data->source);
|
||||
char *src_copy = bstrdup(src_name ? src_name : "Easy IRL Stream");
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
blog(LOG_DEBUG, "[%s] Firing disconnect actions", PLUGIN_NAME);
|
||||
|
||||
queue_scene_switch(scene);
|
||||
queue_overlay(overlay, true);
|
||||
queue_recording(rec_action);
|
||||
|
||||
if (webhook && webhook[0])
|
||||
webhook_send_async(webhook, "disconnect", src_copy);
|
||||
if (cmd && cmd[0])
|
||||
webhook_execute_command_async(cmd);
|
||||
|
||||
/* Clear last video frame so OBS shows nothing */
|
||||
obs_source_output_video(data->source, NULL);
|
||||
|
||||
bfree(scene);
|
||||
bfree(overlay);
|
||||
bfree(webhook);
|
||||
bfree(cmd);
|
||||
bfree(src_copy);
|
||||
}
|
||||
|
||||
static void fire_reconnect_actions(struct irl_source_data *data)
|
||||
{
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
char *scene = data->reconnect_scene_name
|
||||
? bstrdup(data->reconnect_scene_name)
|
||||
: NULL;
|
||||
char *overlay = data->overlay_source_name
|
||||
? bstrdup(data->overlay_source_name)
|
||||
: NULL;
|
||||
char *webhook = data->webhook_url ? bstrdup(data->webhook_url) : NULL;
|
||||
char *cmd = data->custom_command ? bstrdup(data->custom_command)
|
||||
: NULL;
|
||||
const char *src_name = obs_source_get_name(data->source);
|
||||
char *src_copy = bstrdup(src_name ? src_name : "Easy IRL Stream");
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
blog(LOG_DEBUG, "[%s] Firing reconnect actions", PLUGIN_NAME);
|
||||
|
||||
queue_scene_switch(scene);
|
||||
queue_overlay(overlay, false);
|
||||
|
||||
if (webhook && webhook[0])
|
||||
webhook_send_async(webhook, "reconnect", src_copy);
|
||||
if (cmd && cmd[0])
|
||||
webhook_execute_command_async(cmd);
|
||||
|
||||
bfree(scene);
|
||||
bfree(overlay);
|
||||
bfree(webhook);
|
||||
bfree(cmd);
|
||||
bfree(src_copy);
|
||||
}
|
||||
|
||||
/* ---- public API ---- */
|
||||
|
||||
void event_handler_on_connect(struct irl_source_data *data)
|
||||
{
|
||||
blog(LOG_DEBUG, "[%s] Client connected", PLUGIN_NAME);
|
||||
|
||||
bool was_disconnected;
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
was_disconnected = data->disconnect_actions_fired;
|
||||
data->low_quality_active = false;
|
||||
data->low_quality_actions_fired = false;
|
||||
data->last_bitrate_check_ns = 0;
|
||||
data->current_bitrate_kbps = 0;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
os_atomic_set_long(&data->bytes_window, 0);
|
||||
|
||||
if (was_disconnected) {
|
||||
fire_reconnect_actions(data);
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
data->disconnect_actions_fired = false;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
}
|
||||
}
|
||||
|
||||
void event_handler_on_disconnect(struct irl_source_data *data)
|
||||
{
|
||||
blog(LOG_DEBUG, "[%s] Client disconnected", PLUGIN_NAME);
|
||||
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
data->disconnect_time_ns = os_gettime_ns();
|
||||
int timeout = data->disconnect_timeout_sec;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
if (timeout <= 0)
|
||||
fire_disconnect_actions(data);
|
||||
}
|
||||
|
||||
static void check_quality(struct irl_source_data *data)
|
||||
{
|
||||
uint64_t now = os_gettime_ns();
|
||||
|
||||
if (data->last_bitrate_check_ns == 0) {
|
||||
data->last_bitrate_check_ns = now;
|
||||
return;
|
||||
}
|
||||
|
||||
uint64_t elapsed = now - data->last_bitrate_check_ns;
|
||||
if (elapsed < 1000000000ULL)
|
||||
return;
|
||||
|
||||
long bytes = os_atomic_exchange_long(&data->bytes_window, 0);
|
||||
double seconds = (double)elapsed / 1000000000.0;
|
||||
data->current_bitrate_kbps =
|
||||
(int64_t)((bytes * 8.0) / (seconds * 1000.0));
|
||||
data->last_bitrate_check_ns = now;
|
||||
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
bool enabled = data->low_quality_enabled;
|
||||
int threshold = data->low_quality_bitrate_kbps;
|
||||
int timeout = data->low_quality_timeout_sec;
|
||||
bool was_active = data->low_quality_active;
|
||||
bool was_fired = data->low_quality_actions_fired;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
if (!enabled)
|
||||
return;
|
||||
|
||||
bool is_low = data->current_bitrate_kbps < threshold &&
|
||||
data->current_bitrate_kbps > 0;
|
||||
|
||||
if (is_low) {
|
||||
if (!was_active) {
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
data->low_quality_active = true;
|
||||
data->low_quality_start_ns = now;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
} else if (!was_fired) {
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
uint64_t lq_elapsed =
|
||||
now - data->low_quality_start_ns;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
uint64_t timeout_ns =
|
||||
(uint64_t)timeout * 1000000000ULL;
|
||||
if (lq_elapsed >= timeout_ns)
|
||||
fire_low_quality_actions(data);
|
||||
}
|
||||
} else if (was_active) {
|
||||
if (was_fired)
|
||||
fire_quality_recovered_actions(data);
|
||||
else {
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
data->low_quality_active = false;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void event_handler_tick(struct irl_source_data *data)
|
||||
{
|
||||
long state = os_atomic_load_long(&data->connection_state);
|
||||
|
||||
if (state == CONN_STATE_CONNECTED)
|
||||
check_quality(data);
|
||||
|
||||
if (state != CONN_STATE_DISCONNECTED)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&data->mutex);
|
||||
bool already_fired = data->disconnect_actions_fired;
|
||||
uint64_t disc_time = data->disconnect_time_ns;
|
||||
int timeout = data->disconnect_timeout_sec;
|
||||
pthread_mutex_unlock(&data->mutex);
|
||||
|
||||
if (already_fired || timeout <= 0)
|
||||
return;
|
||||
|
||||
uint64_t elapsed_ns = os_gettime_ns() - disc_time;
|
||||
uint64_t timeout_ns = (uint64_t)timeout * 1000000000ULL;
|
||||
|
||||
if (elapsed_ns >= timeout_ns)
|
||||
fire_disconnect_actions(data);
|
||||
}
|
||||
|
||||
int64_t event_handler_get_bitrate(struct irl_source_data *data)
|
||||
{
|
||||
return data->current_bitrate_kbps;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "irl-source.h"
|
||||
|
||||
void event_handler_on_connect(struct irl_source_data *data);
|
||||
void event_handler_on_disconnect(struct irl_source_data *data);
|
||||
void event_handler_tick(struct irl_source_data *data);
|
||||
|
||||
int64_t event_handler_get_bitrate(struct irl_source_data *data);
|
||||
@@ -0,0 +1,390 @@
|
||||
#include <QDialog>
|
||||
#include <QTextBrowser>
|
||||
#include <QVBoxLayout>
|
||||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#include <QMessageBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QUrl>
|
||||
|
||||
#include <obs-frontend-api.h>
|
||||
|
||||
#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 <b>von unterwegs</b> (Mobilfunk) streamen kann, "
|
||||
"muss der Port im Router weitergeleitet werden:",
|
||||
"<b>Router-Konfiguration öffnen</b><br>"
|
||||
"Fritz!Box: <code>http://fritz.box</code><br>"
|
||||
"Telekom: <code>http://192.168.2.1</code><br>"
|
||||
"Andere: <code>http://192.168.1.1</code>",
|
||||
"<b>Port-Weiterleitung einrichten</b><br>"
|
||||
"Externer Port: Dein Plugin-Port (Standard: <code>1935</code> / <code>9000</code>)<br>"
|
||||
"Interner Port: Der gleiche Port<br>"
|
||||
"Protokoll: <b>TCP</b> (RTMP) oder <b>UDP</b> (SRT)<br>"
|
||||
"Ziel-IP: <code>%1</code> (dieser PC)",
|
||||
"<b>Windows-Firewall prüfen</b><br>"
|
||||
"Beim ersten Start fragt Windows nach. Falls nicht:<br>"
|
||||
"Windows-Suche → <i>Windows Defender Firewall</i> → "
|
||||
"<i>Erweiterte Einstellungen</i> → <i>Eingehende Regeln</i> → "
|
||||
"<i>Neue Regel</i> → Port → TCP/UDP → Port eingeben → Zulassen",
|
||||
"<b>Am Handy verbinden</b><br>"
|
||||
"Als Server-IP die externe IP verwenden: <code>%1</code>",
|
||||
"<b>Im gleichen WLAN?</b> Keine Port-Weiterleitung nötig! "
|
||||
"Einfach die lokale IP verwenden: <code>%1</code>",
|
||||
"DuckDNS (Dynamisches DNS)",
|
||||
"Deine externe IP ändert sich regelmäßig. "
|
||||
"Mit <a href='https://www.duckdns.org'>DuckDNS</a> bekommst du eine feste Adresse:",
|
||||
"Gehe zu <a href='https://www.duckdns.org'>duckdns.org</a> und erstelle ein Konto",
|
||||
"Erstelle eine Subdomain (z.B. <code>meinstream</code>)",
|
||||
"Kopiere deinen <b>Token</b>",
|
||||
"Trage Subdomain + Token auf <a href='https://stools.cc/dashboard/plugin'>stools.cc</a> unter <i>DuckDNS</i> 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)<br>"
|
||||
"2. Im gleichen WLAN? → Lokale IP verwenden<br>"
|
||||
"3. Über Mobilfunk? → Port-Weiterleitung einrichten<br>"
|
||||
"4. Windows-Firewall → Port freigeben<br>"
|
||||
"5. Port + Protokoll korrekt? RTMP = TCP:1935, SRT = UDP:9000",
|
||||
"Was ist besser – RTMP oder SRT?",
|
||||
"<b>SRT</b> ist besser für Mobilfunk (eingebaute Fehlerkorrektur, konfigurierbare Latenz).<br>"
|
||||
"<b>RTMP</b> ist einfacher und wird von mehr Streaming-Apps unterstützt.<br>"
|
||||
"<i>Empfehlung:</i> SRT für IRL-Streaming, RTMP als Fallback.<br>"
|
||||
"<b>Hinweis:</b> Die SRT-Passphrase muss <b>10–79 Zeichen</b> lang sein (SRT-Protokoll-Vorgabe).",
|
||||
"Wie funktionieren Overlays?",
|
||||
"Erstelle eine Quelle (Bild/Text) in deiner Szene → Blende sie mit dem "
|
||||
"<b>Auge-Symbol</b> 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: <code>500 kbps</code>. Liegt die Bitrate darunter, werden die "
|
||||
"konfigurierten Qualitäts-Aktionen ausgelöst (Overlay, Szenenwechsel…).",
|
||||
"Unterschied Disconnect vs. schlechte Qualität?",
|
||||
"<b>Disconnect:</b> Verbindung komplett weg – kein Stream kommt an.<br>"
|
||||
"<b>Schlechte Qualität:</b> Stream kommt noch an, aber Bitrate ist zu niedrig.<br>"
|
||||
"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 "
|
||||
"<code>meinstream.duckdns.org</code>.",
|
||||
"SRTLA (Link Aggregation)",
|
||||
"SRTLA ermöglicht Apps wie <b>Moblin</b>, WLAN und Mobilfunk <b>gleichzeitig</b> "
|
||||
"zu nutzen. Die Verbindung wird dadurch deutlich stabiler – fällt ein Netzwerk aus, "
|
||||
"läuft der Stream über das andere weiter.",
|
||||
"Auf <a href='https://stools.cc/dashboard/plugin'>stools.cc</a>: <b>SRT</b> als Protokoll wählen und <b>SRTLA aktivieren</b>",
|
||||
"SRTLA-Port merken (Standard: <code>5000</code>)",
|
||||
"In <b>Moblin</b>: Protokoll auf <b>SRT(LA)</b> stellen",
|
||||
"Als Server-Adresse <code><DEINE_IP>:5000</code> eingeben "
|
||||
"(den SRTLA-Port, <b>nicht</b> den SRT-Port!)",
|
||||
"Was ist SRTLA?",
|
||||
"<b>SRTLA</b> (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.<br>"
|
||||
"<b>Standard-Ports:</b> SRTLA = UDP <code>5000</code>, SRT = UDP <code>9000</code><br>"
|
||||
"<b>Wichtig:</b> In Moblin den <b>SRTLA-Port</b> (5000) angeben, nicht den SRT-Port (9000)!",
|
||||
};
|
||||
|
||||
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 <b>remotely</b> (mobile data), "
|
||||
"you need to set up port forwarding in your router:",
|
||||
"<b>Open router configuration</b><br>"
|
||||
"Common addresses: <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code>",
|
||||
"<b>Set up port forwarding</b><br>"
|
||||
"External port: Your plugin port (default: <code>1935</code> / <code>9000</code>)<br>"
|
||||
"Internal port: Same port<br>"
|
||||
"Protocol: <b>TCP</b> (RTMP) or <b>UDP</b> (SRT)<br>"
|
||||
"Target IP: <code>%1</code> (this PC)",
|
||||
"<b>Check Windows Firewall</b><br>"
|
||||
"Windows should ask on first launch. If not:<br>"
|
||||
"Windows Search → <i>Windows Defender Firewall</i> → "
|
||||
"<i>Advanced Settings</i> → <i>Inbound Rules</i> → "
|
||||
"<i>New Rule</i> → Port → TCP/UDP → Enter port → Allow",
|
||||
"<b>Connect your phone</b><br>"
|
||||
"Use the external IP as server address: <code>%1</code>",
|
||||
"<b>Same WiFi?</b> No port forwarding needed! "
|
||||
"Just use the local IP: <code>%1</code>",
|
||||
"DuckDNS (Dynamic DNS)",
|
||||
"Your external IP changes regularly. "
|
||||
"With <a href='https://www.duckdns.org'>DuckDNS</a> you get a fixed address:",
|
||||
"Go to <a href='https://www.duckdns.org'>duckdns.org</a> and create an account",
|
||||
"Create a subdomain (e.g. <code>mystream</code>)",
|
||||
"Copy your <b>Token</b>",
|
||||
"Enter subdomain + token on <a href='https://stools.cc/dashboard/plugin'>stools.cc</a> under <i>DuckDNS</i>",
|
||||
"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)<br>"
|
||||
"2. Same WiFi? → Use local IP<br>"
|
||||
"3. On mobile data? → Set up port forwarding<br>"
|
||||
"4. Windows Firewall → Allow the port<br>"
|
||||
"5. Port + protocol correct? RTMP = TCP:1935, SRT = UDP:9000",
|
||||
"Which is better – RTMP or SRT?",
|
||||
"<b>SRT</b> is better for mobile (built-in error correction, configurable latency).<br>"
|
||||
"<b>RTMP</b> is simpler and supported by more streaming apps.<br>"
|
||||
"<i>Recommendation:</i> SRT for IRL streaming, RTMP as fallback.<br>"
|
||||
"<b>Note:</b> The SRT passphrase must be <b>10–79 characters</b> long (SRT protocol requirement).",
|
||||
"How do overlays work?",
|
||||
"Create a source (image/text) in your scene → Hide it with the "
|
||||
"<b>eye icon</b> → 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: <code>500 kbps</code>. If the bitrate drops below this, the "
|
||||
"configured quality actions are triggered (overlay, scene switch…).",
|
||||
"Difference between disconnect and bad quality?",
|
||||
"<b>Disconnect:</b> Connection completely lost – no stream arriving.<br>"
|
||||
"<b>Bad quality:</b> Stream still arriving, but bitrate is too low.<br>"
|
||||
"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 "
|
||||
"<code>mystream.duckdns.org</code>.",
|
||||
"SRTLA (Link Aggregation)",
|
||||
"SRTLA allows apps like <b>Moblin</b> to use WiFi and mobile data <b>simultaneously</b>. "
|
||||
"This makes the connection much more stable – if one network drops, "
|
||||
"the stream continues over the other.",
|
||||
"On <a href='https://stools.cc/dashboard/plugin'>stools.cc</a>: Select <b>SRT</b> as protocol and <b>enable SRTLA</b>",
|
||||
"Note the SRTLA port (default: <code>5000</code>)",
|
||||
"In <b>Moblin</b>: Set protocol to <b>SRT(LA)</b>",
|
||||
"Enter <code><YOUR_IP>:5000</code> as server address "
|
||||
"(the SRTLA port, <b>not</b> the SRT port!)",
|
||||
"What is SRTLA?",
|
||||
"<b>SRTLA</b> (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.<br>"
|
||||
"<b>Default ports:</b> SRTLA = UDP <code>5000</code>, SRT = UDP <code>9000</code><br>"
|
||||
"<b>Important:</b> In Moblin, enter the <b>SRTLA port</b> (5000), not the SRT port (9000)!",
|
||||
};
|
||||
|
||||
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(
|
||||
"<!DOCTYPE html>"
|
||||
"<html><head><meta charset='utf-8'><style>"
|
||||
"body { font-family: sans-serif; font-size: 13px; "
|
||||
" background: %1; color: %2; padding: 16px 20px; line-height: 1.55; }"
|
||||
"h1 { font-size: 18px; font-weight: 600; margin: 0 0 2px 0; }"
|
||||
".ver { color: %3; font-size: 11px; margin-bottom: 18px; }"
|
||||
"h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; "
|
||||
" letter-spacing: 1px; color: %3; border-bottom: 1px solid %4; "
|
||||
" padding-bottom: 4px; margin: 22px 0 10px 0; }"
|
||||
".ip-row { background: %4; border-radius: 4px; padding: 8px 12px; "
|
||||
" margin-bottom: 6px; }"
|
||||
".ip-label { font-size: 11px; color: %3; }"
|
||||
".ip-val { font-family: monospace; font-size: 15px; font-weight: 700; "
|
||||
" color: %5; }"
|
||||
"ol { padding-left: 22px; margin: 6px 0; }"
|
||||
"li { margin-bottom: 6px; }"
|
||||
"code { background: %4; padding: 1px 5px; border-radius: 3px; font-size: 12px; }"
|
||||
".note { background: %4; border-left: 3px solid %5; "
|
||||
" padding: 8px 12px; border-radius: 0 4px 4px 0; margin: 10px 0; font-size: 12px; }"
|
||||
".q { font-weight: 700; color: %5; margin-top: 12px; }"
|
||||
".a { margin-bottom: 8px; padding-left: 12px; font-size: 12px; color: %2; }"
|
||||
"a { color: %6; }"
|
||||
"</style></head><body>")
|
||||
.arg(bg, fg, dimmed, bg2, accent, link)
|
||||
|
||||
+ QString("<h1>%1</h1><div class='ver'>Version %2</div>").arg(L.title).arg(version)
|
||||
|
||||
+ QString("<h2>%1</h2>").arg(L.your_network)
|
||||
+ QString("<div class='ip-row'><div class='ip-label'>%1</div>"
|
||||
"<div class='ip-val'>%2</div></div>")
|
||||
.arg(L.local_ip_label)
|
||||
.arg(lip)
|
||||
+ QString("<div class='ip-row'><div class='ip-label'>%1</div>"
|
||||
"<div class='ip-val'>%2</div></div>")
|
||||
.arg(L.external_ip_label)
|
||||
.arg(eip)
|
||||
|
||||
+ QString("<h2>%1</h2><p>%2</p>").arg(L.port_fwd).arg(L.port_fwd_intro)
|
||||
+ QString("<ol>"
|
||||
"<li>%1</li>"
|
||||
"<li>%2</li>"
|
||||
"<li>%3</li>"
|
||||
"<li>%4</li>"
|
||||
"</ol>")
|
||||
.arg(L.step1)
|
||||
.arg(QString(L.step2).arg(lip))
|
||||
.arg(L.step3)
|
||||
.arg(QString(L.step4).arg(eip))
|
||||
+ QString("<div class='note'>%1</div>").arg(QString(L.same_wifi_note).arg(lip))
|
||||
|
||||
+ QString("<h2>%1</h2><p>%2</p>").arg(L.duckdns_title).arg(L.duckdns_intro)
|
||||
+ QString("<ol><li>%1</li><li>%2</li><li>%3</li><li>%4</li><li>%5</li></ol>")
|
||||
.arg(L.duck_step1)
|
||||
.arg(L.duck_step2)
|
||||
.arg(L.duck_step3)
|
||||
.arg(L.duck_step4)
|
||||
.arg(L.duck_step5)
|
||||
+ QString("<p>%1<br><code>rtmp://meinstream.duckdns.org:1935/live</code></p>").arg(L.duck_example)
|
||||
|
||||
+ QString("<h2>%1</h2><p>%2</p>").arg(L.srtla_title).arg(L.srtla_intro)
|
||||
+ QString("<ol><li>%1</li><li>%2</li><li>%3</li><li>%4</li></ol>")
|
||||
.arg(L.srtla_step1)
|
||||
.arg(L.srtla_step2)
|
||||
.arg(L.srtla_step3)
|
||||
.arg(L.srtla_step4)
|
||||
|
||||
+ QString("<h2>%1</h2>").arg(L.faq_title)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q1).arg(L.faq_a1)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q2).arg(L.faq_a2)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q3).arg(L.faq_a3)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q4).arg(L.faq_a4)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q5).arg(L.faq_a5)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q6).arg(L.faq_a6)
|
||||
+ QString("<div class='q'>%1</div><div class='a'>%2</div>").arg(L.faq_q7).arg(L.faq_a7)
|
||||
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <intrin.h>
|
||||
#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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 <windows.h>
|
||||
#include <shellapi.h>
|
||||
#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,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
#pragma once
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <obs-frontend-api.h>
|
||||
#include <util/threading.h>
|
||||
#include <util/platform.h>
|
||||
#include <util/dstr.h>
|
||||
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/avutil.h>
|
||||
#include <libavutil/imgutils.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libavutil/channel_layout.h>
|
||||
#include <libswscale/swscale.h>
|
||||
#include <libswresample/swresample.h>
|
||||
|
||||
#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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,46 @@
|
||||
#include "obfuscation.h"
|
||||
|
||||
static constexpr char K = 0x5A;
|
||||
|
||||
template<unsigned N>
|
||||
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<unsigned N>
|
||||
static void xor_dec(char *out, const XorStr<N> &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")
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* 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
|
||||
@@ -0,0 +1,423 @@
|
||||
#include <obs-module.h>
|
||||
#include <obs-frontend-api.h>
|
||||
#include <util/threading.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <curl/curl.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <iphlpapi.h>
|
||||
#else
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#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);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
#include "remote-settings.h"
|
||||
#include "ingest-thread.h"
|
||||
#include "obfuscation.h"
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <obs-frontend-api.h>
|
||||
#include <util/threading.h>
|
||||
#include <util/platform.h>
|
||||
#include <util/dstr.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
/* ---- 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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,648 @@
|
||||
#include "srtla-server.h"
|
||||
#include <util/platform.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <util/threading.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#define SRTLA_SOCKET SOCKET
|
||||
#define SRTLA_INVALID_SOCKET INVALID_SOCKET
|
||||
#else
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#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);
|
||||
@@ -0,0 +1,347 @@
|
||||
#include <QDockWidget>
|
||||
#include <QMainWindow>
|
||||
#include <QWidget>
|
||||
#include <QLabel>
|
||||
#include <QGridLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QTimer>
|
||||
#include <QFont>
|
||||
#include <QPalette>
|
||||
|
||||
#include <obs-frontend-api.h>
|
||||
#include <util/platform.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void stats_dialog_show(const char *locale);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <obs.h>
|
||||
|
||||
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";
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
#include "webhook.h"
|
||||
#include <obs-module.h>
|
||||
#include <util/threading.h>
|
||||
#include <util/platform.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
typedef SOCKET sock_t;
|
||||
#define SOCK_INVALID INVALID_SOCKET
|
||||
#define sock_close closesocket
|
||||
#else
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user