Initial commit

Made-with: Cursor
This commit is contained in:
Nils
2026-03-29 20:45:07 +02:00
commit 10be82cba5
30 changed files with 5085 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
name: Build & Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 1.2.0)'
required: true
env:
OBS_VERSION: '32.1.0'
OBS_DEPS_VERSION: '2025-08-23'
PLUGIN_NAME: 'easy-irl-stream'
jobs:
# ============================================================
# Windows Build
# ============================================================
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Setup MSVC
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Determine version
id: version
shell: bash
run: |
if [ "${{ github.event_name }}" == "push" ]; then
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
else
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Clone OBS headers
shell: bash
run: |
git clone --depth 1 --branch ${{ env.OBS_VERSION }} \
--filter=blob:none --sparse \
https://github.com/obsproject/obs-studio.git deps/obs-studio
cd deps/obs-studio
git sparse-checkout set libobs frontend/api deps/w32-pthreads
- name: Create obsconfig.h
shell: powershell
run: |
@"
#pragma once
#define OBS_DATA_PATH "data"
#define OBS_PLUGIN_PATH "obs-plugins/64bit"
#define OBS_PLUGIN_DESTINATION "obs-plugins/64bit"
#define OBS_INSTALL_PREFIX "C:/Program Files/obs-studio"
#define OBS_RELEASE_CANDIDATE 0
#define OBS_BETA 0
"@ | Out-File -Encoding ASCII "deps/obs-studio/libobs/obsconfig.h"
- name: Download OBS deps (FFmpeg)
shell: powershell
run: |
$url = "https://github.com/obsproject/obs-deps/releases/download/${{ env.OBS_DEPS_VERSION }}/windows-deps-${{ env.OBS_DEPS_VERSION }}-x64.zip"
Invoke-WebRequest -Uri $url -OutFile deps/obs-deps.zip -UseBasicParsing
Expand-Archive -Path deps/obs-deps.zip -DestinationPath deps/obs-deps -Force
- name: Download Qt6 deps
shell: powershell
run: |
$url = "https://github.com/obsproject/obs-deps/releases/download/${{ env.OBS_DEPS_VERSION }}/windows-deps-qt6-${{ env.OBS_DEPS_VERSION }}-x64.zip"
Invoke-WebRequest -Uri $url -OutFile deps/qt6.zip -UseBasicParsing
Expand-Archive -Path deps/qt6.zip -DestinationPath deps/qt6 -Force
- name: Download OBS binaries
shell: powershell
run: |
$url = "https://github.com/obsproject/obs-studio/releases/download/${{ env.OBS_VERSION }}/OBS-Studio-${{ env.OBS_VERSION }}-Windows-x64.zip"
Invoke-WebRequest -Uri $url -OutFile deps/obs-bin.zip -UseBasicParsing
Expand-Archive -Path deps/obs-bin.zip -DestinationPath deps/obs-bin -Force
- name: Generate import libraries
shell: powershell
run: |
$outDir = "deps/obs-libs"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Write-Host "=== OBS binary directory structure ==="
Get-ChildItem deps/obs-bin -Recurse -Directory | ForEach-Object { Write-Host $_.FullName }
foreach ($dllName in @("obs", "obs-frontend-api", "w32-pthreads")) {
$dll = Get-ChildItem -Path "deps/obs-bin" -Filter "$dllName.dll" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $dll) { throw "Could not find $dllName.dll in deps/obs-bin" }
Write-Host "Found: $($dll.FullName)"
$raw = (& dumpbin /exports $dll.FullName 2>&1) | Out-String
$lines = $raw -split "`r?`n"
$defLines = @("LIBRARY ""$dllName""", "EXPORTS")
$capture = $false
foreach ($line in $lines) {
if ($line -match "ordinal\s+hint\s+RVA\s+name") { $capture = $true; continue }
if ($capture -and $line -match "^\s*Summary") { break }
if ($capture -and $line -match "^\s+(\d+)\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\S+)") {
$defLines += (" " + $Matches[2])
}
}
$exportCount = $defLines.Count - 2
Write-Host " $dllName exports: $exportCount"
if ($exportCount -lt 1) { throw "No exports found in $dllName.dll - dumpbin parsing failed" }
$defLines -join "`n" | Out-File -Encoding ASCII "$outDir/$dllName.def" -NoNewline
& lib /nologo /def:"$outDir/$dllName.def" /out:"$outDir/$dllName.lib" /machine:x64
if (-not (Test-Path "$outDir/$dllName.lib")) { throw "Failed to create $dllName.lib" }
$libSize = [math]::Round((Get-Item "$outDir/$dllName.lib").Length / 1KB, 1)
Write-Host " $dllName.lib size: $libSize KB"
}
- name: Build plugin
shell: powershell
run: |
cmake -S . -B build -G Ninja `
-DCMAKE_BUILD_TYPE=RelWithDebInfo `
-DCMAKE_C_COMPILER=cl `
-DCMAKE_CXX_COMPILER=cl `
-DOBS_SOURCE_DIR="deps/obs-studio" `
-DOBS_LIB_DIR="deps/obs-libs" `
-DFFMPEG_DIR="deps/obs-deps" `
-DQT6_DIR="deps/qt6" `
-DPLUGIN_VERSION_OVERRIDE="${{ steps.version.outputs.version }}"
cmake --build build --config RelWithDebInfo
- name: Create portable zip
shell: powershell
run: |
$ver = "${{ steps.version.outputs.version }}"
New-Item -ItemType Directory -Force "release" | Out-Null
$dir = "staging/easy-irl-stream"
New-Item -ItemType Directory -Force "$dir/obs-plugins/64bit" | Out-Null
Copy-Item "build/easy-irl-stream.dll" "$dir/obs-plugins/64bit/"
Compress-Archive -Path "$dir/*" -DestinationPath "release/easy-irl-stream-$ver-windows-x64.zip"
- name: Build installer
shell: powershell
run: |
choco install innosetup -y --no-progress | Out-Null
$ver = "${{ steps.version.outputs.version }}"
& "C:\Program Files (x86)\Inno Setup 6\iscc.exe" `
/DMyAppVersion=$ver installer/installer.iss
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: windows-release
path: release/*
# ============================================================
# Linux Build
# ============================================================
build-linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Determine version
id: version
shell: bash
run: |
if [ "${{ github.event_name }}" == "push" ]; then
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
else
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Install dependencies
run: |
sudo add-apt-repository ppa:obsproject/obs-studio -y
sudo apt-get update
sudo apt-get install -y \
libobs-dev \
libavformat-dev libavcodec-dev libavutil-dev \
libswscale-dev libswresample-dev \
qt6-base-dev \
libcurl4-openssl-dev libxkbcommon-dev \
cmake ninja-build pkg-config
- name: Setup OBS frontend headers
id: frontend
run: |
# Use system header if available, otherwise clone matching version
if pkg-config --cflags obs-frontend-api 2>/dev/null; then
echo "Found obs-frontend-api via pkg-config"
echo "cmake_arg=" >> $GITHUB_OUTPUT
elif [ -f /usr/include/obs/obs-frontend-api.h ]; then
echo "Found obs-frontend-api.h in system includes"
echo "cmake_arg=" >> $GITHUB_OUTPUT
else
SYS_VER=$(pkg-config --modversion libobs | cut -d. -f1-3)
echo "Cloning OBS $SYS_VER frontend headers..."
git clone --depth 1 --branch "$SYS_VER" \
--filter=blob:none --sparse \
https://github.com/obsproject/obs-studio.git deps/obs-studio
cd deps/obs-studio
git sparse-checkout set frontend/api
echo "cmake_arg=-DOBS_FRONTEND_INCLUDE_DIR=deps/obs-studio/frontend/api" >> $GITHUB_OUTPUT
fi
- name: Build plugin
run: |
cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DPLUGIN_VERSION_OVERRIDE="${{ steps.version.outputs.version }}" \
${{ steps.frontend.outputs.cmake_arg }}
cmake --build build
- name: Package
run: |
ver="${{ steps.version.outputs.version }}"
mkdir -p release
mkdir -p staging/obs-plugins
cp build/easy-irl-stream.so staging/obs-plugins/
cd staging
tar czf ../release/easy-irl-stream-$ver-linux-x86_64.tar.gz *
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: linux-release
path: release/*
# ============================================================
# Create GitHub Release
# ============================================================
release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Determine version
id: version
shell: bash
run: |
if [ "${{ github.event_name }}" == "push" ]; then
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
else
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
echo "tag=v${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
path: artifacts
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Easy IRL Stream v${{ steps.version.outputs.version }}
draft: false
prerelease: false
generate_release_notes: true
body: |
## Installation
### Windows (Installer — recommended)
1. Download **`easy-irl-stream-${{ steps.version.outputs.version }}-windows-installer.exe`**
2. Run the installer — it auto-detects your OBS installation
3. Restart OBS
### Windows (Manual)
1. Download **`easy-irl-stream-${{ steps.version.outputs.version }}-windows-x64.zip`**
2. Extract into your OBS Studio folder (e.g. `C:\Program Files\obs-studio\`)
3. Restart OBS
### Linux
1. Download **`easy-irl-stream-${{ steps.version.outputs.version }}-linux-x86_64.tar.gz`**
2. Extract to `/usr/lib/obs-plugins/` and `/usr/share/obs/obs-plugins/`
3. Restart OBS
files: |
artifacts/windows-release/*
artifacts/linux-release/*
+12
View File
@@ -0,0 +1,12 @@
build/
deps/
staging/
release/
cmake-build-*/
.vs/
.vscode/
*.user
CMakeUserPresets.json
out/
install-now.bat
.cache/
+166
View File
@@ -0,0 +1,166 @@
cmake_minimum_required(VERSION 3.16...3.28)
project(easy-irl-stream VERSION 1.1.0 LANGUAGES C CXX)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE RelWithDebInfo)
endif()
# --- Plugin sources ---
add_library(easy-irl-stream MODULE
src/plugin-main.c
src/irl-source.c
src/ingest-thread.c
src/media-decoder.c
src/event-handler.c
src/webhook.c
src/srtla-server.c
src/remote-settings.c
src/obfuscation.cpp
src/help-dialog.cpp
src/stats-dialog.cpp
)
if(WIN32)
# ================================================================
# Windows: explicit dependency paths (set by build.ps1 or CI)
# ================================================================
set(OBS_SOURCE_DIR "" CACHE PATH "Path to obs-studio source tree (for headers)")
set(OBS_LIB_DIR "" CACHE PATH "Path to OBS import libraries (.lib)")
set(FFMPEG_DIR "" CACHE PATH "Path to FFmpeg dev package (include + lib)")
set(QT6_DIR "" CACHE PATH "Path to Qt6 dev package (include + lib)")
add_library(obs_lib SHARED IMPORTED)
set_target_properties(obs_lib PROPERTIES
IMPORTED_IMPLIB "${OBS_LIB_DIR}/obs.lib"
)
add_library(obs_frontend SHARED IMPORTED)
set_target_properties(obs_frontend PROPERTIES
IMPORTED_IMPLIB "${OBS_LIB_DIR}/obs-frontend-api.lib"
)
add_library(w32_pthreads SHARED IMPORTED)
set_target_properties(w32_pthreads PROPERTIES
IMPORTED_IMPLIB "${OBS_LIB_DIR}/w32-pthreads.lib"
)
foreach(_qt_mod Core Gui Widgets)
add_library(Qt6::${_qt_mod} SHARED IMPORTED)
set_target_properties(Qt6::${_qt_mod} PROPERTIES
IMPORTED_IMPLIB "${QT6_DIR}/lib/Qt6${_qt_mod}.lib"
)
endforeach()
target_include_directories(easy-irl-stream PRIVATE
"${OBS_SOURCE_DIR}/libobs"
"${OBS_SOURCE_DIR}/frontend/api"
"${OBS_SOURCE_DIR}/deps/w32-pthreads"
"${FFMPEG_DIR}/include"
"${QT6_DIR}/include"
"${QT6_DIR}/include/QtCore"
"${QT6_DIR}/include/QtGui"
"${QT6_DIR}/include/QtWidgets"
)
target_link_libraries(easy-irl-stream
obs_lib
obs_frontend
w32_pthreads
ws2_32
iphlpapi
shell32
Qt6::Core
Qt6::Gui
Qt6::Widgets
"${FFMPEG_DIR}/lib/avformat.lib"
"${FFMPEG_DIR}/lib/avcodec.lib"
"${FFMPEG_DIR}/lib/avutil.lib"
"${FFMPEG_DIR}/lib/swscale.lib"
"${FFMPEG_DIR}/lib/swresample.lib"
"${FFMPEG_DIR}/lib/libcurl_imp.lib"
)
target_compile_definitions(easy-irl-stream PRIVATE _CRT_SECURE_NO_WARNINGS)
target_compile_options(easy-irl-stream PRIVATE /Zc:__cplusplus /permissive- /utf-8)
else()
# ================================================================
# Linux / macOS: find system packages
# ================================================================
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBOBS REQUIRED IMPORTED_TARGET libobs)
pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat)
pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec)
pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil)
pkg_check_modules(SWSCALE REQUIRED IMPORTED_TARGET libswscale)
pkg_check_modules(SWRESAMPLE REQUIRED IMPORTED_TARGET libswresample)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets)
find_package(CURL REQUIRED)
# obs-frontend-api: try pkg-config first, fall back to find_library
pkg_check_modules(OBS_FRONTEND IMPORTED_TARGET obs-frontend-api)
if(OBS_FRONTEND_FOUND)
set(_OBS_FRONTEND_TARGET PkgConfig::OBS_FRONTEND)
else()
find_library(OBS_FRONTEND_LIB obs-frontend-api)
if(OBS_FRONTEND_LIB)
add_library(obs_frontend_imported SHARED IMPORTED)
set_target_properties(obs_frontend_imported PROPERTIES
IMPORTED_LOCATION "${OBS_FRONTEND_LIB}"
)
set(_OBS_FRONTEND_TARGET obs_frontend_imported)
else()
message(WARNING "obs-frontend-api not found, linking by name")
set(_OBS_FRONTEND_TARGET obs-frontend-api)
endif()
endif()
# Optional: extra include dir for obs-frontend-api headers
set(OBS_FRONTEND_INCLUDE_DIR "" CACHE PATH
"Path to obs-frontend-api headers (frontend/api)")
if(OBS_FRONTEND_INCLUDE_DIR)
target_include_directories(easy-irl-stream PRIVATE
"${OBS_FRONTEND_INCLUDE_DIR}")
endif()
target_link_libraries(easy-irl-stream
PkgConfig::LIBOBS
${_OBS_FRONTEND_TARGET}
PkgConfig::AVFORMAT
PkgConfig::AVCODEC
PkgConfig::AVUTIL
PkgConfig::SWSCALE
PkgConfig::SWRESAMPLE
Qt6::Core
Qt6::Gui
Qt6::Widgets
CURL::libcurl
pthread
)
# Install targets
include(GNUInstallDirs)
install(TARGETS easy-irl-stream
LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}/obs-plugins")
endif()
if(DEFINED PLUGIN_VERSION_OVERRIDE)
set(_PLUGIN_VER "${PLUGIN_VERSION_OVERRIDE}")
else()
set(_PLUGIN_VER "${PROJECT_VERSION}")
endif()
target_compile_definitions(easy-irl-stream PRIVATE
PLUGIN_VERSION="${_PLUGIN_VER}"
)
set_target_properties(easy-irl-stream PROPERTIES PREFIX "")
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{description}
Copyright (C) {year} {fullname}
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
{signature of Ty Coon}, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+22
View File
@@ -0,0 +1,22 @@
# Easy IRL Stream
OBS Studio plugin for IRL streamers. Receives an RTMP or SRT stream directly in OBS and automatically reacts to connection events.
## Features
- Built-in RTMP/SRT server — no external server needed
- Automatic scene switching on disconnect or low quality
- Overlay control, recording control, webhooks & custom commands
- SRTLA support (bond WiFi + mobile data)
- Real-time stream monitor dock
- DuckDNS integration
- Cross-platform (Windows, macOS, Linux)
## Download & Documentation
For setup instructions, FAQ and downloads visit **[stools.cc/p/easy-irl-stream](https://stools.cc/p/easy-irl-stream)**.
## License
This project is licensed under the [GNU General Public License v2.0](LICENSE).
+130
View File
@@ -0,0 +1,130 @@
$ErrorActionPreference = "Stop"
$ROOT = $PSScriptRoot
# --- VS Developer Environment ---
$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
if (-not $vsPath) { throw "Visual Studio not found" }
Import-Module "$vsPath\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vsPath -DevCmdArguments "-arch=x64 -host_arch=x64" -SkipAutomaticLocation
$OBS_BIN = "C:\Program Files\obs-studio\bin\64bit"
$DEPS_DIR = "$ROOT\deps"
$OBS_SRC = "$DEPS_DIR\obs-studio"
$OBS_DEPS = "$DEPS_DIR\obs-deps"
$QT6_DIR = "$DEPS_DIR\qt6"
$OBS_LIBS = "$DEPS_DIR\obs-libs"
$BUILD_DIR = "$ROOT\build"
$OBS_VERSION = "32.1.0"
# --- 1. OBS headers (sparse clone) ---
if (-not (Test-Path "$OBS_SRC\libobs\obs-module.h")) {
Write-Host "[1/5] Cloning OBS Studio headers..."
git clone --depth 1 --branch $OBS_VERSION --filter=blob:none --sparse `
"https://github.com/obsproject/obs-studio.git" $OBS_SRC
Push-Location $OBS_SRC
git sparse-checkout set libobs frontend/api deps/w32-pthreads
Pop-Location
@"
#pragma once
#define OBS_DATA_PATH "data"
#define OBS_PLUGIN_PATH "obs-plugins/64bit"
#define OBS_PLUGIN_DESTINATION "obs-plugins/64bit"
#define OBS_INSTALL_PREFIX "C:/Program Files/obs-studio"
#define OBS_RELEASE_CANDIDATE 0
#define OBS_BETA 0
"@ | Out-File -Encoding ASCII "$OBS_SRC\libobs\obsconfig.h"
} else {
Write-Host "[1/5] OBS headers: OK"
}
# --- 2. OBS deps (FFmpeg headers + libs) ---
$depsZip = "$DEPS_DIR\windows-deps-x64.zip"
if (-not (Test-Path "$OBS_DEPS\lib\avformat.lib")) {
Write-Host "[2/5] Downloading OBS deps..."
New-Item -ItemType Directory -Force -Path $DEPS_DIR | Out-Null
$depsUrl = "https://github.com/obsproject/obs-deps/releases/download/2025-08-23/windows-deps-2025-08-23-x64.zip"
Invoke-WebRequest -Uri $depsUrl -OutFile $depsZip -UseBasicParsing
Expand-Archive -Path $depsZip -DestinationPath $OBS_DEPS -Force
} else {
Write-Host "[2/5] OBS deps: OK"
}
# --- 3. Qt6 deps (headers + libs) ---
$qt6Zip = "$DEPS_DIR\windows-deps-qt6-x64.zip"
if (-not (Test-Path "$QT6_DIR\include\QtWidgets")) {
Write-Host "[3/5] Downloading Qt6 deps..."
New-Item -ItemType Directory -Force -Path $QT6_DIR | Out-Null
$qt6Url = "https://github.com/obsproject/obs-deps/releases/download/2025-08-23/windows-deps-qt6-2025-08-23-x64.zip"
Invoke-WebRequest -Uri $qt6Url -OutFile $qt6Zip -UseBasicParsing
Expand-Archive -Path $qt6Zip -DestinationPath $QT6_DIR -Force
Write-Host " Qt6 deps downloaded."
} else {
Write-Host "[3/5] Qt6 deps: OK"
}
# --- 4. Generate OBS import libraries ---
if (-not (Test-Path "$OBS_LIBS\obs.lib") -or (Get-Item "$OBS_LIBS\obs.lib").Length -lt 10000) {
Write-Host "[4/5] Generating import libraries..."
New-Item -ItemType Directory -Force -Path $OBS_LIBS | Out-Null
foreach ($name in @("obs", "obs-frontend-api", "w32-pthreads")) {
$raw = (& dumpbin /exports "$OBS_BIN\$name.dll" 2>&1) | Out-String
$lines = $raw -split "`r?`n"
$defLines = @("LIBRARY `"$name`"", "EXPORTS")
$capture = $false
foreach ($line in $lines) {
if ($line -match "ordinal\s+hint\s+RVA\s+name") { $capture = $true; continue }
if ($capture -and $line -match "Summary") { break }
if ($capture -and $line -match "^\s+(\d+)\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\S+)") {
$defLines += " $($Matches[2])"
}
}
$defLines -join "`n" | Out-File -Encoding ASCII "$OBS_LIBS\$name.def" -NoNewline
& lib /nologo /def:"$OBS_LIBS\$name.def" /out:"$OBS_LIBS\$name.lib" /machine:x64 2>$null | Out-Null
}
} else {
Write-Host "[4/5] Import libraries: OK"
}
# --- 5. Build ---
Write-Host "[5/5] Building..."
cmake -S $ROOT -B $BUILD_DIR -G "Ninja" `
-DCMAKE_BUILD_TYPE=RelWithDebInfo `
-DCMAKE_C_COMPILER=cl `
-DCMAKE_CXX_COMPILER=cl `
-DOBS_SOURCE_DIR="$OBS_SRC" `
-DOBS_LIB_DIR="$OBS_LIBS" `
-DFFMPEG_DIR="$OBS_DEPS" `
-DQT6_DIR="$QT6_DIR"
cmake --build $BUILD_DIR --config RelWithDebInfo
$dll = "$BUILD_DIR\easy-irl-stream.dll"
if (Test-Path $dll) {
$size = [math]::Round((Get-Item $dll).Length / 1KB, 1)
Write-Host ""
Write-Host "BUILD SUCCESSFUL: easy-irl-stream.dll ($size KB)" -ForegroundColor Green
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 {
Write-Host "BUILD FAILED" -ForegroundColor Red
exit 1
}
+10
View File
@@ -0,0 +1,10 @@
@echo off
echo Creating firewall rules for Easy IRL Stream...
netsh advfirewall firewall add rule name="Easy IRL Stream - SRT (UDP)" dir=in action=allow protocol=UDP localport=9000 profile=private,public
netsh advfirewall firewall add rule name="Easy IRL Stream - RTMP (TCP)" dir=in action=allow protocol=TCP localport=1935 profile=private,public
netsh advfirewall firewall add rule name="Easy IRL Stream - SRTLA (UDP)" dir=in action=allow protocol=UDP localport=5000 profile=private,public
echo.
echo Done! SRT (UDP 9000), RTMP (TCP 1935) and SRTLA (UDP 5000) are now allowed.
pause
+78
View File
@@ -0,0 +1,78 @@
#ifndef MyAppVersion
#define MyAppVersion "dev"
#endif
#define MyAppName "Easy IRL Stream"
#define MyAppPublisher "Easy IRL Stream"
#define MyAppURL "https://github.com/nils-kt/Easy-IRL-Stream"
[Setup]
AppId={{B5E8A3D1-C7F2-4A96-9E5D-1F3B8A6C0D4E}
AppName={#MyAppName} (OBS Plugin)
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
DefaultDirName={code:GetOBSDir}
DirExistsWarning=no
DisableProgramGroupPage=yes
OutputDir=release
OutputBaseFilename=easy-irl-stream-{#MyAppVersion}-windows-installer
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64os
ArchitecturesInstallIn64BitMode=x64os
PrivilegesRequired=admin
UninstallDisplayName={#MyAppName} (OBS Plugin)
SourceDir=..
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Files]
Source: "build\easy-irl-stream.dll"; DestDir: "{app}\obs-plugins\64bit"; Flags: ignoreversion
[UninstallDelete]
Type: filesandordirs; Name: "{app}\data\obs-plugins\easy-irl-stream"
[Messages]
english.WelcomeLabel2=This will install the {#MyAppName} plugin for OBS Studio.%n%nPlease close OBS Studio before continuing.
german.WelcomeLabel2=Dies installiert das {#MyAppName} Plugin f%C3%BCr OBS Studio.%n%nBitte schlie%C3%9Fe OBS Studio vor der Installation.
[Code]
function GetOBSDir(Param: String): String;
var
Path: String;
begin
if RegQueryStringValue(HKLM, 'SOFTWARE\OBS Studio', '', Path) then
Result := Path
else
Result := ExpandConstant('{autopf}\obs-studio');
end;
function IsOBSRunning(): Boolean;
var
ResultCode: Integer;
begin
Exec('tasklist', '/FI "IMAGENAME eq obs64.exe" /NH', '', SW_HIDE,
ewWaitUntilTerminated, ResultCode);
Result := (ResultCode = 0);
end;
function InitializeSetup(): Boolean;
begin
Result := True;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
if FindWindowByClassName('OBSMainWindow') <> 0 then
begin
MsgBox('OBS Studio is currently running. Please close it before continuing.', mbError, MB_OK);
Abort;
end;
end;
end;
+372
View File
@@ -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;
}
+9
View File
@@ -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);
+390
View File
@@ -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&uuml;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 &ouml;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&uuml;fen</b><br>"
"Beim ersten Start fragt Windows nach. Falls nicht:<br>"
"Windows-Suche &rarr; <i>Windows Defender Firewall</i> &rarr; "
"<i>Erweiterte Einstellungen</i> &rarr; <i>Eingehende Regeln</i> &rarr; "
"<i>Neue Regel</i> &rarr; Port &rarr; TCP/UDP &rarr; Port eingeben &rarr; 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&ouml;tig! "
"Einfach die lokale IP verwenden: <code>%1</code>",
"DuckDNS (Dynamisches DNS)",
"Deine externe IP &auml;ndert sich regelm&auml;&szlig;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&auml;ufige Fragen",
"Mein Handy kann sich nicht verbinden &ndash; was tun?",
"1. Plugin in OBS aktiv? (Quelle muss in einer Szene sein)<br>"
"2. Im gleichen WLAN? &rarr; Lokale IP verwenden<br>"
"3. &Uuml;ber Mobilfunk? &rarr; Port-Weiterleitung einrichten<br>"
"4. Windows-Firewall &rarr; Port freigeben<br>"
"5. Port + Protokoll korrekt? RTMP = TCP:1935, SRT = UDP:9000",
"Was ist besser &ndash; RTMP oder SRT?",
"<b>SRT</b> ist besser f&uuml;r Mobilfunk (eingebaute Fehlerkorrektur, konfigurierbare Latenz).<br>"
"<b>RTMP</b> ist einfacher und wird von mehr Streaming-Apps unterst&uuml;tzt.<br>"
"<i>Empfehlung:</i> SRT f&uuml;r IRL-Streaming, RTMP als Fallback.<br>"
"<b>Hinweis:</b> Die SRT-Passphrase muss <b>10&ndash;79 Zeichen</b> lang sein (SRT-Protokoll-Vorgabe).",
"Wie funktionieren Overlays?",
"Erstelle eine Quelle (Bild/Text) in deiner Szene &rarr; Blende sie mit dem "
"<b>Auge-Symbol</b> aus &rarr; W&auml;hle sie im Plugin als Overlay-Quelle aus &rarr; "
"Das Plugin blendet sie automatisch ein/aus.",
"Was bedeutet &bdquo;Schwellenwert (kbps)&ldquo;?",
"Die minimale Bitrate, ab der die Verbindung als &bdquo;schlecht&ldquo; gilt. "
"Standard: <code>500 kbps</code>. Liegt die Bitrate darunter, werden die "
"konfigurierten Qualit&auml;ts-Aktionen ausgel&ouml;st (Overlay, Szenenwechsel&hellip;).",
"Unterschied Disconnect vs. schlechte Qualit&auml;t?",
"<b>Disconnect:</b> Verbindung komplett weg &ndash; kein Stream kommt an.<br>"
"<b>Schlechte Qualit&auml;t:</b> Stream kommt noch an, aber Bitrate ist zu niedrig.<br>"
"F&uuml;r beide k&ouml;nnen unterschiedliche Aktionen und Overlays konfiguriert werden.",
"Meine externe IP &auml;ndert sich st&auml;ndig?",
"Nutze DuckDNS (siehe oben). Dann hast du eine feste Adresse wie "
"<code>meinstream.duckdns.org</code>.",
"SRTLA (Link Aggregation)",
"SRTLA erm&ouml;glicht Apps wie <b>Moblin</b>, WLAN und Mobilfunk <b>gleichzeitig</b> "
"zu nutzen. Die Verbindung wird dadurch deutlich stabiler &ndash; f&auml;llt ein Netzwerk aus, "
"l&auml;uft der Stream &uuml;ber das andere weiter.",
"Auf <a href='https://stools.cc/dashboard/plugin'>stools.cc</a>: <b>SRT</b> als Protokoll w&auml;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>&lt;DEINE_IP&gt;:5000</code> eingeben "
"(den SRTLA-Port, <b>nicht</b> den SRT-Port!)",
"Was ist SRTLA?",
"<b>SRTLA</b> (SRT Link Aggregation) b&uuml;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 &rarr; <i>Windows Defender Firewall</i> &rarr; "
"<i>Advanced Settings</i> &rarr; <i>Inbound Rules</i> &rarr; "
"<i>New Rule</i> &rarr; Port &rarr; TCP/UDP &rarr; Enter port &rarr; 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&apos;t connect &ndash; what to do?",
"1. Plugin active in OBS? (source must be in a scene)<br>"
"2. Same WiFi? &rarr; Use local IP<br>"
"3. On mobile data? &rarr; Set up port forwarding<br>"
"4. Windows Firewall &rarr; Allow the port<br>"
"5. Port + protocol correct? RTMP = TCP:1935, SRT = UDP:9000",
"Which is better &ndash; 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&ndash;79 characters</b> long (SRT protocol requirement).",
"How do overlays work?",
"Create a source (image/text) in your scene &rarr; Hide it with the "
"<b>eye icon</b> &rarr; Select it as overlay source in the plugin &rarr; "
"The plugin shows/hides it automatically.",
"What does &quot;threshold (kbps)&quot; mean?",
"The minimum bitrate below which the connection is considered &quot;bad&quot;. "
"Default: <code>500 kbps</code>. If the bitrate drops below this, the "
"configured quality actions are triggered (overlay, scene switch&hellip;).",
"Difference between disconnect and bad quality?",
"<b>Disconnect:</b> Connection completely lost &ndash; 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 &ndash; 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>&lt;YOUR_IP&gt;: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);
}
}
+14
View File
@@ -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
+223
View File
@@ -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);
}
+6
View File
@@ -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);
+266
View File
@@ -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,
};
+136
View File
@@ -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;
+364
View File
@@ -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;
}
+7
View File
@@ -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);
+46
View File
@@ -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")
+33
View File
@@ -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
+423
View File
@@ -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);
}
+390
View File
@@ -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);
}
+13
View File
@@ -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);
+648
View File
@@ -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;
}
+76
View File
@@ -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);
+347
View File
@@ -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();
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
void stats_dialog_show(const char *locale);
#ifdef __cplusplus
}
#endif
+49
View File
@@ -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
View File
@@ -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);
}
}
+6
View File
@@ -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);