4 Commits

Author SHA1 Message Date
Nils 9890848c6c Refactor build script and update project version; enhance webhook functionality
- Removed OBS installation prompt from build.ps1.
- Updated project version from 1.1.0 to 1.0.0 in CMakeLists.txt.
- Added new webhook event data structure and enhanced webhook sending functionality in webhook.c.
- Integrated video statistics tracking in event-handler.c and media-decoder.c.
- Added IP address display in stats-dialog.cpp.
- Improved URL handling and response management in webhook.c.
2026-04-03 14:46:56 +02:00
Nils 73659ce0a0 Update README.md to reflect new AI model name 2026-03-30 20:49:29 +02:00
Nils f8b992ca1d Enhance README.md with AI-Powered Development section
Added a new section highlighting the project's AI-assisted development using Anthropic Claude 4.6, emphasizing its AI-friendly nature.
2026-03-30 20:48:29 +02:00
Nils 75eff0dd29 Update FUNDING.yml with Patreon username
Added Patreon username for funding support.
2026-03-29 20:50:41 +02:00
11 changed files with 169 additions and 174 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: stoolscc
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.16...3.28) cmake_minimum_required(VERSION 3.16...3.28)
project(easy-irl-stream VERSION 1.1.0 LANGUAGES C CXX) project(easy-irl-stream VERSION 1.0.0 LANGUAGES C CXX)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
+4
View File
@@ -17,6 +17,10 @@ OBS Studio plugin for IRL streamers. Receives an RTMP or SRT stream directly in
For setup instructions, FAQ and downloads visit **[stools.cc/p/easy-irl-stream](https://stools.cc/p/easy-irl-stream)**. For setup instructions, FAQ and downloads visit **[stools.cc/p/easy-irl-stream](https://stools.cc/p/easy-irl-stream)**.
## AI-Powered Development
This project was built with the support of **Anthropic Claude Opus 4.6**. We embrace AI-assisted development — this is an AI-friendly project. The future is now.
## License ## License
This project is licensed under the [GNU General Public License v2.0](LICENSE). This project is licensed under the [GNU General Public License v2.0](LICENSE).
-17
View File
@@ -107,23 +107,6 @@ if (Test-Path $dll) {
Write-Host "" Write-Host ""
Write-Host "BUILD SUCCESSFUL: easy-irl-stream.dll ($size KB)" -ForegroundColor Green Write-Host "BUILD SUCCESSFUL: easy-irl-stream.dll ($size KB)" -ForegroundColor Green
Write-Host "Output: $dll" Write-Host "Output: $dll"
Write-Host ""
$install = Read-Host "Install to OBS? (y/n)"
if ($install -eq "y") {
$obsPluginDir = "C:\Program Files\obs-studio\obs-plugins\64bit"
$obsDataDir = "C:\Program Files\obs-studio\data\obs-plugins\easy-irl-stream\locale"
$curlDll = "$OBS_DEPS\bin\libcurl.dll"
$script = @"
Copy-Item '$dll' '$obsPluginDir\easy-irl-stream.dll' -Force
New-Item -ItemType Directory -Force -Path '$obsDataDir' | Out-Null
Copy-Item '$ROOT\data\locale\en-US.ini' '$obsDataDir\en-US.ini' -Force
Copy-Item '$ROOT\data\locale\de-DE.ini' '$obsDataDir\de-DE.ini' -Force
if (Test-Path '$curlDll') { Copy-Item '$curlDll' '$obsPluginDir\libcurl.dll' -Force }
"@
Start-Process powershell -Verb RunAs -ArgumentList "-NoProfile -Command $script" -Wait
Write-Host "Installed." -ForegroundColor Green
}
} else { } else {
Write-Host "BUILD FAILED" -ForegroundColor Red Write-Host "BUILD FAILED" -ForegroundColor Red
exit 1 exit 1
+35 -8
View File
@@ -1,5 +1,24 @@
#include "event-handler.h" #include "event-handler.h"
#include "webhook.h" #include "webhook.h"
#include <util/platform.h>
static struct webhook_event_data snapshot_event_data(struct irl_source_data *data)
{
struct webhook_event_data ed = {0};
ed.bitrate_kbps = data->current_bitrate_kbps;
ed.video_width = data->stats_video_width;
ed.video_height = data->stats_video_height;
ed.video_codec = data->stats_video_codec[0]
? data->stats_video_codec
: NULL;
uint64_t conn_ns = data->stats_connect_time_ns;
if (conn_ns > 0) {
uint64_t now = os_gettime_ns();
ed.uptime_sec = (int64_t)((now - conn_ns) / 1000000000ULL);
}
return ed;
}
/* ---- queued tasks executed on the UI thread ---- */ /* ---- queued tasks executed on the UI thread ---- */
@@ -118,8 +137,10 @@ static void fire_low_quality_actions(struct irl_source_data *data)
queue_scene_switch(scene); queue_scene_switch(scene);
queue_overlay(overlay, true); queue_overlay(overlay, true);
if (webhook && webhook[0]) if (webhook && webhook[0]) {
webhook_send_async(webhook, "low_quality", src_copy); struct webhook_event_data ed = snapshot_event_data(data);
webhook_send_async(webhook, "low_quality", src_copy, &ed);
}
if (cmd && cmd[0]) if (cmd && cmd[0])
webhook_execute_command_async(cmd); webhook_execute_command_async(cmd);
@@ -153,8 +174,10 @@ static void fire_quality_recovered_actions(struct irl_source_data *data)
queue_scene_switch(scene); queue_scene_switch(scene);
queue_overlay(overlay, false); queue_overlay(overlay, false);
if (webhook && webhook[0]) if (webhook && webhook[0]) {
webhook_send_async(webhook, "quality_recovered", src_copy); struct webhook_event_data ed = snapshot_event_data(data);
webhook_send_async(webhook, "quality_recovered", src_copy, &ed);
}
bfree(scene); bfree(scene);
bfree(overlay); bfree(overlay);
@@ -193,8 +216,10 @@ static void fire_disconnect_actions(struct irl_source_data *data)
queue_overlay(overlay, true); queue_overlay(overlay, true);
queue_recording(rec_action); queue_recording(rec_action);
if (webhook && webhook[0]) if (webhook && webhook[0]) {
webhook_send_async(webhook, "disconnect", src_copy); struct webhook_event_data ed = snapshot_event_data(data);
webhook_send_async(webhook, "disconnect", src_copy, &ed);
}
if (cmd && cmd[0]) if (cmd && cmd[0])
webhook_execute_command_async(cmd); webhook_execute_command_async(cmd);
@@ -229,8 +254,10 @@ static void fire_reconnect_actions(struct irl_source_data *data)
queue_scene_switch(scene); queue_scene_switch(scene);
queue_overlay(overlay, false); queue_overlay(overlay, false);
if (webhook && webhook[0]) if (webhook && webhook[0]) {
webhook_send_async(webhook, "reconnect", src_copy); struct webhook_event_data ed = snapshot_event_data(data);
webhook_send_async(webhook, "reconnect", src_copy, &ed);
}
if (cmd && cmd[0]) if (cmd && cmd[0])
webhook_execute_command_async(cmd); webhook_execute_command_async(cmd);
+5
View File
@@ -47,6 +47,9 @@ static void build_url(struct irl_source_data *data, char *buf, size_t sz)
} }
} }
if (data->srt_streamid && data->srt_streamid[0])
dstr_catf(&url, "&streamid=%s", data->srt_streamid);
snprintf(buf, sz, "%s", url.array); snprintf(buf, sz, "%s", url.array);
dstr_free(&url); dstr_free(&url);
} }
@@ -152,6 +155,8 @@ static void *ingest_thread_func(void *arg)
data->stats_connect_time_ns = os_gettime_ns(); data->stats_connect_time_ns = os_gettime_ns();
data->stats_total_frames = 0; data->stats_total_frames = 0;
data->stats_total_bytes = 0; data->stats_total_bytes = 0;
data->dec_vid_pkt_count = 0;
data->dec_vid_frame_count = 0;
event_handler_on_connect(data); event_handler_on_connect(data);
AVPacket *pkt = av_packet_alloc(); AVPacket *pkt = av_packet_alloc();
+4
View File
@@ -115,6 +115,10 @@ struct irl_source_data {
char *webhook_url; char *webhook_url;
char *custom_command; char *custom_command;
/* Decoder debug counters (per-source) */
int dec_vid_pkt_count;
int dec_vid_frame_count;
/* Stats (written by ingest thread, read by UI) */ /* Stats (written by ingest thread, read by UI) */
char stats_video_codec[32]; char stats_video_codec[32];
char stats_audio_codec[32]; char stats_audio_codec[32];
+10 -11
View File
@@ -304,32 +304,30 @@ static void output_audio_frame(struct irl_source_data *data, AVFrame *frame)
bool decoder_decode_packet(struct irl_source_data *data, AVPacket *pkt) 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 && if (pkt->stream_index == data->video_stream_idx &&
data->video_dec_ctx) { data->video_dec_ctx) {
int send_ret = int send_ret =
avcodec_send_packet(data->video_dec_ctx, pkt); avcodec_send_packet(data->video_dec_ctx, pkt);
vid_pkt_count++; data->dec_vid_pkt_count++;
if (send_ret < 0) { if (send_ret < 0) {
if (vid_pkt_count <= 5) if (data->dec_vid_pkt_count <= 5)
blog(LOG_WARNING, blog(LOG_WARNING,
"[%s] avcodec_send_packet failed: %d (pkt #%d, size=%d)", "[%s] avcodec_send_packet failed: %d (pkt #%d, size=%d)",
PLUGIN_NAME, send_ret, PLUGIN_NAME, send_ret,
vid_pkt_count, pkt->size); data->dec_vid_pkt_count, pkt->size);
return true; return true;
} }
AVFrame *frame = av_frame_alloc(); AVFrame *frame = av_frame_alloc();
while (avcodec_receive_frame(data->video_dec_ctx, frame) == 0) { while (avcodec_receive_frame(data->video_dec_ctx, frame) == 0) {
vid_frame_count++; data->dec_vid_frame_count++;
if (vid_frame_count <= 3 || if (data->dec_vid_frame_count <= 3 ||
(vid_frame_count % 300 == 0)) (data->dec_vid_frame_count % 300 == 0))
blog(LOG_DEBUG, blog(LOG_DEBUG,
"[%s] Video frame #%d decoded (fmt=%d %dx%d)", "[%s] Video frame #%d decoded (fmt=%d %dx%d)",
PLUGIN_NAME, vid_frame_count, PLUGIN_NAME,
data->dec_vid_frame_count,
frame->format, frame->format,
frame->width, frame->height); frame->width, frame->height);
output_video_frame(data, frame); output_video_frame(data, frame);
@@ -338,7 +336,8 @@ bool decoder_decode_packet(struct irl_source_data *data, AVPacket *pkt)
} }
av_frame_free(&frame); av_frame_free(&frame);
if (vid_pkt_count == 30 && vid_frame_count == 0) if (data->dec_vid_pkt_count == 30 &&
data->dec_vid_frame_count == 0)
blog(LOG_WARNING, blog(LOG_WARNING,
"[%s] 30 video packets sent but 0 frames decoded", "[%s] 30 video packets sent but 0 frames decoded",
PLUGIN_NAME); PLUGIN_NAME);
+15 -1
View File
@@ -82,7 +82,7 @@ private:
bool m_de; bool m_de;
QLabel *m_dot, *m_status; QLabel *m_dot, *m_status;
QLabel *m_lbls[4], *m_vals[4]; QLabel *m_lbls[4], *m_vals[4];
QLabel *m_videoLine, *m_audioLine, *m_serverLine; QLabel *m_videoLine, *m_audioLine, *m_serverLine, *m_ipLine;
QTimer *m_timer; QTimer *m_timer;
int64_t m_prevFrames = 0; int64_t m_prevFrames = 0;
uint64_t m_prevTime = 0; uint64_t m_prevTime = 0;
@@ -181,9 +181,11 @@ private:
m_videoLine = makeLabel("-", -1, false, ""); m_videoLine = makeLabel("-", -1, false, "");
m_audioLine = makeLabel("-", -1, false, ""); m_audioLine = makeLabel("-", -1, false, "");
m_serverLine = makeLabel("-", -1, false, dim); m_serverLine = makeLabel("-", -1, false, dim);
m_ipLine = makeLabel("-", -1, false, dim);
root->addWidget(m_videoLine); root->addWidget(m_videoLine);
root->addWidget(m_audioLine); root->addWidget(m_audioLine);
root->addWidget(m_serverLine); root->addWidget(m_serverLine);
root->addWidget(m_ipLine);
root->addStretch(); root->addStretch();
} }
@@ -288,6 +290,17 @@ private:
s += QString(" \u00b7 SRTLA \u2713 (:%1)") s += QString(" \u00b7 SRTLA \u2713 (:%1)")
.arg(srtla_p); .arg(srtla_p);
m_serverLine->setText(s); m_serverLine->setText(s);
QString ip_text;
if (g_local_ip[0] && g_external_ip[0])
ip_text = QString("LAN: %1 \u00b7 WAN: %2")
.arg(g_local_ip)
.arg(g_external_ip);
else if (g_local_ip[0])
ip_text = QString("LAN: %1").arg(g_local_ip);
else
ip_text = "-";
m_ipLine->setText(ip_text);
} }
void setNoSource() void setNoSource()
@@ -301,6 +314,7 @@ private:
m_videoLine->setText("-"); m_videoLine->setText("-");
m_audioLine->setText("-"); m_audioLine->setText("-");
m_serverLine->setText("-"); m_serverLine->setText("-");
m_ipLine->setText("-");
m_fps = 0; m_fps = 0;
m_prevFrames = 0; m_prevFrames = 0;
m_prevTime = 0; m_prevTime = 0;
+67 -134
View File
@@ -6,183 +6,116 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <time.h> #include <time.h>
#include <curl/curl.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 { struct webhook_args {
char *url; char *url;
char *event_name; char *json_body;
char *source_name;
}; };
struct cmd_args { struct cmd_args {
char *command; char *command;
}; };
static bool parse_url(const char *url, char *host, size_t host_sz, static size_t discard_response(void *ptr, size_t size, size_t nmemb,
char *port, size_t port_sz, char *path, size_t path_sz) void *userdata)
{ {
const char *p = url; (void)ptr;
(void)userdata;
if (strncmp(p, "http://", 7) == 0) { return size * nmemb;
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, static void webhook_do_send(const char *url, const char *json_body)
const char *source_name)
{ {
char host[256] = {0}; CURL *curl = curl_easy_init();
char port_str[16] = {0}; if (!curl) {
char path[512] = {0}; blog(LOG_WARNING, "[%s] Webhook: curl_easy_init failed",
"Easy IRL Stream");
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; return;
} }
#ifdef _WIN32 struct curl_slist *headers = NULL;
WSADATA wsa; headers = curl_slist_append(headers, "Content-Type: application/json");
WSAStartup(MAKEWORD(2, 2), &wsa);
#endif
struct addrinfo hints = {0}; curl_easy_setopt(curl, CURLOPT_URL, url);
struct addrinfo *res = NULL; curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
hints.ai_family = AF_INET; curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
hints.ai_socktype = SOCK_STREAM; curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, discard_response);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "easy-irl-stream-webhook/1.0");
if (getaddrinfo(host, port_str, &hints, &res) != 0) { CURLcode res = curl_easy_perform(curl);
blog(LOG_WARNING, "[%s] Webhook: DNS lookup failed for '%s'", if (res != CURLE_OK) {
"Easy IRL Stream", host); blog(LOG_WARNING, "[%s] Webhook failed (%s): %s",
return; "Easy IRL Stream", url, curl_easy_strerror(res));
} else {
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
blog(LOG_DEBUG, "[%s] Webhook sent: %s (HTTP %ld)",
"Easy IRL Stream", url, http_code);
} }
sock_t sock = socket(res->ai_family, res->ai_socktype, curl_slist_free_all(headers);
res->ai_protocol); curl_easy_cleanup(curl);
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) static void *webhook_thread_func(void *arg)
{ {
struct webhook_args *wa = arg; struct webhook_args *wa = arg;
webhook_do_send(wa->url, wa->event_name, wa->source_name); webhook_do_send(wa->url, wa->json_body);
bfree(wa->url); bfree(wa->url);
bfree(wa->event_name); bfree(wa->json_body);
bfree(wa->source_name);
bfree(wa); bfree(wa);
return NULL; return NULL;
} }
static char *build_json_body(const char *event_name, const char *source_name,
const struct webhook_event_data *extra)
{
char buf[1024];
if (extra) {
snprintf(buf, sizeof(buf),
"{\"event\":\"%s\",\"source\":\"%s\","
"\"timestamp\":%lld,"
"\"bitrate_kbps\":%lld,"
"\"uptime_sec\":%lld,"
"\"video_width\":%d,"
"\"video_height\":%d,"
"\"video_codec\":\"%s\"}",
event_name, source_name, (long long)time(NULL),
(long long)extra->bitrate_kbps,
(long long)extra->uptime_sec,
extra->video_width, extra->video_height,
extra->video_codec ? extra->video_codec : "");
} else {
snprintf(buf, sizeof(buf),
"{\"event\":\"%s\",\"source\":\"%s\","
"\"timestamp\":%lld}",
event_name, source_name, (long long)time(NULL));
}
return bstrdup(buf);
}
void webhook_send_async(const char *url, const char *event_name, void webhook_send_async(const char *url, const char *event_name,
const char *source_name) const char *source_name,
const struct webhook_event_data *extra)
{ {
if (!url || !url[0]) if (!url || !url[0])
return; return;
struct webhook_args *wa = bzalloc(sizeof(*wa)); struct webhook_args *wa = bzalloc(sizeof(*wa));
wa->url = bstrdup(url); wa->url = bstrdup(url);
wa->event_name = bstrdup(event_name); wa->json_body = build_json_body(event_name, source_name, extra);
wa->source_name = bstrdup(source_name);
pthread_t thread; pthread_t thread;
if (pthread_create(&thread, NULL, webhook_thread_func, wa) == 0) { if (pthread_create(&thread, NULL, webhook_thread_func, wa) == 0) {
pthread_detach(thread); pthread_detach(thread);
} else { } else {
bfree(wa->url); bfree(wa->url);
bfree(wa->event_name); bfree(wa->json_body);
bfree(wa->source_name);
bfree(wa); bfree(wa);
} }
} }
+12 -1
View File
@@ -1,6 +1,17 @@
#pragma once #pragma once
#include <stdint.h>
struct webhook_event_data {
int64_t bitrate_kbps;
int64_t uptime_sec;
int video_width;
int video_height;
const char *video_codec;
};
void webhook_send_async(const char *url, const char *event_name, void webhook_send_async(const char *url, const char *event_name,
const char *source_name); const char *source_name,
const struct webhook_event_data *extra);
void webhook_execute_command_async(const char *command); void webhook_execute_command_async(const char *command);