commit 10be82cba59d2a25cc28463d3501e75b28ca9b61 Author: Nils <34674720+nils-kt@users.noreply.github.com> Date: Sun Mar 29 20:45:07 2026 +0200 Initial commit Made-with: Cursor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..903f773 --- /dev/null +++ b/.github/workflows/release.yml @@ -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/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..359ce86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +build/ +deps/ +staging/ +release/ +cmake-build-*/ +.vs/ +.vscode/ +*.user +CMakeUserPresets.json +out/ +install-now.bat +.cache/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..85258b1 --- /dev/null +++ b/CMakeLists.txt @@ -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 "") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b8a5cd --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f00d35 --- /dev/null +++ b/README.md @@ -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). diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..d9c471a --- /dev/null +++ b/build.ps1 @@ -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 +} diff --git a/firewall-setup.bat b/firewall-setup.bat new file mode 100644 index 0000000..d07b619 --- /dev/null +++ b/firewall-setup.bat @@ -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 diff --git a/installer/installer.iss b/installer/installer.iss new file mode 100644 index 0000000..cb6f1f6 --- /dev/null +++ b/installer/installer.iss @@ -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; diff --git a/src/event-handler.c b/src/event-handler.c new file mode 100644 index 0000000..1afe3a6 --- /dev/null +++ b/src/event-handler.c @@ -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; +} diff --git a/src/event-handler.h b/src/event-handler.h new file mode 100644 index 0000000..a01fcaf --- /dev/null +++ b/src/event-handler.h @@ -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); diff --git a/src/help-dialog.cpp b/src/help-dialog.cpp new file mode 100644 index 0000000..1a22408 --- /dev/null +++ b/src/help-dialog.cpp @@ -0,0 +1,390 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "help-dialog.hpp" +#include "obfuscation.h" + +static QDialog *g_help_dlg = nullptr; +static QTextBrowser *g_browser = nullptr; + +struct HelpStrings { + const char *title; + const char *your_network; + const char *local_ip_label; + const char *external_ip_label; + const char *port_fwd; + const char *port_fwd_intro; + const char *step1; + const char *step2; + const char *step3; + const char *step4; + const char *same_wifi_note; + const char *duckdns_title; + const char *duckdns_intro; + const char *duck_step1; + const char *duck_step2; + const char *duck_step3; + const char *duck_step4; + const char *duck_step5; + const char *duck_example; + const char *faq_title; + const char *faq_q1; + const char *faq_a1; + const char *faq_q2; + const char *faq_a2; + const char *faq_q3; + const char *faq_a3; + const char *faq_q4; + const char *faq_a4; + const char *faq_q5; + const char *faq_a5; + const char *faq_q6; + const char *faq_a6; + const char *srtla_title; + const char *srtla_intro; + const char *srtla_step1; + const char *srtla_step2; + const char *srtla_step3; + const char *srtla_step4; + const char *faq_q7; + const char *faq_a7; +}; + +static const HelpStrings LANG_DE = { + "Easy IRL Stream", + "Deine Netzwerk-Informationen", + "Lokale IP (im gleichen WLAN)", + "Externe IP (für Mobilfunk / unterwegs)", + "Port-Weiterleitung einrichten", + "Damit dein Handy von unterwegs (Mobilfunk) streamen kann, " + "muss der Port im Router weitergeleitet werden:", + "Router-Konfiguration öffnen
" + "Fritz!Box: http://fritz.box
" + "Telekom: http://192.168.2.1
" + "Andere: http://192.168.1.1", + "Port-Weiterleitung einrichten
" + "Externer Port: Dein Plugin-Port (Standard: 1935 / 9000)
" + "Interner Port: Der gleiche Port
" + "Protokoll: TCP (RTMP) oder UDP (SRT)
" + "Ziel-IP: %1 (dieser PC)", + "Windows-Firewall prüfen
" + "Beim ersten Start fragt Windows nach. Falls nicht:
" + "Windows-Suche → Windows Defender Firewall → " + "Erweiterte EinstellungenEingehende Regeln → " + "Neue Regel → Port → TCP/UDP → Port eingeben → Zulassen", + "Am Handy verbinden
" + "Als Server-IP die externe IP verwenden: %1", + "Im gleichen WLAN? Keine Port-Weiterleitung nötig! " + "Einfach die lokale IP verwenden: %1", + "DuckDNS (Dynamisches DNS)", + "Deine externe IP ändert sich regelmäßig. " + "Mit DuckDNS bekommst du eine feste Adresse:", + "Gehe zu duckdns.org und erstelle ein Konto", + "Erstelle eine Subdomain (z.B. meinstream)", + "Kopiere deinen Token", + "Trage Subdomain + Token auf stools.cc unter DuckDNS ein", + "Das Plugin aktualisiert deine IP automatisch!", + "Dein Handy verbindet sich dann z.B. mit:", + "Häufige Fragen", + "Mein Handy kann sich nicht verbinden – was tun?", + "1. Plugin in OBS aktiv? (Quelle muss in einer Szene sein)
" + "2. Im gleichen WLAN? → Lokale IP verwenden
" + "3. Über Mobilfunk? → Port-Weiterleitung einrichten
" + "4. Windows-Firewall → Port freigeben
" + "5. Port + Protokoll korrekt? RTMP = TCP:1935, SRT = UDP:9000", + "Was ist besser – RTMP oder SRT?", + "SRT ist besser für Mobilfunk (eingebaute Fehlerkorrektur, konfigurierbare Latenz).
" + "RTMP ist einfacher und wird von mehr Streaming-Apps unterstützt.
" + "Empfehlung: SRT für IRL-Streaming, RTMP als Fallback.
" + "Hinweis: Die SRT-Passphrase muss 10–79 Zeichen lang sein (SRT-Protokoll-Vorgabe).", + "Wie funktionieren Overlays?", + "Erstelle eine Quelle (Bild/Text) in deiner Szene → Blende sie mit dem " + "Auge-Symbol aus → Wähle sie im Plugin als Overlay-Quelle aus → " + "Das Plugin blendet sie automatisch ein/aus.", + "Was bedeutet „Schwellenwert (kbps)“?", + "Die minimale Bitrate, ab der die Verbindung als „schlecht“ gilt. " + "Standard: 500 kbps. Liegt die Bitrate darunter, werden die " + "konfigurierten Qualitäts-Aktionen ausgelöst (Overlay, Szenenwechsel…).", + "Unterschied Disconnect vs. schlechte Qualität?", + "Disconnect: Verbindung komplett weg – kein Stream kommt an.
" + "Schlechte Qualität: Stream kommt noch an, aber Bitrate ist zu niedrig.
" + "Für beide können unterschiedliche Aktionen und Overlays konfiguriert werden.", + "Meine externe IP ändert sich ständig?", + "Nutze DuckDNS (siehe oben). Dann hast du eine feste Adresse wie " + "meinstream.duckdns.org.", + "SRTLA (Link Aggregation)", + "SRTLA ermöglicht Apps wie Moblin, WLAN und Mobilfunk gleichzeitig " + "zu nutzen. Die Verbindung wird dadurch deutlich stabiler – fällt ein Netzwerk aus, " + "läuft der Stream über das andere weiter.", + "Auf stools.cc: SRT als Protokoll wählen und SRTLA aktivieren", + "SRTLA-Port merken (Standard: 5000)", + "In Moblin: Protokoll auf SRT(LA) stellen", + "Als Server-Adresse <DEINE_IP>:5000 eingeben " + "(den SRTLA-Port, nicht den SRT-Port!)", + "Was ist SRTLA?", + "SRTLA (SRT Link Aggregation) bündelt mehrere Netzwerkverbindungen " + "(z.B. WLAN + Mobilfunk) zu einer einzigen. Das Plugin startet einen SRTLA-Proxy, " + "der die Pakete entgegennimmt und an den internen SRT-Server weiterleitet.
" + "Standard-Ports: SRTLA = UDP 5000, SRT = UDP 9000
" + "Wichtig: In Moblin den SRTLA-Port (5000) angeben, nicht den SRT-Port (9000)!", +}; + +static const HelpStrings LANG_EN = { + "Easy IRL Stream", + "Your Network Information", + "Local IP (same WiFi network)", + "External IP (for mobile / remote)", + "Port Forwarding Setup", + "For your phone to stream remotely (mobile data), " + "you need to set up port forwarding in your router:", + "Open router configuration
" + "Common addresses: http://192.168.1.1 or http://192.168.0.1", + "Set up port forwarding
" + "External port: Your plugin port (default: 1935 / 9000)
" + "Internal port: Same port
" + "Protocol: TCP (RTMP) or UDP (SRT)
" + "Target IP: %1 (this PC)", + "Check Windows Firewall
" + "Windows should ask on first launch. If not:
" + "Windows Search → Windows Defender Firewall → " + "Advanced SettingsInbound Rules → " + "New Rule → Port → TCP/UDP → Enter port → Allow", + "Connect your phone
" + "Use the external IP as server address: %1", + "Same WiFi? No port forwarding needed! " + "Just use the local IP: %1", + "DuckDNS (Dynamic DNS)", + "Your external IP changes regularly. " + "With DuckDNS you get a fixed address:", + "Go to duckdns.org and create an account", + "Create a subdomain (e.g. mystream)", + "Copy your Token", + "Enter subdomain + token on stools.cc under DuckDNS", + "The plugin updates your IP automatically!", + "Your phone then connects to e.g.:", + "Frequently Asked Questions", + "My phone can't connect – what to do?", + "1. Plugin active in OBS? (source must be in a scene)
" + "2. Same WiFi? → Use local IP
" + "3. On mobile data? → Set up port forwarding
" + "4. Windows Firewall → Allow the port
" + "5. Port + protocol correct? RTMP = TCP:1935, SRT = UDP:9000", + "Which is better – RTMP or SRT?", + "SRT is better for mobile (built-in error correction, configurable latency).
" + "RTMP is simpler and supported by more streaming apps.
" + "Recommendation: SRT for IRL streaming, RTMP as fallback.
" + "Note: The SRT passphrase must be 10–79 characters long (SRT protocol requirement).", + "How do overlays work?", + "Create a source (image/text) in your scene → Hide it with the " + "eye icon → Select it as overlay source in the plugin → " + "The plugin shows/hides it automatically.", + "What does "threshold (kbps)" mean?", + "The minimum bitrate below which the connection is considered "bad". " + "Default: 500 kbps. If the bitrate drops below this, the " + "configured quality actions are triggered (overlay, scene switch…).", + "Difference between disconnect and bad quality?", + "Disconnect: Connection completely lost – no stream arriving.
" + "Bad quality: Stream still arriving, but bitrate is too low.
" + "Different actions and overlays can be configured for each.", + "My external IP keeps changing?", + "Use DuckDNS (see above). Then you have a fixed address like " + "mystream.duckdns.org.", + "SRTLA (Link Aggregation)", + "SRTLA allows apps like Moblin to use WiFi and mobile data simultaneously. " + "This makes the connection much more stable – if one network drops, " + "the stream continues over the other.", + "On stools.cc: Select SRT as protocol and enable SRTLA", + "Note the SRTLA port (default: 5000)", + "In Moblin: Set protocol to SRT(LA)", + "Enter <YOUR_IP>:5000 as server address " + "(the SRTLA port, not the SRT port!)", + "What is SRTLA?", + "SRTLA (SRT Link Aggregation) bonds multiple network connections " + "(e.g. WiFi + mobile data) into one. The plugin runs an SRTLA proxy that " + "receives the packets and forwards them to the internal SRT server.
" + "Default ports: SRTLA = UDP 5000, SRT = UDP 9000
" + "Important: In Moblin, enter the SRTLA port (5000), not the SRT port (9000)!", +}; + +static QString build_html(const char *local_ip, const char *external_ip, + const char *version, const HelpStrings &L) +{ + QString lip = local_ip && local_ip[0] ? local_ip : "?.?.?.?"; + QString eip = external_ip && external_ip[0] + ? external_ip + : "..."; + + QWidget *w = QApplication::activeWindow(); + QPalette pal = w ? w->palette() : QApplication::palette(); + + QString bg = pal.color(QPalette::Base).name(); + QString fg = pal.color(QPalette::Text).name(); + QString bg2 = pal.color(QPalette::AlternateBase).name(); + QString accent = pal.color(QPalette::Highlight).name(); + QString dimmed = pal.color(QPalette::PlaceholderText).name(); + QString link = pal.color(QPalette::Link).name(); + + return QString( + "" + "") + .arg(bg, fg, dimmed, bg2, accent, link) + + + QString("

%1

Version %2
").arg(L.title).arg(version) + + + QString("

%1

").arg(L.your_network) + + QString("
%1
" + "
%2
") + .arg(L.local_ip_label) + .arg(lip) + + QString("
%1
" + "
%2
") + .arg(L.external_ip_label) + .arg(eip) + + + QString("

%1

%2

").arg(L.port_fwd).arg(L.port_fwd_intro) + + QString("
    " + "
  1. %1
  2. " + "
  3. %2
  4. " + "
  5. %3
  6. " + "
  7. %4
  8. " + "
") + .arg(L.step1) + .arg(QString(L.step2).arg(lip)) + .arg(L.step3) + .arg(QString(L.step4).arg(eip)) + + QString("
%1
").arg(QString(L.same_wifi_note).arg(lip)) + + + QString("

%1

%2

").arg(L.duckdns_title).arg(L.duckdns_intro) + + QString("
  1. %1
  2. %2
  3. %3
  4. %4
  5. %5
") + .arg(L.duck_step1) + .arg(L.duck_step2) + .arg(L.duck_step3) + .arg(L.duck_step4) + .arg(L.duck_step5) + + QString("

%1
rtmp://meinstream.duckdns.org:1935/live

").arg(L.duck_example) + + + QString("

%1

%2

").arg(L.srtla_title).arg(L.srtla_intro) + + QString("
  1. %1
  2. %2
  3. %3
  4. %4
") + .arg(L.srtla_step1) + .arg(L.srtla_step2) + .arg(L.srtla_step3) + .arg(L.srtla_step4) + + + QString("

%1

").arg(L.faq_title) + + QString("
%1
%2
").arg(L.faq_q1).arg(L.faq_a1) + + QString("
%1
%2
").arg(L.faq_q2).arg(L.faq_a2) + + QString("
%1
%2
").arg(L.faq_q3).arg(L.faq_a3) + + QString("
%1
%2
").arg(L.faq_q4).arg(L.faq_a4) + + QString("
%1
%2
").arg(L.faq_q5).arg(L.faq_a5) + + QString("
%1
%2
").arg(L.faq_q6).arg(L.faq_a6) + + QString("
%1
%2
").arg(L.faq_q7).arg(L.faq_a7) + + + ""; +} + +extern "C" void help_dialog_show(const char *local_ip, + const char *external_ip, + const char *version, + const char *locale) +{ + bool is_de = locale && (strncmp(locale, "de", 2) == 0); + const HelpStrings &L = is_de ? LANG_DE : LANG_EN; + + if (g_help_dlg) { + g_browser->setHtml( + build_html(local_ip, external_ip, version, L)); + g_help_dlg->show(); + g_help_dlg->raise(); + g_help_dlg->activateWindow(); + return; + } + + QWidget *parent = (QWidget *)obs_frontend_get_main_window(); + + g_help_dlg = new QDialog(parent); + g_help_dlg->setWindowTitle( + QString("Easy IRL Stream %1 Help & FAQ") + .arg(QChar(0x2014))); + g_help_dlg->resize(580, 700); + g_help_dlg->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(g_help_dlg, &QDialog::destroyed, []() { + g_help_dlg = nullptr; + g_browser = nullptr; + }); + + g_browser = new QTextBrowser(g_help_dlg); + g_browser->setOpenExternalLinks(true); + g_browser->setHtml(build_html(local_ip, external_ip, version, L)); + + QVBoxLayout *layout = new QVBoxLayout(g_help_dlg); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(g_browser); + + g_help_dlg->show(); +} + +static void open_url(const char *url) +{ + QDesktopServices::openUrl(QUrl(QString::fromUtf8(url))); +} + +extern "C" void update_dialog_show(const char *new_version, const char *locale) +{ + bool is_de = locale && (strncmp(locale, "de", 2) == 0); + + QWidget *parent = (QWidget *)obs_frontend_get_main_window(); + + QString title = is_de ? QString::fromUtf8("Update verf\xc3\xbc""gbar") + : "Update Available"; + + QString text = is_de + ? QString::fromUtf8("Eine neue Version (%1) von Easy IRL Stream " + "ist verf\xc3\xbc""gbar!\n\n" + "M\xc3\xb6""chtest du die Download-Seite " + "\xc3\xb6""ffnen?") + .arg(new_version) + : QString("A new version (%1) of Easy IRL Stream is available!" + "\n\nWould you like to open the download page?") + .arg(new_version); + + QMessageBox::StandardButton reply = QMessageBox::information( + parent, title, text, + QMessageBox::Ok | QMessageBox::Cancel); + + if (reply == QMessageBox::Ok) { + char url[256]; + snprintf(url, sizeof(url), "%s%s%s", + obf_https_prefix(), obf_stools_host(), + obf_dash_downloads_path()); + open_url(url); + } +} diff --git a/src/help-dialog.hpp b/src/help-dialog.hpp new file mode 100644 index 0000000..939961a --- /dev/null +++ b/src/help-dialog.hpp @@ -0,0 +1,14 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +void help_dialog_show(const char *local_ip, const char *external_ip, + const char *version, const char *locale); + +void update_dialog_show(const char *new_version, const char *locale); + +#ifdef __cplusplus +} +#endif diff --git a/src/ingest-thread.c b/src/ingest-thread.c new file mode 100644 index 0000000..f8fded0 --- /dev/null +++ b/src/ingest-thread.c @@ -0,0 +1,223 @@ +#include "ingest-thread.h" +#include "media-decoder.h" +#include "event-handler.h" + +extern void duckdns_update(const char *domain, const char *token); + +#ifdef _WIN32 +#include +#define atomic_add_long(ptr, val) _InterlockedExchangeAdd((ptr), (val)) +#else +#define atomic_add_long(ptr, val) __sync_fetch_and_add((ptr), (val)) +#endif + +static int interrupt_cb(void *opaque) +{ + struct irl_source_data *data = opaque; + return !data->active ? 1 : 0; +} + +static void build_url(struct irl_source_data *data, char *buf, size_t sz) +{ + pthread_mutex_lock(&data->mutex); + + if (data->protocol == PROTOCOL_RTMP) { + const char *key = + (data->stream_key && data->stream_key[0]) + ? data->stream_key + : "stream"; + snprintf(buf, sz, "rtmp://0.0.0.0:%d/live/%s", data->port, + key); + } else { + struct dstr url; + dstr_init(&url); + dstr_printf(&url, "srt://0.0.0.0:%d?mode=listener&latency=%d", + data->port, data->srt_latency_ms * 1000); + + if (data->srt_passphrase && data->srt_passphrase[0]) { + size_t plen = strlen(data->srt_passphrase); + if (plen >= 10 && plen <= 79) { + dstr_catf(&url, "&passphrase=%s", + data->srt_passphrase); + } else { + blog(LOG_WARNING, + "[%s] SRT passphrase ignored: " + "must be 10-79 characters (got %zu)", + PLUGIN_NAME, plen); + } + } + + snprintf(buf, sz, "%s", url.array); + dstr_free(&url); + } + + pthread_mutex_unlock(&data->mutex); +} + +static void *ingest_thread_func(void *arg) +{ + struct irl_source_data *data = arg; + + os_set_thread_name("easy-irl-ingest"); + + /* Update DuckDNS on startup */ + pthread_mutex_lock(&data->mutex); + if (data->duckdns_domain && data->duckdns_domain[0] && + data->duckdns_token && data->duckdns_token[0]) { + char *dd = bstrdup(data->duckdns_domain); + char *dt = bstrdup(data->duckdns_token); + pthread_mutex_unlock(&data->mutex); + duckdns_update(dd, dt); + bfree(dd); + bfree(dt); + } else { + pthread_mutex_unlock(&data->mutex); + } + + /* Start SRTLA proxy if enabled and SRT selected */ + pthread_mutex_lock(&data->mutex); + bool start_srtla = data->srtla_enabled && + data->protocol == PROTOCOL_SRT; + int srtla_port = data->srtla_port; + int srt_port_val = data->port; + pthread_mutex_unlock(&data->mutex); + + if (start_srtla) + srtla_server_start(&data->srtla, srtla_port, srt_port_val); + + while (data->active) { + char url[1024]; + build_url(data, url, sizeof(url)); + + os_atomic_set_long(&data->connection_state, + CONN_STATE_LISTENING); + blog(LOG_DEBUG, "[%s] Listening: %s", PLUGIN_NAME, url); + + AVFormatContext *fmt_ctx = avformat_alloc_context(); + if (!fmt_ctx) { + os_sleep_ms(2000); + continue; + } + + fmt_ctx->interrupt_callback.callback = interrupt_cb; + fmt_ctx->interrupt_callback.opaque = data; + + AVDictionary *opts = NULL; + if (data->protocol == PROTOCOL_RTMP) + av_dict_set(&opts, "listen", "1", 0); + av_dict_set(&opts, "rw_timeout", "5000000", 0); + + int ret = avformat_open_input(&fmt_ctx, url, NULL, &opts); + av_dict_free(&opts); + + if (ret < 0) { + avformat_free_context(fmt_ctx); + if (!data->active) + break; + char errbuf[256]; + av_strerror(ret, errbuf, sizeof(errbuf)); + blog(LOG_WARNING, + "[%s] avformat_open_input failed: %s", + PLUGIN_NAME, errbuf); + os_sleep_ms(2000); + continue; + } + + data->fmt_ctx = fmt_ctx; + + ret = avformat_find_stream_info(fmt_ctx, NULL); + if (ret < 0) { + blog(LOG_WARNING, "[%s] Could not find stream info", + PLUGIN_NAME); + avformat_close_input(&data->fmt_ctx); + data->fmt_ctx = NULL; + if (!data->active) + break; + os_sleep_ms(2000); + continue; + } + + if (!decoder_open(data)) { + avformat_close_input(&data->fmt_ctx); + data->fmt_ctx = NULL; + if (!data->active) + break; + os_sleep_ms(2000); + continue; + } + + os_atomic_set_long(&data->connection_state, + CONN_STATE_CONNECTED); + data->last_frame_time_ns = os_gettime_ns(); + data->stats_connect_time_ns = os_gettime_ns(); + data->stats_total_frames = 0; + data->stats_total_bytes = 0; + event_handler_on_connect(data); + + AVPacket *pkt = av_packet_alloc(); + + while (data->active) { + ret = av_read_frame(fmt_ctx, pkt); + if (ret < 0) + break; + + atomic_add_long(&data->bytes_window, + (long)pkt->size); + data->stats_total_bytes += (uint64_t)pkt->size; + + decoder_decode_packet(data, pkt); + av_packet_unref(pkt); + } + + av_packet_free(&pkt); + decoder_close(data); + avformat_close_input(&data->fmt_ctx); + data->fmt_ctx = NULL; + + if (data->active) { + os_atomic_set_long(&data->connection_state, + CONN_STATE_DISCONNECTED); + data->stats_connect_time_ns = 0; + event_handler_on_disconnect(data); + os_sleep_ms(500); + } + } + + srtla_server_stop(&data->srtla); + + os_atomic_set_long(&data->connection_state, CONN_STATE_IDLE); + blog(LOG_DEBUG, "[%s] Ingest thread exited", PLUGIN_NAME); + return NULL; +} + +void ingest_thread_start(struct irl_source_data *data) +{ + if (data->thread_created) + ingest_thread_stop(data); + + data->active = true; + data->disconnect_actions_fired = false; + + if (pthread_create(&data->ingest_thread, NULL, ingest_thread_func, + data) == 0) { + data->thread_created = true; + } else { + blog(LOG_ERROR, "[%s] Failed to create ingest thread", + PLUGIN_NAME); + data->active = false; + } +} + +void ingest_thread_stop(struct irl_source_data *data) +{ + if (!data->thread_created) + return; + + data->active = false; + + pthread_join(data->ingest_thread, NULL); + data->thread_created = false; + + os_atomic_set_long(&data->connection_state, CONN_STATE_IDLE); + blog(LOG_DEBUG, "[%s] Ingest thread stopped", PLUGIN_NAME); +} diff --git a/src/ingest-thread.h b/src/ingest-thread.h new file mode 100644 index 0000000..57a501a --- /dev/null +++ b/src/ingest-thread.h @@ -0,0 +1,6 @@ +#pragma once + +#include "irl-source.h" + +void ingest_thread_start(struct irl_source_data *data); +void ingest_thread_stop(struct irl_source_data *data); diff --git a/src/irl-source.c b/src/irl-source.c new file mode 100644 index 0000000..38cbedf --- /dev/null +++ b/src/irl-source.c @@ -0,0 +1,266 @@ +#include "irl-source.h" +#include "ingest-thread.h" +#include "event-handler.h" +#include "remote-settings.h" +#include "obfuscation.h" +#include "translations.h" + +#ifdef _WIN32 +#include +#include +#endif + +struct irl_source_data *g_irl_sources[MAX_IRL_SOURCES] = {0}; +int g_irl_source_count = 0; + +/* ---- helpers ---- */ + +static inline void safe_bfree(char **ptr) +{ + bfree(*ptr); + *ptr = NULL; +} + +static void load_settings(struct irl_source_data *data, obs_data_t *settings) +{ + pthread_mutex_lock(&data->mutex); + + data->protocol = (int)obs_data_get_int(settings, "protocol"); + data->port = (int)obs_data_get_int(settings, "port"); + + safe_bfree(&data->stream_key); + data->stream_key = + bstrdup(obs_data_get_string(settings, "stream_key")); + + safe_bfree(&data->srt_passphrase); + data->srt_passphrase = + bstrdup(obs_data_get_string(settings, "srt_passphrase")); + + safe_bfree(&data->srt_streamid); + data->srt_streamid = + bstrdup(obs_data_get_string(settings, "srt_streamid")); + + data->srt_latency_ms = + (int)obs_data_get_int(settings, "srt_latency"); + + data->disconnect_timeout_sec = + (int)obs_data_get_int(settings, "disconnect_timeout"); + + safe_bfree(&data->disconnect_scene_name); + data->disconnect_scene_name = + bstrdup(obs_data_get_string(settings, "disconnect_scene")); + + safe_bfree(&data->reconnect_scene_name); + data->reconnect_scene_name = + bstrdup(obs_data_get_string(settings, "reconnect_scene")); + + safe_bfree(&data->overlay_source_name); + data->overlay_source_name = + bstrdup(obs_data_get_string(settings, "overlay_source")); + + data->disconnect_recording_action = + (int)obs_data_get_int(settings, "recording_action"); + + data->low_quality_enabled = + obs_data_get_bool(settings, "low_quality_enabled"); + data->low_quality_bitrate_kbps = + (int)obs_data_get_int(settings, "low_quality_bitrate"); + data->low_quality_timeout_sec = + (int)obs_data_get_int(settings, "low_quality_timeout"); + + safe_bfree(&data->low_quality_scene_name); + data->low_quality_scene_name = + bstrdup(obs_data_get_string(settings, "low_quality_scene")); + + safe_bfree(&data->low_quality_overlay_name); + data->low_quality_overlay_name = + bstrdup(obs_data_get_string(settings, "low_quality_overlay")); + + data->srtla_enabled = + obs_data_get_bool(settings, "srtla_enabled"); + data->srtla_port = + (int)obs_data_get_int(settings, "srtla_port"); + + safe_bfree(&data->duckdns_domain); + data->duckdns_domain = + bstrdup(obs_data_get_string(settings, "duckdns_domain")); + + safe_bfree(&data->duckdns_token); + data->duckdns_token = + bstrdup(obs_data_get_string(settings, "duckdns_token")); + + safe_bfree(&data->webhook_url); + data->webhook_url = + bstrdup(obs_data_get_string(settings, "webhook_url")); + + safe_bfree(&data->custom_command); + data->custom_command = + bstrdup(obs_data_get_string(settings, "custom_command")); + + pthread_mutex_unlock(&data->mutex); +} + +/* ---- source callbacks ---- */ + +static const char *irl_source_get_name(void *unused) +{ + UNUSED_PARAMETER(unused); + return tr_source_name(); +} + +static void *irl_source_create(obs_data_t *settings, obs_source_t *source) +{ + struct irl_source_data *data = bzalloc(sizeof(*data)); + data->source = source; + data->video_stream_idx = -1; + data->audio_stream_idx = -1; + data->active = true; + + pthread_mutex_init(&data->mutex, NULL); + + load_settings(data, settings); + ingest_thread_start(data); + + if (g_irl_source_count < MAX_IRL_SOURCES) + g_irl_sources[g_irl_source_count++] = data; + + remote_settings_start(data); + + return data; +} + +static void irl_source_destroy(void *vdata) +{ + struct irl_source_data *data = vdata; + + remote_settings_stop(data); + + for (int i = 0; i < g_irl_source_count; i++) { + if (g_irl_sources[i] == data) { + g_irl_sources[i] = + g_irl_sources[--g_irl_source_count]; + g_irl_sources[g_irl_source_count] = NULL; + break; + } + } + + data->active = false; + ingest_thread_stop(data); + + obs_source_output_video(data->source, NULL); + + safe_bfree(&data->stream_key); + safe_bfree(&data->srt_passphrase); + safe_bfree(&data->srt_streamid); + safe_bfree(&data->disconnect_scene_name); + safe_bfree(&data->reconnect_scene_name); + safe_bfree(&data->overlay_source_name); + safe_bfree(&data->low_quality_scene_name); + safe_bfree(&data->low_quality_overlay_name); + safe_bfree(&data->duckdns_domain); + safe_bfree(&data->duckdns_token); + safe_bfree(&data->webhook_url); + safe_bfree(&data->custom_command); + + pthread_mutex_destroy(&data->mutex); + bfree(data); +} + +static void irl_source_update(void *vdata, obs_data_t *settings) +{ + struct irl_source_data *data = vdata; + UNUSED_PARAMETER(settings); + UNUSED_PARAMETER(data); +} + +static void irl_source_get_defaults(obs_data_t *settings) +{ + obs_data_set_default_string(settings, "api_token", ""); + + obs_data_set_default_int(settings, "protocol", PROTOCOL_RTMP); + obs_data_set_default_int(settings, "port", 1935); + obs_data_set_default_string(settings, "stream_key", "stream"); + obs_data_set_default_string(settings, "srt_streamid", "stream"); + obs_data_set_default_int(settings, "srt_latency", 200); + obs_data_set_default_int(settings, "disconnect_timeout", 5); + obs_data_set_default_int(settings, "recording_action", + RECORDING_ACTION_NONE); + obs_data_set_default_bool(settings, "low_quality_enabled", false); + obs_data_set_default_int(settings, "low_quality_bitrate", 500); + obs_data_set_default_int(settings, "low_quality_timeout", 3); + obs_data_set_default_bool(settings, "srtla_enabled", false); + obs_data_set_default_int(settings, "srtla_port", 5000); +} + + +static bool login_button_clicked(obs_properties_t *props, obs_property_t *prop, + void *data) +{ + UNUSED_PARAMETER(props); + UNUSED_PARAMETER(prop); + UNUSED_PARAMETER(data); + + char url[256]; + snprintf(url, sizeof(url), "%s%s%s", + obf_https_prefix(), obf_stools_host(), + obf_dash_tools_path()); + +#ifdef _WIN32 + ShellExecuteA(NULL, "open", url, NULL, NULL, SW_SHOWNORMAL); +#elif __APPLE__ + char cmd[512]; + snprintf(cmd, sizeof(cmd), "open \"%s\"", url); + system(cmd); +#else + char cmd[512]; + snprintf(cmd, sizeof(cmd), "xdg-open \"%s\"", url); + system(cmd); +#endif + + return false; +} + +static obs_properties_t *irl_source_get_properties(void *vdata) +{ + UNUSED_PARAMETER(vdata); + + obs_properties_t *props = obs_properties_create(); + + obs_properties_add_text(props, "api_token", + tr_api_token(), + OBS_TEXT_PASSWORD); + + obs_properties_add_button(props, "login_button", + tr_login_button(), + login_button_clicked); + + obs_properties_add_text(props, "api_info", + tr_api_info(), + OBS_TEXT_INFO); + + return props; +} + +static void irl_source_video_tick(void *vdata, float seconds) +{ + UNUSED_PARAMETER(seconds); + struct irl_source_data *data = vdata; + event_handler_tick(data); +} + +/* ---- source info ---- */ + +struct obs_source_info irl_source_info = { + .id = SOURCE_ID, + .type = OBS_SOURCE_TYPE_INPUT, + .output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | + OBS_SOURCE_DO_NOT_DUPLICATE, + .get_name = irl_source_get_name, + .create = irl_source_create, + .destroy = irl_source_destroy, + .update = irl_source_update, + .get_defaults = irl_source_get_defaults, + .get_properties = irl_source_get_properties, + .video_tick = irl_source_video_tick, + .icon_type = OBS_ICON_TYPE_CAMERA, +}; diff --git a/src/irl-source.h b/src/irl-source.h new file mode 100644 index 0000000..181d3c6 --- /dev/null +++ b/src/irl-source.h @@ -0,0 +1,136 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "srtla-server.h" + +#define PLUGIN_NAME "Easy IRL Stream" +#define SOURCE_ID "easy_irl_stream_source" + +/* IP detection globals (filled by plugin-main.c on startup) */ +extern char g_local_ip[64]; +extern char g_external_ip[64]; + +#define PROTOCOL_RTMP 0 +#define PROTOCOL_SRT 1 + +#define RECORDING_ACTION_NONE 0 +#define RECORDING_ACTION_START 1 +#define RECORDING_ACTION_STOP 2 + +enum connection_state { + CONN_STATE_IDLE, + CONN_STATE_LISTENING, + CONN_STATE_CONNECTED, + CONN_STATE_DISCONNECTED, +}; + +struct irl_source_data { + obs_source_t *source; + + /* Settings */ + int protocol; + int port; + char *stream_key; + char *srt_passphrase; + char *srt_streamid; + int srt_latency_ms; + + /* Ingest thread */ + pthread_t ingest_thread; + volatile bool active; + bool thread_created; + + /* Connection state */ + volatile long connection_state; + uint64_t last_frame_time_ns; + + /* FFmpeg decoder context (owned by ingest thread) */ + AVFormatContext *fmt_ctx; + AVCodecContext *video_dec_ctx; + AVCodecContext *audio_dec_ctx; + int video_stream_idx; + int audio_stream_idx; + + /* Pixel-format conversion */ + struct SwsContext *sws_ctx; + int sws_width; + int sws_height; + enum AVPixelFormat sws_src_fmt; + uint8_t *video_dst_data[4]; + int video_dst_linesize[4]; + + /* Audio resampler */ + struct SwrContext *swr_ctx; + int swr_sample_rate; + int swr_channels; + + /* Bitrate tracking (written by ingest thread, read by tick) */ + volatile long bytes_window; + uint64_t last_bitrate_check_ns; + int64_t current_bitrate_kbps; + + /* Event handler settings: disconnect */ + int disconnect_timeout_sec; + char *disconnect_scene_name; + char *reconnect_scene_name; + char *overlay_source_name; + int disconnect_recording_action; + bool disconnect_actions_fired; + uint64_t disconnect_time_ns; + + /* Event handler settings: low quality */ + bool low_quality_enabled; + int low_quality_bitrate_kbps; + int low_quality_timeout_sec; + char *low_quality_scene_name; + char *low_quality_overlay_name; + bool low_quality_active; + bool low_quality_actions_fired; + uint64_t low_quality_start_ns; + + /* SRTLA */ + bool srtla_enabled; + int srtla_port; + struct srtla_state srtla; + + /* DuckDNS */ + char *duckdns_domain; + char *duckdns_token; + + /* Webhook / custom command */ + char *webhook_url; + char *custom_command; + + /* Stats (written by ingest thread, read by UI) */ + char stats_video_codec[32]; + char stats_audio_codec[32]; + char stats_video_pixfmt[32]; + int stats_video_width; + int stats_video_height; + int stats_audio_sample_rate; + uint64_t stats_connect_time_ns; + int64_t stats_total_frames; + uint64_t stats_total_bytes; + + pthread_mutex_t mutex; +}; + +#define MAX_IRL_SOURCES 8 +extern struct irl_source_data *g_irl_sources[MAX_IRL_SOURCES]; +extern int g_irl_source_count; + +extern struct obs_source_info irl_source_info; diff --git a/src/media-decoder.c b/src/media-decoder.c new file mode 100644 index 0000000..eff5e51 --- /dev/null +++ b/src/media-decoder.c @@ -0,0 +1,364 @@ +#include "media-decoder.h" + +bool decoder_open(struct irl_source_data *data) +{ + data->video_stream_idx = -1; + data->audio_stream_idx = -1; + + for (unsigned i = 0; i < data->fmt_ctx->nb_streams; i++) { + AVCodecParameters *par = data->fmt_ctx->streams[i]->codecpar; + + if (par->codec_type == AVMEDIA_TYPE_VIDEO && + data->video_stream_idx < 0) { + const AVCodec *codec = + avcodec_find_decoder(par->codec_id); + if (!codec) + continue; + + AVCodecContext *ctx = avcodec_alloc_context3(codec); + if (!ctx) + continue; + + avcodec_parameters_to_context(ctx, par); + ctx->thread_count = 2; + + if (avcodec_open2(ctx, codec, NULL) < 0) { + avcodec_free_context(&ctx); + continue; + } + + data->video_dec_ctx = ctx; + data->video_stream_idx = (int)i; + snprintf(data->stats_video_codec, + sizeof(data->stats_video_codec), "%s", + codec->name); + data->stats_video_width = par->width; + data->stats_video_height = par->height; + data->stats_video_pixfmt[0] = '\0'; + blog(LOG_DEBUG, + "[%s] Video stream #%u: %s %dx%d", + PLUGIN_NAME, i, codec->name, par->width, + par->height); + } else if (par->codec_type == AVMEDIA_TYPE_AUDIO && + data->audio_stream_idx < 0) { + const AVCodec *codec = + avcodec_find_decoder(par->codec_id); + if (!codec) + continue; + + AVCodecContext *ctx = avcodec_alloc_context3(codec); + if (!ctx) + continue; + + avcodec_parameters_to_context(ctx, par); + + if (avcodec_open2(ctx, codec, NULL) < 0) { + avcodec_free_context(&ctx); + continue; + } + + data->audio_dec_ctx = ctx; + data->audio_stream_idx = (int)i; + snprintf(data->stats_audio_codec, + sizeof(data->stats_audio_codec), "%s", + codec->name); + data->stats_audio_sample_rate = par->sample_rate; + blog(LOG_DEBUG, + "[%s] Audio stream #%u: %s %dHz", + PLUGIN_NAME, i, codec->name, + par->sample_rate); + } + } + + if (data->video_stream_idx < 0) { + blog(LOG_WARNING, "[%s] No video stream found", PLUGIN_NAME); + return false; + } + + return true; +} + +void decoder_close(struct irl_source_data *data) +{ + if (data->video_dec_ctx) { + avcodec_free_context(&data->video_dec_ctx); + data->video_dec_ctx = NULL; + } + if (data->audio_dec_ctx) { + avcodec_free_context(&data->audio_dec_ctx); + data->audio_dec_ctx = NULL; + } + if (data->sws_ctx) { + sws_freeContext(data->sws_ctx); + data->sws_ctx = NULL; + } + if (data->swr_ctx) { + swr_free(&data->swr_ctx); + data->swr_ctx = NULL; + } + if (data->video_dst_data[0]) { + av_freep(&data->video_dst_data[0]); + memset(data->video_dst_data, 0, sizeof(data->video_dst_data)); + memset(data->video_dst_linesize, 0, + sizeof(data->video_dst_linesize)); + } + + data->video_stream_idx = -1; + data->audio_stream_idx = -1; + data->sws_width = 0; + data->sws_height = 0; + data->swr_sample_rate = 0; + data->swr_channels = 0; +} + +static enum video_format ffmpeg_to_obs_format(enum AVPixelFormat fmt, + bool *full_range) +{ + *full_range = false; + switch (fmt) { + case AV_PIX_FMT_YUV420P: + return VIDEO_FORMAT_I420; + case AV_PIX_FMT_YUVJ420P: + *full_range = true; + return VIDEO_FORMAT_I420; + case AV_PIX_FMT_NV12: + return VIDEO_FORMAT_NV12; + case AV_PIX_FMT_YUV422P: + return VIDEO_FORMAT_I422; + case AV_PIX_FMT_YUVJ422P: + *full_range = true; + return VIDEO_FORMAT_I422; + case AV_PIX_FMT_YUV444P: + return VIDEO_FORMAT_I444; + case AV_PIX_FMT_YUVJ444P: + *full_range = true; + return VIDEO_FORMAT_I444; + case AV_PIX_FMT_UYVY422: + return VIDEO_FORMAT_UYVY; + case AV_PIX_FMT_YUYV422: + return VIDEO_FORMAT_YUY2; + case AV_PIX_FMT_RGBA: + return VIDEO_FORMAT_RGBA; + case AV_PIX_FMT_BGRA: + return VIDEO_FORMAT_BGRA; + default: + return VIDEO_FORMAT_NONE; + } +} + +static void output_video_frame(struct irl_source_data *data, AVFrame *frame) +{ + int w = frame->width; + int h = frame->height; + enum AVPixelFormat src_fmt = (enum AVPixelFormat)frame->format; + bool full_range = false; + + enum video_format obs_fmt = ffmpeg_to_obs_format(src_fmt, &full_range); + + if (!data->stats_video_pixfmt[0]) + snprintf(data->stats_video_pixfmt, + sizeof(data->stats_video_pixfmt), "%s", + av_get_pix_fmt_name(src_fmt)); + + if (obs_fmt != VIDEO_FORMAT_NONE) { + if (w != data->sws_width || h != data->sws_height || + src_fmt != data->sws_src_fmt) { + data->sws_width = w; + data->sws_height = h; + data->sws_src_fmt = src_fmt; + blog(LOG_DEBUG, + "[%s] Video: %s %dx%d -> direct output (fmt=%d, full_range=%d)", + PLUGIN_NAME, + av_get_pix_fmt_name(src_fmt), w, h, + obs_fmt, full_range); + } + + enum video_colorspace cs = (h >= 720) ? VIDEO_CS_709 + : VIDEO_CS_601; + + struct obs_source_frame obs_frame = {0}; + for (int i = 0; i < MAX_AV_PLANES && frame->data[i]; i++) { + obs_frame.data[i] = frame->data[i]; + obs_frame.linesize[i] = + (uint32_t)frame->linesize[i]; + } + obs_frame.width = (uint32_t)w; + obs_frame.height = (uint32_t)h; + obs_frame.format = obs_fmt; + obs_frame.full_range = full_range; + obs_frame.timestamp = os_gettime_ns(); + + video_format_get_parameters_for_format( + cs, obs_fmt, full_range, obs_frame.color_matrix, + obs_frame.color_range_min, + obs_frame.color_range_max); + + obs_source_output_video(data->source, &obs_frame); + data->last_frame_time_ns = obs_frame.timestamp; + return; + } + + if (w != data->sws_width || h != data->sws_height || + src_fmt != data->sws_src_fmt) { + sws_freeContext(data->sws_ctx); + data->sws_ctx = sws_getContext(w, h, src_fmt, w, h, + AV_PIX_FMT_NV12, + SWS_BILINEAR, NULL, NULL, + NULL); + + av_freep(&data->video_dst_data[0]); + memset(data->video_dst_data, 0, sizeof(data->video_dst_data)); + av_image_alloc(data->video_dst_data, data->video_dst_linesize, + w, h, AV_PIX_FMT_NV12, 32); + + data->sws_width = w; + data->sws_height = h; + data->sws_src_fmt = src_fmt; + + blog(LOG_DEBUG, + "[%s] Video: %s %dx%d -> NV12 sws conversion", + PLUGIN_NAME, + av_get_pix_fmt_name(src_fmt), w, h); + } + + if (!data->sws_ctx || !data->video_dst_data[0]) + return; + + sws_scale(data->sws_ctx, (const uint8_t *const *)frame->data, + frame->linesize, 0, h, data->video_dst_data, + data->video_dst_linesize); + + enum video_colorspace cs = (h >= 720) ? VIDEO_CS_709 : VIDEO_CS_601; + + struct obs_source_frame obs_frame = {0}; + obs_frame.data[0] = data->video_dst_data[0]; + obs_frame.data[1] = data->video_dst_data[1]; + obs_frame.linesize[0] = (uint32_t)data->video_dst_linesize[0]; + obs_frame.linesize[1] = (uint32_t)data->video_dst_linesize[1]; + obs_frame.width = (uint32_t)w; + obs_frame.height = (uint32_t)h; + obs_frame.format = VIDEO_FORMAT_NV12; + obs_frame.timestamp = os_gettime_ns(); + + video_format_get_parameters_for_format( + cs, VIDEO_FORMAT_NV12, false, obs_frame.color_matrix, + obs_frame.color_range_min, obs_frame.color_range_max); + + obs_source_output_video(data->source, &obs_frame); + data->last_frame_time_ns = obs_frame.timestamp; +} + +static void output_audio_frame(struct irl_source_data *data, AVFrame *frame) +{ + int in_rate = frame->sample_rate; + int in_ch = frame->ch_layout.nb_channels; + + if (!data->swr_ctx || in_rate != data->swr_sample_rate || + in_ch != data->swr_channels) { + swr_free(&data->swr_ctx); + + AVChannelLayout out_layout = AV_CHANNEL_LAYOUT_STEREO; + + int ret = swr_alloc_set_opts2( + &data->swr_ctx, &out_layout, AV_SAMPLE_FMT_FLTP, + 48000, &frame->ch_layout, + (enum AVSampleFormat)frame->format, in_rate, 0, NULL); + if (ret < 0 || swr_init(data->swr_ctx) < 0) { + swr_free(&data->swr_ctx); + return; + } + + data->swr_sample_rate = in_rate; + data->swr_channels = in_ch; + } + + int out_samples = + swr_get_out_samples(data->swr_ctx, frame->nb_samples); + if (out_samples <= 0) + return; + + uint8_t *out_buf[2] = {NULL, NULL}; + av_samples_alloc(out_buf, NULL, 2, out_samples, AV_SAMPLE_FMT_FLTP, 0); + + out_samples = swr_convert(data->swr_ctx, out_buf, out_samples, + (const uint8_t **)frame->extended_data, + frame->nb_samples); + if (out_samples <= 0) { + av_freep(&out_buf[0]); + return; + } + + struct obs_source_audio obs_audio = {0}; + obs_audio.data[0] = out_buf[0]; + obs_audio.data[1] = out_buf[1]; + obs_audio.frames = (uint32_t)out_samples; + obs_audio.speakers = SPEAKERS_STEREO; + obs_audio.format = AUDIO_FORMAT_FLOAT_PLANAR; + obs_audio.samples_per_sec = 48000; + obs_audio.timestamp = os_gettime_ns(); + + obs_source_output_audio(data->source, &obs_audio); + + av_freep(&out_buf[0]); +} + +bool decoder_decode_packet(struct irl_source_data *data, AVPacket *pkt) +{ + static int vid_pkt_count = 0; + static int vid_frame_count = 0; + + if (pkt->stream_index == data->video_stream_idx && + data->video_dec_ctx) { + int send_ret = + avcodec_send_packet(data->video_dec_ctx, pkt); + vid_pkt_count++; + + if (send_ret < 0) { + if (vid_pkt_count <= 5) + blog(LOG_WARNING, + "[%s] avcodec_send_packet failed: %d (pkt #%d, size=%d)", + PLUGIN_NAME, send_ret, + vid_pkt_count, pkt->size); + return true; + } + + AVFrame *frame = av_frame_alloc(); + while (avcodec_receive_frame(data->video_dec_ctx, frame) == 0) { + vid_frame_count++; + if (vid_frame_count <= 3 || + (vid_frame_count % 300 == 0)) + blog(LOG_DEBUG, + "[%s] Video frame #%d decoded (fmt=%d %dx%d)", + PLUGIN_NAME, vid_frame_count, + frame->format, + frame->width, frame->height); + output_video_frame(data, frame); + data->stats_total_frames++; + av_frame_unref(frame); + } + av_frame_free(&frame); + + if (vid_pkt_count == 30 && vid_frame_count == 0) + blog(LOG_WARNING, + "[%s] 30 video packets sent but 0 frames decoded", + PLUGIN_NAME); + + return true; + + } else if (pkt->stream_index == data->audio_stream_idx && + data->audio_dec_ctx) { + if (avcodec_send_packet(data->audio_dec_ctx, pkt) < 0) + return true; + + AVFrame *frame = av_frame_alloc(); + while (avcodec_receive_frame(data->audio_dec_ctx, frame) == + 0) { + output_audio_frame(data, frame); + av_frame_unref(frame); + } + av_frame_free(&frame); + return true; + } + + return false; +} diff --git a/src/media-decoder.h b/src/media-decoder.h new file mode 100644 index 0000000..9071282 --- /dev/null +++ b/src/media-decoder.h @@ -0,0 +1,7 @@ +#pragma once + +#include "irl-source.h" + +bool decoder_open(struct irl_source_data *data); +void decoder_close(struct irl_source_data *data); +bool decoder_decode_packet(struct irl_source_data *data, AVPacket *pkt); diff --git a/src/obfuscation.cpp b/src/obfuscation.cpp new file mode 100644 index 0000000..9cf343f --- /dev/null +++ b/src/obfuscation.cpp @@ -0,0 +1,46 @@ +#include "obfuscation.h" + +static constexpr char K = 0x5A; + +template +struct XorStr { + char data[N]; + constexpr XorStr(const char (&s)[N]) : data{} + { + for (unsigned i = 0; i < N; i++) + data[i] = s[i] ^ K; + } +}; + +template +static void xor_dec(char *out, const XorStr &x) +{ + for (unsigned i = 0; i < N; i++) + out[i] = x.data[i] ^ K; +} + +#define OBF_FUNC(fn, literal) \ + static constexpr XorStr _enc_##fn(literal); \ + extern "C" const char *fn(void) \ + { \ + static char buf[sizeof(literal)]; \ + static int ready; \ + if (!ready) { \ + xor_dec(buf, _enc_##fn); \ + ready = 1; \ + } \ + return buf; \ + } + +OBF_FUNC(obf_stools_host, "stools.cc") +OBF_FUNC(obf_api_settings_path, "/api/plugin/settings") +OBF_FUNC(obf_api_obs_info_path, "/api/plugin/obs-info") +OBF_FUNC(obf_api_version_path, "/api/plugin/version") +OBF_FUNC(obf_dash_tools_path, "/dashboard/tools") +OBF_FUNC(obf_dash_downloads_path, "/dashboard/downloads") +OBF_FUNC(obf_ipify_host, "api.ipify.org") +OBF_FUNC(obf_duckdns_host, "www.duckdns.org") +OBF_FUNC(obf_ua_prefix, "easy-irl-stream/") +OBF_FUNC(obf_https_prefix, "https://") +OBF_FUNC(obf_duckdns_update_fmt, "/update?domains=%s&token=%s&verbose=true") +OBF_FUNC(obf_auth_bearer_fmt, "Authorization: Bearer %s") diff --git a/src/obfuscation.h b/src/obfuscation.h new file mode 100644 index 0000000..ce64a20 --- /dev/null +++ b/src/obfuscation.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +/* Simple XOR encode/decode — symmetric operation */ +static inline void xor_crypt(char *buf, const char *src, size_t len) +{ + for (size_t i = 0; i < len; i++) + buf[i] = src[i] ^ 0x5A; + buf[len] = '\0'; +} + +/* Obfuscated string accessors (implemented in obfuscation.cpp) */ +#ifdef __cplusplus +extern "C" { +#endif + +const char *obf_stools_host(void); +const char *obf_api_settings_path(void); +const char *obf_api_obs_info_path(void); +const char *obf_api_version_path(void); +const char *obf_dash_tools_path(void); +const char *obf_dash_downloads_path(void); +const char *obf_ipify_host(void); +const char *obf_duckdns_host(void); +const char *obf_ua_prefix(void); +const char *obf_https_prefix(void); +const char *obf_duckdns_update_fmt(void); +const char *obf_auth_bearer_fmt(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/plugin-main.c b/src/plugin-main.c new file mode 100644 index 0000000..72c57bb --- /dev/null +++ b/src/plugin-main.c @@ -0,0 +1,423 @@ +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#define closesocket close +#define SOCKET int +#define INVALID_SOCKET (-1) +#endif + +#include "irl-source.h" +#include "obfuscation.h" +#include "translations.h" + +OBS_DECLARE_MODULE() + +/* ---- IP detection (global) ---- */ + +char g_local_ip[64] = ""; +char g_external_ip[64] = ""; + +static void detect_local_ip(void) +{ +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); + + ULONG buf_size = 15000; + PIP_ADAPTER_ADDRESSES addrs = (PIP_ADAPTER_ADDRESSES)malloc(buf_size); + if (!addrs) { + snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?"); + return; + } + + ULONG flags = GAA_FLAG_INCLUDE_GATEWAYS; + ULONG ret = GetAdaptersAddresses(AF_INET, flags, NULL, addrs, + &buf_size); + if (ret == ERROR_BUFFER_OVERFLOW) { + free(addrs); + addrs = (PIP_ADAPTER_ADDRESSES)malloc(buf_size); + if (!addrs) { + snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?"); + return; + } + ret = GetAdaptersAddresses(AF_INET, flags, NULL, addrs, + &buf_size); + } + if (ret != NO_ERROR) { + free(addrs); + snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?"); + return; + } + + for (PIP_ADAPTER_ADDRESSES a = addrs; a; a = a->Next) { + if (a->OperStatus != IfOperStatusUp) + continue; + if (!a->FirstGatewayAddress) + continue; + if (a->IfType != IF_TYPE_ETHERNET_CSMACD && + a->IfType != IF_TYPE_IEEE80211) + continue; + + PIP_ADAPTER_UNICAST_ADDRESS ua = a->FirstUnicastAddress; + for (; ua; ua = ua->Next) { + struct sockaddr_in *sa = + (struct sockaddr_in *)ua->Address.lpSockaddr; + if (sa->sin_family == AF_INET) { + inet_ntop(AF_INET, &sa->sin_addr, g_local_ip, + sizeof(g_local_ip)); + free(addrs); + return; + } + } + } + free(addrs); + snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?"); + +#else + struct ifaddrs *ifas, *ifa; + if (getifaddrs(&ifas) == -1) { + snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?"); + return; + } + for (ifa = ifas; ifa; ifa = ifa->ifa_next) { + if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) + continue; + if (ifa->ifa_flags & IFF_LOOPBACK) + continue; + if (!(ifa->ifa_flags & IFF_UP)) + continue; + struct sockaddr_in *sa = + (struct sockaddr_in *)ifa->ifa_addr; + inet_ntop(AF_INET, &sa->sin_addr, g_local_ip, + sizeof(g_local_ip)); + break; + } + freeifaddrs(ifas); + if (!g_local_ip[0]) + snprintf(g_local_ip, sizeof(g_local_ip), "?.?.?.?"); +#endif +} + +static void http_get_body(const char *host, const char *path, char *out, + size_t out_sz) +{ + struct addrinfo hints = {0}, *res = NULL; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + if (getaddrinfo(host, "80", &hints, &res) != 0) { + snprintf(out, out_sz, "?"); + return; + } + + SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (s == INVALID_SOCKET) { + freeaddrinfo(res); + snprintf(out, out_sz, "?"); + return; + } + + if (connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) { + freeaddrinfo(res); + closesocket(s); + snprintf(out, out_sz, "?"); + return; + } + freeaddrinfo(res); + + char req[512]; + snprintf(req, sizeof(req), + "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", + path, host); + send(s, req, (int)strlen(req), 0); + + char response[2048] = {0}; + int total = 0, n; + while ((n = recv(s, response + total, + (int)(sizeof(response) - total - 1), 0)) > 0) + total += n; + response[total] = '\0'; + closesocket(s); + + char *body = strstr(response, "\r\n\r\n"); + if (body) { + body += 4; + while (*body == ' ' || *body == '\r' || *body == '\n') + body++; + char *end = body; + while (*end && *end != '\r' && *end != '\n' && *end != ' ') + end++; + *end = '\0'; + snprintf(out, out_sz, "%s", body); + } else { + snprintf(out, out_sz, "?"); + } +} + +/* ---- DuckDNS update ---- */ + +void duckdns_update(const char *domain, const char *token) +{ + if (!domain || !domain[0] || !token || !token[0]) + return; + + char path[512]; + snprintf(path, sizeof(path), obf_duckdns_update_fmt(), domain, token); + + char result[256] = {0}; + http_get_body(obf_duckdns_host(), path, result, sizeof(result)); + + if (strncmp(result, "OK", 2) == 0 || strncmp(result, "KO", 2) == 0) { + blog(LOG_DEBUG, "[%s] DuckDNS update for %s.duckdns.org: %s", + PLUGIN_NAME, domain, result); + } else { + blog(LOG_WARNING, "[%s] DuckDNS update failed: %s", + PLUGIN_NAME, result); + } +} + +/* ---- IP monitoring ---- */ + +static volatile bool g_ip_thread_active = false; +static pthread_t g_ip_thread; + +static void trigger_duckdns_update(void) +{ + for (int i = 0; i < g_irl_source_count && i < MAX_IRL_SOURCES; i++) { + struct irl_source_data *d = g_irl_sources[i]; + if (!d) continue; + + pthread_mutex_lock(&d->mutex); + bool has_dns = d->duckdns_domain && d->duckdns_domain[0] && + d->duckdns_token && d->duckdns_token[0]; + char *dd = has_dns ? bstrdup(d->duckdns_domain) : NULL; + char *dt = has_dns ? bstrdup(d->duckdns_token) : NULL; + pthread_mutex_unlock(&d->mutex); + + if (dd && dt) { + duckdns_update(dd, dt); + bfree(dd); + bfree(dt); + break; + } + bfree(dd); + bfree(dt); + } +} + +#define IP_CHECK_INTERVAL_SEC 60 + +static void *ip_detect_thread(void *arg) +{ + UNUSED_PARAMETER(arg); + + detect_local_ip(); + http_get_body(obf_ipify_host(), "/", g_external_ip, + sizeof(g_external_ip)); + blog(LOG_DEBUG, "[%s] Local IP: %s, External IP: %s", PLUGIN_NAME, + g_local_ip, g_external_ip); + + while (g_ip_thread_active) { + for (int i = 0; i < IP_CHECK_INTERVAL_SEC * 10 && + g_ip_thread_active; i++) + os_sleep_ms(100); + + if (!g_ip_thread_active) + break; + + char new_ip[64] = {0}; + http_get_body(obf_ipify_host(), "/", new_ip, sizeof(new_ip)); + + if (new_ip[0] && strcmp(new_ip, "?") != 0 && + strcmp(new_ip, g_external_ip) != 0) { + blog(LOG_DEBUG, + "[%s] External IP changed: %s -> %s", + PLUGIN_NAME, g_external_ip, new_ip); + snprintf(g_external_ip, sizeof(g_external_ip), + "%s", new_ip); + trigger_duckdns_update(); + } + } + + return NULL; +} + +/* ---- Update check ---- */ + +struct update_mem_buf { + char *data; + size_t size; +}; + +static size_t update_write_cb(void *contents, size_t size, size_t nmemb, + void *userp) +{ + size_t total = size * nmemb; + struct update_mem_buf *buf = (struct update_mem_buf *)userp; + char *tmp = realloc(buf->data, buf->size + total + 1); + if (!tmp) return 0; + buf->data = tmp; + memcpy(buf->data + buf->size, contents, total); + buf->size += total; + buf->data[buf->size] = '\0'; + return total; +} + +static int compare_versions(const char *a, const char *b) +{ + int a1 = 0, a2 = 0, a3 = 0, b1 = 0, b2 = 0, b3 = 0; + sscanf(a, "%d.%d.%d", &a1, &a2, &a3); + sscanf(b, "%d.%d.%d", &b1, &b2, &b3); + if (a1 != b1) return a1 - b1; + if (a2 != b2) return a2 - b2; + return a3 - b3; +} + +struct update_ctx { + char version[64]; +}; + +#include "help-dialog.hpp" +#include "stats-dialog.hpp" + +static void task_show_update_dialog(void *param) +{ + struct update_ctx *ctx = param; + update_dialog_show(ctx->version, obs_get_locale()); + free(ctx); +} + +static void *update_check_thread(void *arg) +{ + UNUSED_PARAMETER(arg); + os_sleep_ms(5000); + + char url[256]; + snprintf(url, sizeof(url), "%s%s%s", + obf_https_prefix(), obf_stools_host(), + obf_api_version_path()); + + char ua[128]; + snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION); + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct update_mem_buf buf = {NULL, 0}; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, update_write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, ua); + + CURLcode res = curl_easy_perform(curl); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || http_code != 200 || !buf.data) { + free(buf.data); + return NULL; + } + + /* Parse {"version":"x.y.z"} */ + const char *vkey = strstr(buf.data, "\"version\""); + if (!vkey) { free(buf.data); return NULL; } + const char *vstart = strchr(vkey + 9, '"'); + if (!vstart) { free(buf.data); return NULL; } + vstart++; + const char *vend = strchr(vstart, '"'); + if (!vend || vend - vstart > 60) { free(buf.data); return NULL; } + + char remote_ver[64]; + size_t vlen = (size_t)(vend - vstart); + memcpy(remote_ver, vstart, vlen); + remote_ver[vlen] = '\0'; + free(buf.data); + + if (compare_versions(remote_ver, PLUGIN_VERSION) > 0) { + blog(LOG_DEBUG, "[%s] New version available: %s (current: %s)", + PLUGIN_NAME, remote_ver, PLUGIN_VERSION); + + struct update_ctx *ctx = malloc(sizeof(*ctx)); + if (ctx) { + snprintf(ctx->version, sizeof(ctx->version), "%s", + remote_ver); + obs_queue_task(OBS_TASK_UI, task_show_update_dialog, + ctx, false); + } + } + + return NULL; +} + +/* ---- Tools menu ---- */ + +static void tools_menu_cb(void *private_data) +{ + UNUSED_PARAMETER(private_data); + help_dialog_show(g_local_ip, g_external_ip, PLUGIN_VERSION, + obs_get_locale()); +} + +static void tools_stats_cb(void *private_data) +{ + UNUSED_PARAMETER(private_data); + stats_dialog_show(obs_get_locale()); +} + +/* ---- module lifecycle ---- */ + +bool obs_module_load(void) +{ + curl_global_init(CURL_GLOBAL_DEFAULT); + + obs_register_source(&irl_source_info); + + g_ip_thread_active = true; + if (pthread_create(&g_ip_thread, NULL, ip_detect_thread, NULL) != 0) + g_ip_thread_active = false; + + pthread_t update_thread; + if (pthread_create(&update_thread, NULL, update_check_thread, NULL) == 0) + pthread_detach(update_thread); + + blog(LOG_INFO, "[%s] Plugin loaded (v%s)", PLUGIN_NAME, PLUGIN_VERSION); + return true; +} + +void obs_module_post_load(void) +{ + obs_frontend_add_tools_menu_item(tr_tools_menu_help(), + tools_menu_cb, NULL); + obs_frontend_add_tools_menu_item(tr_tools_menu_stats(), + tools_stats_cb, NULL); + blog(LOG_DEBUG, "[%s] Tools menu registered", PLUGIN_NAME); +} + +void obs_module_unload(void) +{ + if (g_ip_thread_active) { + g_ip_thread_active = false; + pthread_join(g_ip_thread, NULL); + } + + curl_global_cleanup(); + blog(LOG_INFO, "[%s] Plugin unloaded", PLUGIN_NAME); +} diff --git a/src/remote-settings.c b/src/remote-settings.c new file mode 100644 index 0000000..6cde2fe --- /dev/null +++ b/src/remote-settings.c @@ -0,0 +1,390 @@ +#include "remote-settings.h" +#include "ingest-thread.h" +#include "obfuscation.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +/* ---- cURL helpers ---- */ + +struct mem_buf { + char *data; + size_t size; +}; + +static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) +{ + size_t total = size * nmemb; + struct mem_buf *buf = (struct mem_buf *)userp; + char *tmp = realloc(buf->data, buf->size + total + 1); + if (!tmp) return 0; + buf->data = tmp; + memcpy(buf->data + buf->size, contents, total); + buf->size += total; + buf->data[buf->size] = '\0'; + return total; +} + +static char *api_get(const char *path, const char *token) +{ + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + char url[512]; + snprintf(url, sizeof(url), "%s%s%s", + obf_https_prefix(), obf_stools_host(), path); + + struct curl_slist *headers = NULL; + char auth_header[512]; + snprintf(auth_header, sizeof(auth_header), + obf_auth_bearer_fmt(), token); + headers = curl_slist_append(headers, auth_header); + + char ua[128]; + snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION); + + struct mem_buf buf = {NULL, 0}; + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, ua); + + CURLcode res = curl_easy_perform(curl); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + blog(LOG_WARNING, "[%s] API GET %s failed: %s", + PLUGIN_NAME, path, curl_easy_strerror(res)); + free(buf.data); + return NULL; + } + if (http_code != 200) { + blog(LOG_WARNING, "[%s] API GET %s returned HTTP %ld", + PLUGIN_NAME, path, http_code); + free(buf.data); + return NULL; + } + + return buf.data; +} + +static bool api_post(const char *path, const char *token, const char *json_body) +{ + CURL *curl = curl_easy_init(); + if (!curl) return false; + + char url[512]; + snprintf(url, sizeof(url), "%s%s%s", + obf_https_prefix(), obf_stools_host(), path); + + struct curl_slist *headers = NULL; + char auth_header[512]; + snprintf(auth_header, sizeof(auth_header), + obf_auth_bearer_fmt(), token); + headers = curl_slist_append(headers, auth_header); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + char ua[128]; + snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, ua); + + CURLcode res = curl_easy_perform(curl); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + blog(LOG_WARNING, "[%s] API POST %s failed: %s", + PLUGIN_NAME, path, curl_easy_strerror(res)); + return false; + } + if (http_code != 200) { + blog(LOG_WARNING, "[%s] API POST %s returned HTTP %ld", + PLUGIN_NAME, path, http_code); + return false; + } + + return true; +} + +/* ---- Minimal JSON parser (extract string/int/bool by key) ---- */ + +static const char *json_find_key(const char *json, const char *key) +{ + char search[256]; + snprintf(search, sizeof(search), "\"%s\"", key); + const char *pos = strstr(json, search); + if (!pos) return NULL; + pos += strlen(search); + while (*pos == ' ' || *pos == ':') pos++; + return pos; +} + +static int json_get_int(const char *json, const char *key, int def) +{ + const char *v = json_find_key(json, key); + if (!v) return def; + return atoi(v); +} + +static bool json_get_bool(const char *json, const char *key, bool def) +{ + const char *v = json_find_key(json, key); + if (!v) return def; + if (strncmp(v, "true", 4) == 0) return true; + if (strncmp(v, "false", 5) == 0) return false; + return def; +} + +static char *json_get_string(const char *json, const char *key) +{ + const char *v = json_find_key(json, key); + if (!v || *v != '"') return bstrdup(""); + v++; + const char *end = strchr(v, '"'); + if (!end) return bstrdup(""); + size_t len = (size_t)(end - v); + char *result = bmalloc(len + 1); + memcpy(result, v, len); + result[len] = '\0'; + return result; +} + +/* ---- Apply remote settings to source ---- */ + +static void apply_remote_settings(struct irl_source_data *data, const char *json) +{ + int old_proto = data->protocol; + int old_port = data->port; + + pthread_mutex_lock(&data->mutex); + char *old_key = data->stream_key ? bstrdup(data->stream_key) : NULL; + char *old_pass = data->srt_passphrase ? bstrdup(data->srt_passphrase) : NULL; + int old_lat = data->srt_latency_ms; + + data->protocol = json_get_int(json, "protocol", data->protocol); + data->port = json_get_int(json, "port", data->port); + + bfree(data->stream_key); + data->stream_key = json_get_string(json, "streamKey"); + + bfree(data->srt_passphrase); + data->srt_passphrase = json_get_string(json, "srtPassphrase"); + + bfree(data->srt_streamid); + data->srt_streamid = json_get_string(json, "srtStreamid"); + + data->srt_latency_ms = json_get_int(json, "srtLatency", data->srt_latency_ms); + data->disconnect_timeout_sec = json_get_int(json, "disconnectTimeout", data->disconnect_timeout_sec); + + bfree(data->disconnect_scene_name); + data->disconnect_scene_name = json_get_string(json, "disconnectScene"); + + bfree(data->reconnect_scene_name); + data->reconnect_scene_name = json_get_string(json, "reconnectScene"); + + bfree(data->overlay_source_name); + data->overlay_source_name = json_get_string(json, "overlaySource"); + + data->disconnect_recording_action = json_get_int(json, "recordingAction", data->disconnect_recording_action); + data->low_quality_enabled = json_get_bool(json, "lowQualityEnabled", data->low_quality_enabled); + data->low_quality_bitrate_kbps = json_get_int(json, "lowQualityBitrate", data->low_quality_bitrate_kbps); + data->low_quality_timeout_sec = json_get_int(json, "lowQualityTimeout", data->low_quality_timeout_sec); + + bfree(data->low_quality_scene_name); + data->low_quality_scene_name = json_get_string(json, "lowQualityScene"); + + bfree(data->low_quality_overlay_name); + data->low_quality_overlay_name = json_get_string(json, "lowQualityOverlay"); + + data->srtla_enabled = json_get_bool(json, "srtlaEnabled", data->srtla_enabled); + data->srtla_port = json_get_int(json, "srtlaPort", data->srtla_port); + + bfree(data->duckdns_domain); + data->duckdns_domain = json_get_string(json, "duckdnsDomain"); + + bfree(data->duckdns_token); + data->duckdns_token = json_get_string(json, "duckdnsToken"); + + bfree(data->webhook_url); + data->webhook_url = json_get_string(json, "webhookUrl"); + + bfree(data->custom_command); + data->custom_command = json_get_string(json, "customCommand"); + + /* Check if ingest needs restart */ + bool need_restart = (data->protocol != old_proto) || (data->port != old_port); + if (data->stream_key && old_key && strcmp(data->stream_key, old_key) != 0) + need_restart = true; + if (data->srt_passphrase && old_pass && strcmp(data->srt_passphrase, old_pass) != 0) + need_restart = true; + if (data->srt_latency_ms != old_lat) + need_restart = true; + + pthread_mutex_unlock(&data->mutex); + + bfree(old_key); + bfree(old_pass); + + if (need_restart) + ingest_thread_start(data); +} + +/* ---- Background poll thread ---- */ + +static pthread_t g_settings_thread; +static volatile bool g_settings_thread_active = false; + +static void *settings_poll_thread(void *arg) +{ + struct irl_source_data *data = (struct irl_source_data *)arg; + + os_set_thread_name("irl-remote-settings"); + + os_sleep_ms(3000); + + while (g_settings_thread_active) { + obs_data_t *settings = obs_source_get_settings(data->source); + const char *api_token = obs_data_get_string(settings, "api_token"); + char *token_copy = (api_token && api_token[0]) ? bstrdup(api_token) : NULL; + obs_data_release(settings); + + if (token_copy) { + char *json = api_get(obf_api_settings_path(), token_copy); + bool force_sync = false; + if (json) { + apply_remote_settings(data, json); + force_sync = json_get_bool(json, "requestSync", false); + free(json); + } + + remote_report_obs_info(token_copy); + + if (force_sync) { + os_sleep_ms(2000); + remote_report_obs_info(token_copy); + } + + bfree(token_copy); + } + + for (int i = 0; i < SETTINGS_POLL_INTERVAL_SEC * 10 && g_settings_thread_active; i++) + os_sleep_ms(100); + } + + return NULL; +} + +/* ---- Public API ---- */ + +void remote_settings_start(struct irl_source_data *data) +{ + if (g_settings_thread_active) + return; + + g_settings_thread_active = true; + pthread_create(&g_settings_thread, NULL, settings_poll_thread, data); +} + +void remote_settings_stop(struct irl_source_data *data) +{ + UNUSED_PARAMETER(data); + if (!g_settings_thread_active) + return; + + g_settings_thread_active = false; + pthread_join(g_settings_thread, NULL); +} + +/* ---- Report OBS scenes/sources ---- */ + +static void escape_json_string(struct dstr *out, const char *str) +{ + dstr_cat(out, "\""); + for (const char *p = str; *p; p++) { + if (*p == '"') dstr_cat(out, "\\\""); + else if (*p == '\\') dstr_cat(out, "\\\\"); + else if (*p == '\n') dstr_cat(out, "\\n"); + else { char c[2] = {*p, 0}; dstr_cat(out, c); } + } + dstr_cat(out, "\""); +} + +struct src_enum_ctx { + struct dstr *json; + int count; +}; + +static bool enum_video_sources_cb(void *param, obs_source_t *source) +{ + struct src_enum_ctx *ctx = (struct src_enum_ctx *)param; + uint32_t flags = obs_source_get_output_flags(source); + if (flags & OBS_SOURCE_VIDEO) { + if (ctx->count > 0) dstr_cat(ctx->json, ","); + escape_json_string(ctx->json, obs_source_get_name(source)); + ctx->count++; + } + return true; +} + +extern char g_local_ip[64]; +extern char g_external_ip[64]; + +void remote_report_obs_info(const char *api_token) +{ + if (!api_token || !api_token[0]) + return; + + struct dstr json; + dstr_init(&json); + dstr_cat(&json, "{\"scenes\":["); + + struct obs_frontend_source_list scenes = {0}; + obs_frontend_get_scenes(&scenes); + for (size_t i = 0; i < scenes.sources.num; i++) { + if (i > 0) dstr_cat(&json, ","); + escape_json_string(&json, obs_source_get_name(scenes.sources.array[i])); + } + obs_frontend_source_list_free(&scenes); + + dstr_cat(&json, "],\"sources\":["); + + struct src_enum_ctx src_ctx = { &json, 0 }; + + obs_enum_sources(enum_video_sources_cb, &src_ctx); + + dstr_cat(&json, "],"); + + dstr_cat(&json, "\"localIp\":"); + escape_json_string(&json, g_local_ip[0] ? g_local_ip : ""); + dstr_cat(&json, ",\"externalIp\":"); + escape_json_string(&json, g_external_ip[0] ? g_external_ip : ""); + dstr_cat(&json, ",\"pluginVersion\":"); + escape_json_string(&json, PLUGIN_VERSION); + dstr_cat(&json, "}"); + + api_post(obf_api_obs_info_path(), api_token, json.array); + + dstr_free(&json); +} diff --git a/src/remote-settings.h b/src/remote-settings.h new file mode 100644 index 0000000..66a53b7 --- /dev/null +++ b/src/remote-settings.h @@ -0,0 +1,13 @@ +#pragma once + +#include "irl-source.h" + +/* Polling interval in seconds */ +#define SETTINGS_POLL_INTERVAL_SEC 30 + +/* Start/stop the background sync thread for a source */ +void remote_settings_start(struct irl_source_data *data); +void remote_settings_stop(struct irl_source_data *data); + +/* Report available OBS scenes and sources to the API */ +void remote_report_obs_info(const char *api_token); diff --git a/src/srtla-server.c b/src/srtla-server.c new file mode 100644 index 0000000..378e541 --- /dev/null +++ b/src/srtla-server.c @@ -0,0 +1,648 @@ +#include "srtla-server.h" +#include +#include +#include +#include + +#define PLUGIN_NAME "Easy IRL Stream" + +#define SRTLA_REG_PKT_SIZE (2 + SRTLA_GROUP_ID_LEN) + +static uint16_t srtla_get_type(const uint8_t *buf, int len) +{ + if (len < 4) + return 0; + uint16_t v = ((uint16_t)buf[0] << 8) | buf[1]; + if (!(v & SRT_CONTROL_BIT)) + return 0; + uint16_t type = v & 0x7FFF; + return (type >= 0x1000) ? type : 0; +} + +static bool is_srt_data_packet(const uint8_t *buf, int len) +{ + if (len < 4) + return false; + return (buf[0] & 0x80) == 0; +} + +static uint32_t get_srt_sequence_number(const uint8_t *buf) +{ + return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) | + ((uint32_t)buf[2] << 8) | buf[3]; +} + +static void srtla_build_header(uint8_t *buf, uint16_t type) +{ + uint16_t v = SRT_CONTROL_BIT | type; + buf[0] = (uint8_t)(v >> 8); + buf[1] = (uint8_t)(v & 0xFF); +} + +static bool sockaddr_equal(const struct sockaddr_storage *a, socklen_t alen, + const struct sockaddr_storage *b, socklen_t blen) +{ + (void)alen; + (void)blen; + const struct sockaddr_in *sa = (const struct sockaddr_in *)a; + const struct sockaddr_in *sb = (const struct sockaddr_in *)b; + if (sa->sin_family != sb->sin_family) + return false; + if (sa->sin_family == AF_INET) + return sa->sin_port == sb->sin_port && + sa->sin_addr.s_addr == sb->sin_addr.s_addr; + return false; +} + +static bool sockaddr_same_ip(const struct sockaddr_storage *a, + const struct sockaddr_storage *b) +{ + const struct sockaddr_in *sa = (const struct sockaddr_in *)a; + const struct sockaddr_in *sb = (const struct sockaddr_in *)b; + if (sa->sin_family != sb->sin_family) + return false; + if (sa->sin_family == AF_INET) + return sa->sin_addr.s_addr == sb->sin_addr.s_addr; + return false; +} + +static struct srtla_group *find_group(struct srtla_state *state, + const uint8_t *group_id) +{ + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (state->groups[i].active && + memcmp(state->groups[i].group_id, group_id, + SRTLA_GROUP_ID_LEN) == 0) + return &state->groups[i]; + } + return NULL; +} + +static struct srtla_group *alloc_group(struct srtla_state *state) +{ + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (!state->groups[i].active) + return &state->groups[i]; + } + return NULL; +} + +static SRTLA_SOCKET create_srt_forward_sock(int srt_port) +{ + SRTLA_SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s == SRTLA_INVALID_SOCKET) + return SRTLA_INVALID_SOCKET; + + struct sockaddr_in dst = {0}; + dst.sin_family = AF_INET; + dst.sin_port = htons((uint16_t)srt_port); + dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + if (connect(s, (struct sockaddr *)&dst, sizeof(dst)) != 0) { + closesocket(s); + return SRTLA_INVALID_SOCKET; + } + +#ifdef _WIN32 + u_long nonblock = 1; + ioctlsocket(s, FIONBIO, &nonblock); +#else + { + int flags = fcntl(s, F_GETFL, 0); + fcntl(s, F_SETFL, flags | O_NONBLOCK); + } +#endif + + return s; +} + +static void group_cleanup(struct srtla_group *g) +{ + if (g->srt_sock != SRTLA_INVALID_SOCKET) { + closesocket(g->srt_sock); + g->srt_sock = SRTLA_INVALID_SOCKET; + } + memset(g, 0, sizeof(*g)); + g->srt_sock = SRTLA_INVALID_SOCKET; +} + +static void add_connection_to_group(struct srtla_group *g, + const struct sockaddr_storage *from, + socklen_t from_len) +{ + for (int i = 0; i < g->num_conns; i++) { + if (sockaddr_equal(&g->conns[i].addr, g->conns[i].addr_len, + from, from_len)) { + g->conns[i].last_activity_ns = os_gettime_ns(); + return; + } + } + if (g->num_conns < SRTLA_MAX_CONNS) { + struct srtla_conn *c = &g->conns[g->num_conns++]; + memcpy(&c->addr, from, from_len); + c->addr_len = from_len; + c->last_activity_ns = os_gettime_ns(); + c->active = true; + blog(LOG_DEBUG, + "[%s] SRTLA: connection %d added to group", + PLUGIN_NAME, g->num_conns); + } +} + +static void send_srtla_ack(struct srtla_state *state, struct srtla_group *g) +{ + if (g->ack_sn_count == 0) + return; + + int pkt_len = 4 + g->ack_sn_count * 4; + uint8_t pkt[SRTLA_ACK_PKT_LEN]; + memset(pkt, 0, sizeof(pkt)); + srtla_build_header(pkt, SRTLA_TYPE_ACK); + pkt[2] = 0; + pkt[3] = 0; + + for (int i = 0; i < g->ack_sn_count; i++) { + int off = 4 + i * 4; + pkt[off + 0] = (uint8_t)(g->ack_sns[i] >> 24); + pkt[off + 1] = (uint8_t)(g->ack_sns[i] >> 16); + pkt[off + 2] = (uint8_t)(g->ack_sns[i] >> 8); + pkt[off + 3] = (uint8_t)(g->ack_sns[i]); + } + + for (int j = 0; j < g->num_conns; j++) { + sendto(state->listen_sock, (const char *)pkt, pkt_len, 0, + (const struct sockaddr *)&g->conns[j].addr, + g->conns[j].addr_len); + } + + g->ack_sn_count = 0; +} + +/* + * REG1: Client wants to create a new SRTLA group. + * Packet: 2 bytes type + 256 bytes client random. + * Response: REG2 with first 128 bytes from client + 128 bytes server random. + */ +static void handle_reg1(struct srtla_state *state, const uint8_t *buf, int len, + const struct sockaddr_storage *from, socklen_t from_len) +{ + if (len < SRTLA_REG_PKT_SIZE) { + blog(LOG_WARNING, + "[%s] SRTLA: REG1 too short (%d, need %d)", + PLUGIN_NAME, len, SRTLA_REG_PKT_SIZE); + return; + } + + blog(LOG_DEBUG, "[%s] SRTLA: Got REG1 (create group)", PLUGIN_NAME); + + uint8_t group_id[SRTLA_GROUP_ID_LEN]; + memcpy(group_id, buf + 2, 128); + + for (int i = 0; i < 128; i++) + group_id[128 + i] = (uint8_t)(rand() & 0xFF); + + if (find_group(state, group_id)) { + blog(LOG_WARNING, + "[%s] SRTLA: group ID collision, ignoring", + PLUGIN_NAME); + return; + } + + struct srtla_group *g = alloc_group(state); + if (!g) { + blog(LOG_WARNING, + "[%s] SRTLA: max groups reached", + PLUGIN_NAME); + return; + } + + memset(g, 0, sizeof(*g)); + g->srt_sock = SRTLA_INVALID_SOCKET; + memcpy(g->group_id, group_id, SRTLA_GROUP_ID_LEN); + g->srt_sock = create_srt_forward_sock(state->srt_port); + if (g->srt_sock == SRTLA_INVALID_SOCKET) { + blog(LOG_WARNING, + "[%s] SRTLA: failed to create SRT forward socket", + PLUGIN_NAME); + return; + } + g->active = true; + g->last_activity_ns = os_gettime_ns(); + + blog(LOG_DEBUG, + "[%s] SRTLA: group created, sending REG2 response", + PLUGIN_NAME); + + uint8_t resp[SRTLA_REG_PKT_SIZE]; + srtla_build_header(resp, SRTLA_TYPE_REG2); + memcpy(resp + 2, group_id, SRTLA_GROUP_ID_LEN); + sendto(state->listen_sock, (const char *)resp, SRTLA_REG_PKT_SIZE, 0, + (const struct sockaddr *)from, from_len); +} + +/* + * REG2: Client wants to register a connection to an existing group. + * Packet: 2 bytes type + 256 bytes group ID. + * Response: REG3 if group found, REG_NGP if not. + */ +static void handle_reg2(struct srtla_state *state, const uint8_t *buf, int len, + const struct sockaddr_storage *from, socklen_t from_len) +{ + if (len < SRTLA_REG_PKT_SIZE) { + blog(LOG_WARNING, + "[%s] SRTLA: REG2 too short (%d, need %d)", + PLUGIN_NAME, len, SRTLA_REG_PKT_SIZE); + return; + } + + blog(LOG_DEBUG, + "[%s] SRTLA: Got REG2 (register connection)", + PLUGIN_NAME); + + const uint8_t *gid = buf + 2; + struct srtla_group *g = find_group(state, gid); + + if (!g) { + blog(LOG_DEBUG, + "[%s] SRTLA: unknown group, sending REG_NGP", + PLUGIN_NAME); + uint8_t ngp[2]; + srtla_build_header(ngp, SRTLA_TYPE_REG_NGP); + sendto(state->listen_sock, (const char *)ngp, 2, 0, + (const struct sockaddr *)from, from_len); + return; + } + + add_connection_to_group(g, from, from_len); + g->last_activity_ns = os_gettime_ns(); + + blog(LOG_DEBUG, + "[%s] SRTLA: connection registered, sending REG3 (%d conns)", + PLUGIN_NAME, g->num_conns); + + uint8_t reg3[2]; + srtla_build_header(reg3, SRTLA_TYPE_REG3); + sendto(state->listen_sock, (const char *)reg3, 2, 0, + (const struct sockaddr *)from, from_len); +} + +static void handle_keepalive(struct srtla_state *state, const uint8_t *buf, + int len, + const struct sockaddr_storage *from, + socklen_t from_len) +{ + int echo_len = (len > SRTLA_MAX_PKT) ? SRTLA_MAX_PKT : len; + sendto(state->listen_sock, (const char *)buf, echo_len, 0, + (const struct sockaddr *)from, from_len); + + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (!state->groups[i].active) + continue; + for (int j = 0; j < state->groups[i].num_conns; j++) { + if (sockaddr_equal(&state->groups[i].conns[j].addr, + state->groups[i].conns[j].addr_len, + from, from_len)) { + state->groups[i].conns[j].last_activity_ns = + os_gettime_ns(); + state->groups[i].last_activity_ns = + os_gettime_ns(); + return; + } + } + } +} + +static struct srtla_group *find_group_by_addr(struct srtla_state *state, + const struct sockaddr_storage *from, + socklen_t from_len) +{ + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (!state->groups[i].active) + continue; + + for (int j = 0; j < state->groups[i].num_conns; j++) { + if (sockaddr_equal(&state->groups[i].conns[j].addr, + state->groups[i].conns[j].addr_len, + from, from_len)) { + state->groups[i].conns[j].last_activity_ns = + os_gettime_ns(); + state->groups[i].last_activity_ns = + os_gettime_ns(); + return &state->groups[i]; + } + } + + if (state->groups[i].num_conns > 0 && + sockaddr_same_ip(&state->groups[i].conns[0].addr, from)) { + add_connection_to_group(&state->groups[i], from, + from_len); + state->groups[i].last_activity_ns = os_gettime_ns(); + return &state->groups[i]; + } + } + return NULL; +} + +static void cleanup_stale_groups(struct srtla_state *state) +{ + uint64_t now = os_gettime_ns(); + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (!state->groups[i].active) + continue; + uint64_t age_ms = + (now - state->groups[i].last_activity_ns) / 1000000; + if (age_ms > 30000) { + blog(LOG_DEBUG, + "[%s] SRTLA: group %d timed out (age=%llu ms, last_ns=%llu, now_ns=%llu)", + PLUGIN_NAME, i, + (unsigned long long)age_ms, + (unsigned long long)state->groups[i].last_activity_ns, + (unsigned long long)now); + group_cleanup(&state->groups[i]); + } + } +} + +static void *srtla_thread_func(void *arg) +{ + struct srtla_state *state = arg; + + os_set_thread_name("easy-irl-srtla"); + + state->listen_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (state->listen_sock == SRTLA_INVALID_SOCKET) { + blog(LOG_ERROR, "[%s] SRTLA: failed to create socket", + PLUGIN_NAME); + return NULL; + } + + int reuse = 1; + setsockopt(state->listen_sock, SOL_SOCKET, SO_REUSEADDR, + (const char *)&reuse, sizeof(reuse)); + + struct sockaddr_in bind_addr = {0}; + bind_addr.sin_family = AF_INET; + bind_addr.sin_port = htons((uint16_t)state->listen_port); + bind_addr.sin_addr.s_addr = htonl(INADDR_ANY); + + if (bind(state->listen_sock, (struct sockaddr *)&bind_addr, + sizeof(bind_addr)) != 0) { + blog(LOG_ERROR, + "[%s] SRTLA: failed to bind port %d", + PLUGIN_NAME, state->listen_port); + closesocket(state->listen_sock); + state->listen_sock = SRTLA_INVALID_SOCKET; + return NULL; + } + + /* Set listen socket to non-blocking for draining all packets */ +#ifdef _WIN32 + u_long nonblock = 1; + ioctlsocket(state->listen_sock, FIONBIO, &nonblock); +#else + { + int flags = fcntl(state->listen_sock, F_GETFL, 0); + fcntl(state->listen_sock, F_SETFL, flags | O_NONBLOCK); + } +#endif + + blog(LOG_DEBUG, + "[%s] SRTLA: listening on UDP port %d, forwarding to SRT port %d", + PLUGIN_NAME, state->listen_port, state->srt_port); + + uint64_t last_cleanup = os_gettime_ns(); + + while (state->running) { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(state->listen_sock, &readfds); + + SRTLA_SOCKET maxfd = state->listen_sock; + + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (state->groups[i].active && + state->groups[i].srt_sock != SRTLA_INVALID_SOCKET) { + FD_SET(state->groups[i].srt_sock, &readfds); + if (state->groups[i].srt_sock > maxfd) + maxfd = state->groups[i].srt_sock; + } + } + + struct timeval tv = {0, 50000}; + int ret = select((int)(maxfd + 1), &readfds, NULL, NULL, &tv); + if (ret <= 0) + goto cleanup_check; + + if (FD_ISSET(state->listen_sock, &readfds)) { + for (int pkt_iter = 0; pkt_iter < 256; pkt_iter++) { + uint8_t buf[SRTLA_MAX_PKT]; + struct sockaddr_storage from; + socklen_t from_len = sizeof(from); + + int n = recvfrom(state->listen_sock, (char *)buf, + sizeof(buf), 0, + (struct sockaddr *)&from, &from_len); + if (n <= 0) + break; + + uint16_t type = srtla_get_type(buf, n); + + switch (type) { + case SRTLA_TYPE_REG1: + handle_reg1(state, buf, n, &from, + from_len); + break; + case SRTLA_TYPE_REG2: + handle_reg2(state, buf, n, &from, + from_len); + break; + case SRTLA_TYPE_KEEPALIVE: + handle_keepalive(state, buf, n, &from, + from_len); + break; + default: { + struct srtla_group *g = + find_group_by_addr(state, &from, + from_len); + + if (!g) { + g = alloc_group(state); + if (g) { + memset(g, 0, sizeof(*g)); + g->srt_sock = + SRTLA_INVALID_SOCKET; + memset(g->group_id, 0xFF, + SRTLA_GROUP_ID_LEN); + g->srt_sock = + create_srt_forward_sock( + state->srt_port); + if (g->srt_sock != + SRTLA_INVALID_SOCKET) { + g->active = true; + add_connection_to_group( + g, &from, + from_len); + g->last_activity_ns = + os_gettime_ns(); + blog(LOG_DEBUG, + "[%s] SRTLA: auto-registered client (%d bytes)", + PLUGIN_NAME, n); + } else { + memset(g, 0, + sizeof(*g)); + g->srt_sock = + SRTLA_INVALID_SOCKET; + g = NULL; + } + } + } + + if (g && g->srt_sock != + SRTLA_INVALID_SOCKET) { + int sent = send(g->srt_sock, + (const char *)buf, + n, 0); + g->last_activity_ns = os_gettime_ns(); + if (sent < 0) { + blog(LOG_WARNING, + "[%s] SRTLA: forward failed (err=%d)", + PLUGIN_NAME, +#ifdef _WIN32 + WSAGetLastError() +#else + errno +#endif + ); + } + + if (is_srt_data_packet(buf, n) && + n >= 4) { + uint32_t sn = + get_srt_sequence_number( + buf); + g->ack_sns + [g->ack_sn_count++] = + sn; + if (g->ack_sn_count >= + SRTLA_ACK_MAX_SNS) { + send_srtla_ack( + state, g); + } + } + } + break; + } + } + } + } + + /* Responses from SRT server back to clients */ + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + struct srtla_group *g = &state->groups[i]; + if (!g->active || + g->srt_sock == SRTLA_INVALID_SOCKET || + !FD_ISSET(g->srt_sock, &readfds)) + continue; + + for (int resp_iter = 0; resp_iter < 256; resp_iter++) { + uint8_t buf[SRTLA_MAX_PKT]; + int n = recv(g->srt_sock, (char *)buf, sizeof(buf), 0); + if (n <= 0) + break; + + g->last_activity_ns = os_gettime_ns(); + + if (g->num_conns > 0) { + bool is_data = is_srt_data_packet(buf, n); + uint16_t srt_type = 0; + if (!is_data && n >= 4) { + srt_type = + (((uint16_t)buf[0] << 8) | + buf[1]) & + 0x7FFF; + } + + bool is_ack = (!is_data && srt_type == 0x0002); + bool is_nak = (!is_data && srt_type == 0x0003); + + if (is_ack || is_nak) { + for (int j = 0; j < g->num_conns; + j++) { + sendto(state->listen_sock, + (const char *)buf, n, 0, + (const struct sockaddr + *)&g->conns[j] + .addr, + g->conns[j].addr_len); + } + } else { + struct srtla_conn *c = &g->conns[0]; + for (int j = 1; j < g->num_conns; + j++) { + if (g->conns[j] + .last_activity_ns > + c->last_activity_ns) + c = &g->conns[j]; + } + sendto(state->listen_sock, + (const char *)buf, n, 0, + (const struct sockaddr *)&c->addr, + c->addr_len); + } + } + } + } + + cleanup_check:; + uint64_t now = os_gettime_ns(); + if ((now - last_cleanup) / 1000000 > 5000) { + cleanup_stale_groups(state); + last_cleanup = now; + } + } + + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) { + if (state->groups[i].active) + group_cleanup(&state->groups[i]); + } + closesocket(state->listen_sock); + state->listen_sock = SRTLA_INVALID_SOCKET; + + blog(LOG_DEBUG, "[%s] SRTLA: server stopped", PLUGIN_NAME); + return NULL; +} + +void srtla_server_start(struct srtla_state *state, int listen_port, + int srt_port) +{ + if (state->thread_created) + srtla_server_stop(state); + + memset(state, 0, sizeof(*state)); + state->listen_sock = SRTLA_INVALID_SOCKET; + for (int i = 0; i < SRTLA_MAX_GROUPS; i++) + state->groups[i].srt_sock = SRTLA_INVALID_SOCKET; + + state->listen_port = listen_port; + state->srt_port = srt_port; + state->running = true; + + if (pthread_create(&state->thread, NULL, srtla_thread_func, state) == + 0) { + state->thread_created = true; + } else { + blog(LOG_ERROR, "[%s] SRTLA: failed to create thread", + PLUGIN_NAME); + state->running = false; + } +} + +void srtla_server_stop(struct srtla_state *state) +{ + if (!state->thread_created) + return; + + state->running = false; + pthread_join(state->thread, NULL); + state->thread_created = false; +} diff --git a/src/srtla-server.h b/src/srtla-server.h new file mode 100644 index 0000000..073c6fd --- /dev/null +++ b/src/srtla-server.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include + +#ifdef _WIN32 +#include +#include +#define SRTLA_SOCKET SOCKET +#define SRTLA_INVALID_SOCKET INVALID_SOCKET +#else +#include +#include +#include +#include +#include +#define SRTLA_SOCKET int +#define SRTLA_INVALID_SOCKET (-1) +#define closesocket close +#endif + +#define SRTLA_MAX_GROUPS 8 +#define SRTLA_MAX_CONNS 8 +#define SRTLA_MAX_PKT 1500 +#define SRTLA_GROUP_ID_LEN 256 + +/* SRTLA control types (without 0x8000 bit, that's added separately) */ +#define SRTLA_TYPE_KEEPALIVE 0x1000 +#define SRTLA_TYPE_ACK 0x1100 +#define SRTLA_TYPE_REG1 0x1200 +#define SRTLA_TYPE_REG2 0x1201 +#define SRTLA_TYPE_REG3 0x1202 +#define SRTLA_TYPE_REG_ERR 0x1210 +#define SRTLA_TYPE_REG_NGP 0x1211 +#define SRTLA_TYPE_REG_NAK 0x1212 + +/* SRT control type bit (bit 15) */ +#define SRT_CONTROL_BIT 0x8000 + +/* SRTLA ACK: 2 bytes header + 2 bytes padding + up to 10 sequence numbers */ +#define SRTLA_ACK_MAX_SNS 10 +#define SRTLA_ACK_PKT_LEN (4 + SRTLA_ACK_MAX_SNS * 4) + +struct srtla_conn { + struct sockaddr_storage addr; + socklen_t addr_len; + uint64_t last_activity_ns; + uint64_t total_bytes; + bool active; +}; + +struct srtla_group { + uint8_t group_id[SRTLA_GROUP_ID_LEN]; + struct srtla_conn conns[SRTLA_MAX_CONNS]; + int num_conns; + SRTLA_SOCKET srt_sock; + uint64_t last_activity_ns; + bool active; + /* SRTLA ACK tracking */ + uint32_t ack_sns[SRTLA_ACK_MAX_SNS]; + int ack_sn_count; +}; + +struct srtla_state { + SRTLA_SOCKET listen_sock; + struct srtla_group groups[SRTLA_MAX_GROUPS]; + int srt_port; + int listen_port; + volatile bool running; + pthread_t thread; + bool thread_created; +}; + +void srtla_server_start(struct srtla_state *state, int listen_port, + int srt_port); +void srtla_server_stop(struct srtla_state *state); diff --git a/src/stats-dialog.cpp b/src/stats-dialog.cpp new file mode 100644 index 0000000..a01bebd --- /dev/null +++ b/src/stats-dialog.cpp @@ -0,0 +1,347 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "stats-dialog.hpp" + +extern "C" { +#include "irl-source.h" +} + +/* ---- helpers ---- */ + +static QString fmt_bytes(uint64_t b) +{ + if (b < 1024) + return QString("%1 B").arg(b); + if (b < 1024ULL * 1024) + return QString("%1 KB").arg(b / 1024.0, 0, 'f', 1); + if (b < 1024ULL * 1024 * 1024) + return QString("%1 MB").arg(b / (1024.0 * 1024.0), 0, 'f', 1); + return QString("%1 GB").arg(b / (1024.0 * 1024.0 * 1024.0), 0, 'f', + 2); +} + +static QString fmt_bitrate(int64_t kbps) +{ + if (kbps <= 0) + return "-"; + if (kbps < 1000) + return QString("%1 kbps").arg(kbps); + return QString("%1 Mbps").arg(kbps / 1000.0, 0, 'f', 1); +} + +static QString fmt_uptime(uint64_t start_ns) +{ + if (!start_ns) + return "-"; + uint64_t now = os_gettime_ns(); + uint64_t sec = (now > start_ns) ? (now - start_ns) / 1000000000ULL : 0; + int h = (int)(sec / 3600); + int m = (int)((sec % 3600) / 60); + int s = (int)(sec % 60); + return QString("%1:%2:%3") + .arg(h, 2, 10, QChar('0')) + .arg(m, 2, 10, QChar('0')) + .arg(s, 2, 10, QChar('0')); +} + +/* ---- widget ---- */ + +static const char *status_colors[] = {"#888888", "#e0a020", "#20c040", + "#e04040"}; + +class StreamStatsWidget : public QWidget { +public: + StreamStatsWidget(bool is_de, QWidget *parent = nullptr) + : QWidget(parent), + m_de(is_de) + { + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + buildUI(); + + m_timer = new QTimer(this); + QObject::connect(m_timer, &QTimer::timeout, + [this]() { refresh(); }); + m_timer->start(500); + refresh(); + } + +private: + bool m_de; + QLabel *m_dot, *m_status; + QLabel *m_lbls[4], *m_vals[4]; + QLabel *m_videoLine, *m_audioLine, *m_serverLine; + QTimer *m_timer; + int64_t m_prevFrames = 0; + uint64_t m_prevTime = 0; + double m_fps = 0.0; + + QLabel *makeLabel(const QString &text, int ptDelta, bool bold, + const QString &color) + { + auto *l = new QLabel(text, this); + QFont f = font(); + f.setPointSize(f.pointSize() + ptDelta); + f.setBold(bold); + l->setFont(f); + if (!color.isEmpty()) + l->setStyleSheet( + QString("QLabel{color:%1}").arg(color)); + l->setTextInteractionFlags(Qt::NoTextInteraction); + return l; + } + + void buildUI() + { + QString dim = palette() + .color(QPalette::PlaceholderText) + .name(); + QString acc = palette().color(QPalette::Highlight).name(); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(12, 10, 12, 10); + root->setSpacing(8); + + /* Row 1 — status */ + auto *row1 = new QHBoxLayout(); + row1->setSpacing(8); + + m_dot = new QLabel(this); + m_dot->setFixedSize(10, 10); + m_dot->setStyleSheet( + "QLabel{background:#888;border-radius:5px;" + "min-width:10px;min-height:10px}"); + row1->addWidget(m_dot, 0, Qt::AlignVCenter); + + m_status = makeLabel(m_de ? "Inaktiv" : "Idle", 1, true, ""); + row1->addWidget(m_status, 0, Qt::AlignVCenter); + row1->addStretch(); + root->addLayout(row1); + + /* Row 2 — stats grid */ + auto *grid = new QGridLayout(); + grid->setHorizontalSpacing(12); + grid->setVerticalSpacing(2); + for (int c = 0; c < 4; c++) + grid->setColumnStretch(c, 1); + + QFont lblFont = font(); + lblFont.setPointSize(lblFont.pointSize() - 2); + lblFont.setBold(true); + + QFont valFont("Consolas", font().pointSize() + 2); + valFont.setBold(true); + + QString headers[4] = {"BITRATE", "FPS", + m_de ? "UPTIME" : "UPTIME", + m_de ? "DATEN" : "DATA"}; + + for (int c = 0; c < 4; c++) { + m_lbls[c] = new QLabel(headers[c], this); + m_lbls[c]->setFont(lblFont); + m_lbls[c]->setStyleSheet( + QString("QLabel{color:%1}").arg(dim)); + m_lbls[c]->setTextInteractionFlags( + Qt::NoTextInteraction); + m_lbls[c]->setAlignment(Qt::AlignLeft | + Qt::AlignBottom); + grid->addWidget(m_lbls[c], 0, c); + + m_vals[c] = new QLabel("-", this); + m_vals[c]->setFont(valFont); + m_vals[c]->setStyleSheet( + QString("QLabel{color:%1}").arg(acc)); + m_vals[c]->setTextInteractionFlags( + Qt::NoTextInteraction); + m_vals[c]->setAlignment(Qt::AlignLeft | + Qt::AlignTop); + grid->addWidget(m_vals[c], 1, c); + } + root->addLayout(grid); + + /* Separator */ + auto *sep = new QFrame(this); + sep->setFrameShape(QFrame::HLine); + sep->setFrameShadow(QFrame::Sunken); + root->addWidget(sep); + + /* Info lines */ + m_videoLine = makeLabel("-", -1, false, ""); + m_audioLine = makeLabel("-", -1, false, ""); + m_serverLine = makeLabel("-", -1, false, dim); + root->addWidget(m_videoLine); + root->addWidget(m_audioLine); + root->addWidget(m_serverLine); + + root->addStretch(); + } + + void refresh() + { + struct irl_source_data *d = nullptr; + for (int i = 0; i < g_irl_source_count && i < MAX_IRL_SOURCES; + i++) { + if (g_irl_sources[i]) { + d = g_irl_sources[i]; + break; + } + } + + if (!d) { + setNoSource(); + return; + } + + long state = os_atomic_load_long(&d->connection_state); + if (state < 0 || state > 3) + state = 0; + bool conn = (state == CONN_STATE_CONNECTED); + + static const char *de[] = {"Inaktiv", "Wartet\xe2\x80\xa6", + "Verbunden", "Getrennt"}; + static const char *en[] = {"Idle", "Listening\xe2\x80\xa6", + "Connected", "Disconnected"}; + + m_dot->setStyleSheet( + QString("QLabel{background:%1;border-radius:5px;" + "min-width:10px;min-height:10px}") + .arg(status_colors[state])); + m_status->setText(m_de ? de[state] : en[state]); + + /* Bitrate */ + m_vals[0]->setText( + conn ? fmt_bitrate(d->current_bitrate_kbps) : "-"); + + /* FPS */ + uint64_t now = os_gettime_ns(); + int64_t frames = d->stats_total_frames; + if (m_prevTime > 0 && now > m_prevTime) { + double dt = (double)(now - m_prevTime) / 1e9; + int64_t df = frames - m_prevFrames; + if (dt > 0.05 && df >= 0) + m_fps = df / dt; + } + m_prevFrames = frames; + m_prevTime = now; + m_vals[1]->setText(conn && m_fps > 0.1 + ? QString::number(m_fps, 'f', 1) + : "-"); + + /* Uptime */ + m_vals[2]->setText( + conn ? fmt_uptime(d->stats_connect_time_ns) : "-"); + + /* Data */ + uint64_t tb = d->stats_total_bytes; + m_vals[3]->setText(tb > 0 ? fmt_bytes(tb) : "-"); + + /* Video */ + if (d->stats_video_codec[0]) { + QString v = QString("Video: %1 %2\u00d7%3") + .arg(QString(d->stats_video_codec) + .toUpper()) + .arg(d->stats_video_width) + .arg(d->stats_video_height); + if (d->stats_video_pixfmt[0]) + v += QString(" %1").arg(d->stats_video_pixfmt); + v += QString(" \u00b7 Frames: %L1").arg(frames); + m_videoLine->setText(v); + } else { + m_videoLine->setText("-"); + } + + /* Audio */ + if (d->stats_audio_codec[0]) + m_audioLine->setText( + QString("Audio: %1 %2 Hz") + .arg(QString(d->stats_audio_codec) + .toUpper()) + .arg(d->stats_audio_sample_rate)); + else + m_audioLine->setText("-"); + + /* Server */ + pthread_mutex_lock(&d->mutex); + int proto = d->protocol; + int port = d->port; + bool srtla = d->srtla_enabled; + int srtla_p = d->srtla_port; + pthread_mutex_unlock(&d->mutex); + + QString s = QString("%1 \u00b7 Port %2") + .arg(proto == PROTOCOL_RTMP ? "RTMP" + : "SRT") + .arg(port); + if (srtla) + s += QString(" \u00b7 SRTLA \u2713 (:%1)") + .arg(srtla_p); + m_serverLine->setText(s); + } + + void setNoSource() + { + m_dot->setStyleSheet( + "QLabel{background:#888;border-radius:5px;" + "min-width:10px;min-height:10px}"); + m_status->setText(m_de ? "Keine Quelle" : "No source"); + for (int c = 0; c < 4; c++) + m_vals[c]->setText("-"); + m_videoLine->setText("-"); + m_audioLine->setText("-"); + m_serverLine->setText("-"); + m_fps = 0; + m_prevFrames = 0; + m_prevTime = 0; + } +}; + +/* ---- dock creation ---- */ + +static QDockWidget *g_dock = nullptr; + +extern "C" void stats_dialog_show(const char *locale) +{ + if (g_dock) { + g_dock->setVisible(!g_dock->isVisible()); + if (g_dock->isVisible()) { + g_dock->raise(); + g_dock->activateWindow(); + } + return; + } + + bool is_de = locale && (strncmp(locale, "de", 2) == 0); + + QMainWindow *main = (QMainWindow *)obs_frontend_get_main_window(); + if (!main) + return; + + g_dock = new QDockWidget( + QString("Easy IRL Stream \u2014 Monitor"), main); + g_dock->setObjectName("EasyIRLStreamMonitorDock"); + g_dock->setWidget(new StreamStatsWidget(is_de, g_dock)); + g_dock->setAllowedAreas(Qt::AllDockWidgetAreas); + g_dock->setFeatures(QDockWidget::DockWidgetMovable | + QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetClosable); + + QObject::connect(g_dock, &QDockWidget::destroyed, + []() { g_dock = nullptr; }); + + main->addDockWidget(Qt::BottomDockWidgetArea, g_dock); + g_dock->setFloating(true); + g_dock->resize(480, 230); + g_dock->show(); +} diff --git a/src/stats-dialog.hpp b/src/stats-dialog.hpp new file mode 100644 index 0000000..434cdf8 --- /dev/null +++ b/src/stats-dialog.hpp @@ -0,0 +1,11 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +void stats_dialog_show(const char *locale); + +#ifdef __cplusplus +} +#endif diff --git a/src/translations.h b/src/translations.h new file mode 100644 index 0000000..ddd33a7 --- /dev/null +++ b/src/translations.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +static inline int tr_is_de(void) +{ + const char *loc = obs_get_locale(); + return loc && loc[0] == 'd' && loc[1] == 'e'; +} + +static inline const char *tr_source_name(void) +{ + return "Easy IRL Stream"; +} + +static inline const char *tr_api_token(void) +{ + return tr_is_de() ? "stools.cc API-Token" : "stools.cc API Token"; +} + +static inline const char *tr_login_button(void) +{ + return tr_is_de() ? "Bei stools.cc anmelden" + : "Sign in with stools.cc"; +} + +static inline const char *tr_api_info(void) +{ + return tr_is_de() + ? "Alle Einstellungen werden auf stools.cc/dashboard/plugin verwaltet.\n" + "1. Klicke oben auf den Button um stools.cc zu \xc3\xb6""ffnen\n" + "2. Erstelle einen Token und kopiere ihn\n" + "3. F\xc3\xbc""ge ihn im API-Token-Feld ein" + : "All settings are managed at stools.cc/dashboard/plugin\n" + "1. Click the button above to open stools.cc\n" + "2. Create a token and copy it\n" + "3. Paste it into the API Token field"; +} + +static inline const char *tr_tools_menu_help(void) +{ + return tr_is_de() ? "Easy IRL Stream - Hilfe" + : "Easy IRL Stream - Help"; +} + +static inline const char *tr_tools_menu_stats(void) +{ + return "Easy IRL Stream - Stream Monitor"; +} diff --git a/src/webhook.c b/src/webhook.c new file mode 100644 index 0000000..2e5dc21 --- /dev/null +++ b/src/webhook.c @@ -0,0 +1,216 @@ +#include "webhook.h" +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +typedef SOCKET sock_t; +#define SOCK_INVALID INVALID_SOCKET +#define sock_close closesocket +#else +#include +#include +#include +#include +typedef int sock_t; +#define SOCK_INVALID (-1) +#define sock_close close +#endif + +struct webhook_args { + char *url; + char *event_name; + char *source_name; +}; + +struct cmd_args { + char *command; +}; + +static bool parse_url(const char *url, char *host, size_t host_sz, + char *port, size_t port_sz, char *path, size_t path_sz) +{ + const char *p = url; + + if (strncmp(p, "http://", 7) == 0) { + p += 7; + snprintf(port, port_sz, "80"); + } else if (strncmp(p, "https://", 8) == 0) { + p += 8; + snprintf(port, port_sz, "443"); + } else { + return false; + } + + const char *slash = strchr(p, '/'); + const char *colon = strchr(p, ':'); + + if (colon && (!slash || colon < slash)) { + size_t hlen = (size_t)(colon - p); + if (hlen >= host_sz) + hlen = host_sz - 1; + memcpy(host, p, hlen); + host[hlen] = '\0'; + + colon++; + const char *pend = slash ? slash : colon + strlen(colon); + size_t plen = (size_t)(pend - colon); + if (plen >= port_sz) + plen = port_sz - 1; + memcpy(port, colon, plen); + port[plen] = '\0'; + } else { + size_t hlen = slash ? (size_t)(slash - p) + : strlen(p); + if (hlen >= host_sz) + hlen = host_sz - 1; + memcpy(host, p, hlen); + host[hlen] = '\0'; + } + + if (slash) + snprintf(path, path_sz, "%s", slash); + else + snprintf(path, path_sz, "/"); + + return true; +} + +static void webhook_do_send(const char *url, const char *event_name, + const char *source_name) +{ + char host[256] = {0}; + char port_str[16] = {0}; + char path[512] = {0}; + + if (!parse_url(url, host, sizeof(host), port_str, sizeof(port_str), + path, sizeof(path))) { + blog(LOG_WARNING, "[%s] Webhook: invalid URL '%s'", + "Easy IRL Stream", url); + return; + } + +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + + struct addrinfo hints = {0}; + struct addrinfo *res = NULL; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + if (getaddrinfo(host, port_str, &hints, &res) != 0) { + blog(LOG_WARNING, "[%s] Webhook: DNS lookup failed for '%s'", + "Easy IRL Stream", host); + return; + } + + sock_t sock = socket(res->ai_family, res->ai_socktype, + res->ai_protocol); + if (sock == SOCK_INVALID) { + freeaddrinfo(res); + return; + } + + if (connect(sock, res->ai_addr, (int)res->ai_addrlen) != 0) { + freeaddrinfo(res); + sock_close(sock); + return; + } + freeaddrinfo(res); + + char body[1024]; + snprintf(body, sizeof(body), + "{\"event\":\"%s\",\"source\":\"%s\",\"timestamp\":%lld}", + event_name, source_name, (long long)time(NULL)); + + char request[2048]; + snprintf(request, sizeof(request), + "POST %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n" + "\r\n" + "%s", + path, host, (int)strlen(body), body); + + send(sock, request, (int)strlen(request), 0); + + char buf[512]; + while (recv(sock, buf, sizeof(buf), 0) > 0) { + } + + sock_close(sock); + + blog(LOG_DEBUG, "[%s] Webhook sent: %s -> %s", "Easy IRL Stream", + event_name, url); +} + +static void *webhook_thread_func(void *arg) +{ + struct webhook_args *wa = arg; + webhook_do_send(wa->url, wa->event_name, wa->source_name); + bfree(wa->url); + bfree(wa->event_name); + bfree(wa->source_name); + bfree(wa); + return NULL; +} + +void webhook_send_async(const char *url, const char *event_name, + const char *source_name) +{ + if (!url || !url[0]) + return; + + struct webhook_args *wa = bzalloc(sizeof(*wa)); + wa->url = bstrdup(url); + wa->event_name = bstrdup(event_name); + wa->source_name = bstrdup(source_name); + + pthread_t thread; + if (pthread_create(&thread, NULL, webhook_thread_func, wa) == 0) { + pthread_detach(thread); + } else { + bfree(wa->url); + bfree(wa->event_name); + bfree(wa->source_name); + bfree(wa); + } +} + +static void *cmd_thread_func(void *arg) +{ + struct cmd_args *ca = arg; + blog(LOG_DEBUG, "[%s] Executing command: %s", "Easy IRL Stream", + ca->command); + (void)system(ca->command); + bfree(ca->command); + bfree(ca); + return NULL; +} + +void webhook_execute_command_async(const char *command) +{ + if (!command || !command[0]) + return; + + struct cmd_args *ca = bzalloc(sizeof(*ca)); + ca->command = bstrdup(command); + + pthread_t thread; + if (pthread_create(&thread, NULL, cmd_thread_func, ca) == 0) { + pthread_detach(thread); + } else { + bfree(ca->command); + bfree(ca); + } +} diff --git a/src/webhook.h b/src/webhook.h new file mode 100644 index 0000000..453f419 --- /dev/null +++ b/src/webhook.h @@ -0,0 +1,6 @@ +#pragma once + +void webhook_send_async(const char *url, const char *event_name, + const char *source_name); + +void webhook_execute_command_async(const char *command);