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